Compare commits
17 Commits
05159a3208
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e664f6e307 | |||
| ab7540c757 | |||
| 65ea0640f8 | |||
| 6e3f49bb94 | |||
| 040cc2ae46 | |||
| fee43cec3e | |||
| 136cc966c7 | |||
| ca83f34b11 | |||
| c29dc64c38 | |||
| 6aa7a47f14 | |||
| 9e45d51ed2 | |||
| ce602a454f | |||
| 0ec150d703 | |||
| a8f4b23977 | |||
| 4adf5b7b9f | |||
| bb7de1dcb6 | |||
| 8854c3d8ac |
@@ -14,7 +14,7 @@ const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: path.resolve(__dirname),
|
||||
},
|
||||
// output: "export" removed to allow server actions
|
||||
output: "standalone",
|
||||
// Required by the How It Works 3D experience. React StrictMode double-invokes
|
||||
// mount/effects in dev, which tears down and re-creates the WebGL context of
|
||||
// the heavy 32MB scene mid-initialization — the context is lost ("THREE.
|
||||
|
||||
@@ -14856,7 +14856,7 @@ img:is([sizes=auto i]) {
|
||||
}
|
||||
|
||||
.elementor-element.elementor-element-7da6646:not(.elementor-motion-effects-element-type-background) {
|
||||
background-image: url("/images/bg-header-5.webp");
|
||||
background-image: url("/images/ev.webp");
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
@@ -16678,6 +16678,20 @@ body:not(.rtl) .elementor-6585 .elementor-element.elementor-element-8899bdf {
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer's own breakpoint above switches horizontal padding at 1020px, but
|
||||
every other section's shared container (.sw-in, and the Hero's matching
|
||||
padding) switches at 1024px — leaving a 1021-1024px viewport gap where
|
||||
the footer sits 10px further in than everything above it (e.g. iPad
|
||||
portrait, 1024px wide). This closes that gap without touching the
|
||||
1020px block above, which also carries unrelated vertical-rhythm rules
|
||||
for other footer elements. */
|
||||
@media (max-width: 1024px) and (min-width: 1021px) {
|
||||
.elementor-6585 .elementor-element.elementor-element-3cd920c {
|
||||
--padding-left: 10px;
|
||||
--padding-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width: 840px) {
|
||||
.elementor-6585 .elementor-element.elementor-element-2631b42 {
|
||||
--flex-direction:row;
|
||||
|
||||
1
public/doorven.svg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
public/images/bg-header-5.png
Normal file
|
After Width: | Height: | Size: 6.3 MiB |
|
Before Width: | Height: | Size: 94 KiB |
BIN
public/images/doormile-mark.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/images/ev fleet1.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/images/ev section1.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/images/ev solution 4.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
public/images/miletruth-ai-core.png
Normal file
|
After Width: | Height: | Size: 489 KiB |
BIN
public/images/workflow4.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
1
public/live-track.svg
Normal file
|
After Width: | Height: | Size: 32 KiB |
24
scripts/probe.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const w of [1600, 1440, 1366, 1280, 1180, 1024, 992, 900, 768, 640, 480, 390]) {
|
||||
const page = await browser.newPage({ viewport: { width: w, height: 900 }, deviceScaleFactor: 1 });
|
||||
await page.goto('http://localhost:3000/miletruth', { waitUntil: 'load', timeout: 90000 });
|
||||
await page.waitForTimeout(1200);
|
||||
const r = await page.evaluate(() => {
|
||||
const copy = document.querySelector('#solution-miletruth-ai .sw-mt-copy');
|
||||
const flow = document.querySelector('#solution-miletruth-ai .sw-mt-flow');
|
||||
const labels = [...flow.querySelectorAll('.sw-mt-step-label')];
|
||||
return {
|
||||
copyW: Math.round(copy.getBoundingClientRect().width),
|
||||
// widest single unbreakable label vs the track it was given
|
||||
worst: labels.map(e => {
|
||||
const track = e.parentElement.getBoundingClientRect().width;
|
||||
return { txt: e.textContent, need: Math.round(e.scrollWidth), track: Math.round(track) };
|
||||
}).filter(x => x.need > x.track + 1),
|
||||
};
|
||||
});
|
||||
console.log(`${String(w).padStart(4)} copy=${String(r.copyW).padStart(3)} overflow=${r.worst.length ? JSON.stringify(r.worst) : 'none'}`);
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
49
scripts/shot-miletruth.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const shots = [
|
||||
{ name: 'desktop-1440', w: 1440, h: 1000 },
|
||||
{ name: 'laptop-1280', w: 1280, h: 900 },
|
||||
{ name: 'tablet-1024', w: 1024, h: 950 },
|
||||
{ name: 'mobile-390', w: 390, h: 844 },
|
||||
];
|
||||
|
||||
for (const s of shots) {
|
||||
// fresh browser each time: reusing one exhausts software WebGL contexts
|
||||
const browser = await chromium.launch({
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: s.w, height: s.h }, deviceScaleFactor: 1 });
|
||||
const errs = [];
|
||||
page.on('pageerror', e => errs.push('pageerror: ' + e.message));
|
||||
|
||||
await page.goto('http://localhost:3000/miletruth', { waitUntil: 'load', timeout: 90000 });
|
||||
const toSec = () => page.evaluate(() => {
|
||||
const el = document.querySelector('#solution-miletruth-ai');
|
||||
if (el) window.scrollTo(0, window.scrollY + el.getBoundingClientRect().top - 8);
|
||||
});
|
||||
await toSec();
|
||||
await page.waitForTimeout(18000);
|
||||
await toSec();
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const sec = document.querySelector('#solution-miletruth-ai');
|
||||
const scene = sec.querySelector('.mtn-scene');
|
||||
const sb = scene.getBoundingClientRect();
|
||||
return {
|
||||
canvas: sec.querySelectorAll('canvas').length,
|
||||
labels: [...scene.querySelectorAll('.mtn-lbl')].map(e => {
|
||||
const r = e.getBoundingClientRect();
|
||||
const clip = r.left < sb.left || r.right > sb.right || r.top < sb.top || r.bottom > sb.bottom;
|
||||
return e.querySelector('.mtn-lbl-n').textContent + (clip ? ' CLIPPED' : '');
|
||||
}),
|
||||
stepRows: new Set([...sec.querySelectorAll('.sw-mt-step')].map(e => Math.round(e.getBoundingClientRect().top))).size,
|
||||
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
});
|
||||
console.log(`${s.name}: ` + JSON.stringify(info));
|
||||
if (errs.length) console.log(' ERRORS:', errs.slice(0, 3));
|
||||
|
||||
await page.screenshot({ path: `/tmp/mt-${s.name}.png`, timeout: 90000 });
|
||||
await browser.close();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import SingleBlog from "@/components/sections/SingleBlog";
|
||||
import BlogPostFooter from "@/components/sections/BlogPostFooter";
|
||||
import { getPostBySlug, getAllSlugs, SITE_URL } from "@/data/blog";
|
||||
import { getPostBySlug, getAllSlugs, SITE_URL } from "@/shared/data/blog";
|
||||
|
||||
type Params = { slug: string };
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import BlogGrid from "@/components/sections/BlogGrid";
|
||||
|
||||
export const metadata = {
|
||||
title: "Blog – Doormile",
|
||||
description: "Practical notes on delivery planning, EV fleet operations, route optimisation, charging, and last-mile performance from the Doormile team.",
|
||||
description: "Practical notes on logistics planning, EV fleet operations, route intelligence, charging, and last-mile performance from the Doormile team.",
|
||||
};
|
||||
|
||||
export default function BlogPage() {
|
||||
|
||||
@@ -11,13 +11,13 @@ import WingsRoadmap from "@/components/sections/WingsRoadmap";
|
||||
|
||||
export const metadata = {
|
||||
title: "Doormile Wings – Redefining Domestic Air Logistics Through AI",
|
||||
description: "Doormile Wings links India's major regional capitals with AI-powered, same-day air logistics and zero-emission EV last-mile delivery — where quick commerce meets quick logistics.",
|
||||
description: "Doormile Wings links India's major regional capitals with AI-powered, same-day air logistics and zero-emission EV last-mile transport — where quick commerce meets quick logistics.",
|
||||
alternates: {
|
||||
canonical: "https://doormile.com/doormile-wings",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Doormile Wings – Redefining Domestic Air Logistics Through AI",
|
||||
description: "Same-day air logistics linking India's major regional capitals, powered by MileTruth™ AI and zero-emission EV last-mile delivery.",
|
||||
description: "Same-day air logistics linking India's major regional capitals, powered by MileTruth™ AI and zero-emission EV last-mile transport.",
|
||||
url: "https://doormile.com/doormile-wings",
|
||||
siteName: "Doormile",
|
||||
type: "website",
|
||||
@@ -33,7 +33,7 @@ export const metadata = {
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Doormile Wings – Redefining Domestic Air Logistics Through AI",
|
||||
description: "Same-day air logistics linking India's major regional capitals, powered by MileTruth™ AI and zero-emission EV last-mile delivery.",
|
||||
description: "Same-day air logistics linking India's major regional capitals, powered by MileTruth™ AI and zero-emission EV last-mile transport.",
|
||||
images: ["https://doormile.com/images/wings-logo.png"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function EmpowermentPage() {
|
||||
<EmpowermentVision />
|
||||
<EmpowermentStory />
|
||||
<WomenSection
|
||||
eyebrow="/ Doormile Wings /"
|
||||
eyebrow="/ Women Empowerment /"
|
||||
bannerLabel="Women Entrepreneurship"
|
||||
bannerCopy="An ecosystem where women build, own, and scale enterprises across logistics."
|
||||
ctaLabel="Explore the ecosystem"
|
||||
|
||||
@@ -22,13 +22,30 @@ html {
|
||||
Note: setting overflow-x:hidden paired with overflow-y:visible gets
|
||||
silently coerced to overflow-y:auto by the CSS spec (an "auto" value on
|
||||
one axis when the other is non-visible) — which ALSO still counts as a
|
||||
scroll container and breaks sticky the same way. So this must be fully
|
||||
visible on both axes. <body> already has overflow-x: hidden site-wide
|
||||
(from the same legacy stylesheet), so horizontal bleed is still safely
|
||||
clipped there — this rule doesn't need to repeat it.
|
||||
scroll container and breaks sticky the same way.
|
||||
|
||||
`overflow-x: clip` is the exception the spec carves out for exactly this:
|
||||
`clip` + `visible` is a legal pair that is NOT coerced, and `clip` does not
|
||||
create a scroll container — so `position: sticky` keeps working while the
|
||||
horizontal clip that the legacy layout depends on is restored.
|
||||
|
||||
That horizontal clip matters because the off-canvas mobile menu drawer
|
||||
(`.mobile-header-menu-container`, site.css) is parked at
|
||||
`position: fixed; right: -320px; width: 320px` on EVERY page. Its
|
||||
containing block is the header container `.elementor-element-466de1b`
|
||||
(it carries a transform), which is absolutely positioned against
|
||||
`.body-container` — so with `.body-container` fully visible, that parked
|
||||
drawer extended the document's scrollable overflow by 320px sitewide.
|
||||
<body>'s `overflow-x: hidden` (legacy stylesheet) propagates to the
|
||||
viewport, which suppresses the scrollbar but still leaves the 320px in the
|
||||
scrollable overflow region — so `documentElement.scrollWidth` read
|
||||
`viewport + 320`. Mobile Chrome sizes its initial page scale from that
|
||||
width, which is what made every page load shrunk-to-fit and pinned to the
|
||||
top-left until the first scroll forced a re-fit.
|
||||
============================================================ */
|
||||
.body-container {
|
||||
overflow: visible !important;
|
||||
overflow-x: clip !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -4,7 +4,7 @@ import Experience3DLoader from "@/modules/how-it-works-3d/Experience3DLoader";
|
||||
|
||||
export const metadata = {
|
||||
title: "How It Works – Doormile",
|
||||
description: "See how Doormile connects first, mid, and last mile into a seamless delivery experience powered by MileTruth™ AI.",
|
||||
description: "See how Doormile connects first, mid, and last mile into a seamless end-to-end logistics experience powered by MileTruth™ AI.",
|
||||
};
|
||||
|
||||
export default function HowItWorksPage() {
|
||||
|
||||
35
src/app/industries/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import SolutionsHero from "@/components/sections/SolutionsHero";
|
||||
import IndustryStack from "@/features/industries/IndustryStack";
|
||||
|
||||
/**
|
||||
* Industries — "Who Doormile serves".
|
||||
*
|
||||
* This is the original industry-focused content that used to live at /solutions
|
||||
* (see commit 205924e): the industry hero slider plus the IndustryStack cards
|
||||
* ported from doormile-site/includes/sections/solution/card-1.php. The FMCG,
|
||||
* Pharma and Enterprise & B2B copy is unchanged from that original; Retail and
|
||||
* Quick Commerce were added later in the same layout.
|
||||
*
|
||||
* /solutions now answers "what Doormile offers" instead.
|
||||
*/
|
||||
export const metadata = {
|
||||
title: "Industries – Doormile",
|
||||
description:
|
||||
"Discover how Doormile's connected logistics platform serves diverse industries — FMCG, Pharma, Enterprise & B2B, Retail and Quick Commerce — with tailored solutions.",
|
||||
};
|
||||
|
||||
export default function IndustriesPage() {
|
||||
return (
|
||||
<div id="industries" className="content-wrapper content-wrapper-may-contain-elementor-code content-wrapper-sidebar-position-none">
|
||||
<div className="content">
|
||||
<div className="content-inner">
|
||||
<div data-elementor-type="wp-page" data-elementor-id="59" className="elementor elementor-59">
|
||||
<SolutionsHero />
|
||||
<IndustryStack />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
/* eslint-disable @next/next/no-css-tags */
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Manrope, Space_Grotesk, Syne, DM_Sans, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import BodyClasses from "@/components/layout/BodyClasses";
|
||||
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";
|
||||
import Header from "@/shared/layout/Header";
|
||||
import Footer from "@/shared/layout/Footer";
|
||||
import BodyClasses from "@/shared/layout/BodyClasses";
|
||||
import BodyOverlay from "@/shared/layout/BodyOverlay";
|
||||
import { HeaderUIProvider } from "@/shared/layout/HeaderUIProvider";
|
||||
import { SHARED_BODY_CLASSES } from "@/shared/lib/bodyClasses";
|
||||
import AnimationProvider from "@/shared/layout/AnimationProvider";
|
||||
import SmoothScroll from "@/shared/layout/SmoothScroll";
|
||||
import LoadingScreen from "@/shared/layout/LoadingScreen";
|
||||
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin"],
|
||||
@@ -58,6 +58,17 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
// Single source of truth for the viewport meta tag. Declared here (rather than
|
||||
// hand-written in <head>) so Next emits exactly one, early in <head>:
|
||||
// <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
// `viewport-fit: cover` is inert for this site — no stylesheet uses
|
||||
// env(safe-area-inset-*) — so it changes nothing visually.
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import React from "react";
|
||||
import MileTruthHero from "../../components/sections/MileTruthHero";
|
||||
import MileTruthIntro from "../../components/sections/MileTruthIntro";
|
||||
import Workflow1 from "../../components/sections/Workflow1";
|
||||
import Workflow2 from "../../components/sections/Workflow2";
|
||||
import Workflow4 from "../../components/sections/Workflow4";
|
||||
import Workflow3Lazy from "../../components/sections/Workflow3Lazy";
|
||||
import {
|
||||
SolutionAIVehicleSelection,
|
||||
SolutionSmartRouteIntelligence,
|
||||
SolutionQuantumDecisionEngine,
|
||||
SolutionLiveLogisticsIntelligence,
|
||||
SolutionMileTruthAI,
|
||||
SolutionOperationsDashboard,
|
||||
} from "../../components/sections/SolutionsWorkflow";
|
||||
|
||||
export const metadata = {
|
||||
title: "MileTruth – Doormile",
|
||||
description: "Optimizes every stage of the delivery journey for maximum efficiency. Explore the only AI built exclusively for logistics.",
|
||||
description: "Optimizes every stage of the logistics journey for maximum efficiency. Explore the only AI built exclusively for logistics.",
|
||||
};
|
||||
|
||||
export default function MileTruthPage() {
|
||||
@@ -17,9 +26,16 @@ export default function MileTruthPage() {
|
||||
<div className="content-inner">
|
||||
<div data-elementor-type="wp-page" data-elementor-id="59" className="elementor elementor-59">
|
||||
<MileTruthHero />
|
||||
<MileTruthIntro />
|
||||
<SolutionAIVehicleSelection />
|
||||
<Workflow1 />
|
||||
<SolutionSmartRouteIntelligence />
|
||||
<Workflow2 />
|
||||
<SolutionQuantumDecisionEngine />
|
||||
<Workflow4 />
|
||||
<SolutionLiveLogisticsIntelligence />
|
||||
<SolutionMileTruthAI />
|
||||
<SolutionOperationsDashboard />
|
||||
<Workflow3Lazy />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import IndexHero from "@/components/sections/IndexHero";
|
||||
import StatsBar from "@/components/sections/StatsBar";
|
||||
import TheProblem from "@/components/sections/TheProblem";
|
||||
import MarqueeTicker from "@/components/sections/MarqueeTicker";
|
||||
import IndexHero from "@/features/home/IndexHero";
|
||||
import StatsBar from "@/features/home/StatsBar";
|
||||
import TheProblem from "@/features/home/TheProblem";
|
||||
import MarqueeTicker from "@/features/home/MarqueeTicker";
|
||||
import ConnectedLogistics from "@/components/sections/ConnectedLogistics";
|
||||
import TheDoormileWay from "@/components/sections/TheDoormileWay";
|
||||
import EVSection from "@/components/sections/EVSection";
|
||||
import IndustrySolutions from "@/components/sections/IndustrySolutions";
|
||||
import IndustrySolutions from "@/features/home/IndustrySolutions";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Doormile — Delivering Trust. Beyond Boundaries",
|
||||
@@ -27,7 +27,7 @@ export default function Home() {
|
||||
<MarqueeTicker />
|
||||
<ConnectedLogistics />
|
||||
<TheDoormileWay />
|
||||
<EVSection />
|
||||
<EVSection bannerImage="/images/bg-header-5.png" image="/images/premium-ev-van.webp" />
|
||||
<IndustrySolutions />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { blogPosts, SITE_URL } from "@/data/blog";
|
||||
import { blogPosts, SITE_URL } from "@/shared/data/blog";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const staticRoutes = [
|
||||
@@ -8,11 +10,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
"/contact",
|
||||
"/blog",
|
||||
"/air-freight",
|
||||
"/first-mile-delivery",
|
||||
"/mid-mile-delivery",
|
||||
"/last-mile-delivery",
|
||||
"/ev-logistics",
|
||||
"/strategy-optimization",
|
||||
"/solutions",
|
||||
"/industries",
|
||||
"/how-it-works",
|
||||
].map((route) => ({
|
||||
url: `${SITE_URL}${route}`,
|
||||
|
||||
@@ -1,65 +1,133 @@
|
||||
import React from "react";
|
||||
import SolutionsHero from "@/components/sections/SolutionsHero";
|
||||
import IndustryStack from "@/components/sections/IndustryStack";
|
||||
import SolutionsHero, { type HeroSlide } from "@/components/sections/SolutionsHero";
|
||||
import Miles3 from "@/components/sections/Miles3";
|
||||
import PlatformCapabilities from "@/components/sections/PlatformCapabilities";
|
||||
import DeliveredCounter from "@/components/sections/DeliveredCounter";
|
||||
import EVSection, { type EVFeature } from "@/components/sections/EVSection";
|
||||
import {
|
||||
SolutionsSectionsShell,
|
||||
SolOfferings,
|
||||
SolWingsSpotlight,
|
||||
SolMileTruthBand,
|
||||
SolutionsWhyDoormile,
|
||||
SolutionsResults,
|
||||
} from "@/components/sections/SolutionsSections";
|
||||
|
||||
/**
|
||||
* Solutions — "What Doormile offers".
|
||||
*
|
||||
* Services only: first mile, mid mile, last mile, Doormile Wings, EV fleet, and
|
||||
* the fact that all of it is powered by MileTruth™. The technology story (AI
|
||||
* architecture, routing, the decision engine) belongs to /miletruth and is
|
||||
* deliberately not repeated here; industry detail belongs to /industries.
|
||||
*/
|
||||
export const metadata = {
|
||||
title: "Solutions – Doormile",
|
||||
description: "Discover how Doormile's connected logistics platform serves diverse industries (FMCG, Pharma, and Enterprise) with tailored solutions.",
|
||||
description:
|
||||
"What Doormile offers: first mile pickup, mid mile line haul, last mile delivery, same-day air with Doormile Wings, and an electric fleet — every mile powered by MileTruth™.",
|
||||
};
|
||||
|
||||
const SOLUTIONS_SLIDES: HeroSlide[] = [
|
||||
{
|
||||
heading: "One Partner for Every Mile",
|
||||
text: "First mile, mid mile and last mile — on road and in the air, with an electric fleet and one tracking link end to end.",
|
||||
},
|
||||
{
|
||||
heading: "Everything Doormile Delivers",
|
||||
text: "Take one service or the whole chain. Either way it is one contract, one team, and one place to see where your shipment is.",
|
||||
},
|
||||
];
|
||||
|
||||
/* Customer-facing framing of the EV fleet — the homepage section's own copy is
|
||||
written around the EV-native platform design, which belongs on MileTruth. */
|
||||
const EV_FLEET_FEATURES: EVFeature[] = [
|
||||
{
|
||||
icon: (
|
||||
<svg className="evnd-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 10h-1.28A6 6 0 0 0 12 5V3M6 10h1.28A6 6 0 0 0 12 5M12 18v2M8 10v6a4 4 0 0 0 8 0v-6" />
|
||||
</svg>
|
||||
),
|
||||
title: "Lower Cost Per Kilometre",
|
||||
desc: "Electric running costs are a fraction of diesel, and that saving shows up on the miles you move with us.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="evnd-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
),
|
||||
title: "No Range Surprises",
|
||||
desc: "Charging is planned into the day before the vehicle leaves the depot, so an EV run arrives when a diesel run would.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="evnd-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2C8 6 6 9 6 13a6 6 0 0 0 12 0c0-4-2-7-6-11z" />
|
||||
<path d="M12 8v9" />
|
||||
</svg>
|
||||
),
|
||||
title: "Zero Tailpipe Emissions",
|
||||
desc: "Clean deliveries into cities with low-emission zones, and a number you can put in your own sustainability reporting.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="evnd-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1" y="11" width="14" height="8" rx="1.5" />
|
||||
<path d="M15 15h4l2 2v2h-6" />
|
||||
<circle cx="5.5" cy="21" r="2" />
|
||||
<circle cx="17.5" cy="21" r="2" />
|
||||
</svg>
|
||||
),
|
||||
title: "Built For Your Routes",
|
||||
desc: "Urban and regional runs served by a fleet sized to the job, from small city vehicles to larger regional loads.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SolutionsPage() {
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
/* Fix "Why Businesses Choose Doormile" section — anchor absolute image left, push content right */
|
||||
.elementor-element.elementor-element-ead59d3 {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.elementor-element.elementor-element-f35119c {
|
||||
position: absolute !important;
|
||||
left: -2% !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
width: 48% !important;
|
||||
max-width: 520px !important;
|
||||
opacity: 0.18 !important;
|
||||
z-index: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
.elementor-element.elementor-element-56ecbb3 {
|
||||
position: relative !important;
|
||||
z-index: 1 !important;
|
||||
width: 55% !important;
|
||||
max-width: 55% !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: 4% !important;
|
||||
}
|
||||
.elementor-element.elementor-element-56ecbb3 .e-con-inner {
|
||||
max-width: 100% !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
@media (max-width: 1020px) {
|
||||
.elementor-element.elementor-element-f35119c { display: none !important; }
|
||||
.elementor-element.elementor-element-56ecbb3 {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
<div id="solutions" className="content-wrapper content-wrapper-may-contain-elementor-code content-wrapper-sidebar-position-none">
|
||||
<div className="content">
|
||||
<div className="content-inner">
|
||||
<div data-elementor-type="wp-page" data-elementor-id="59" className="elementor elementor-59">
|
||||
<SolutionsHero slides={SOLUTIONS_SLIDES} />
|
||||
|
||||
<div id="solutions" className="content-wrapper content-wrapper-may-contain-elementor-code content-wrapper-sidebar-position-none">
|
||||
<div className="content">
|
||||
<div className="content-inner">
|
||||
<div data-elementor-type="wp-page" data-elementor-id="59" className="elementor elementor-59">
|
||||
<SolutionsHero />
|
||||
<IndustryStack />
|
||||
</div>
|
||||
{/* One #sol-root scope for the whole page — the shell carries the
|
||||
stylesheet and must not be rendered twice. Miles3 and EVSection
|
||||
sit between beats; the shell's CSS is scoped to `.sol-*` only,
|
||||
so their Elementor markup is unaffected. */}
|
||||
<SolutionsSectionsShell>
|
||||
<SolOfferings />
|
||||
{/* Platform Capabilities carousel is passed as children so it
|
||||
renders INSIDE the Miles3 dark card, directly under the
|
||||
first/mid/last mile grid — one continuous "What We Offer"
|
||||
section, not a second one. Scoped to #pcap-root, so it does
|
||||
not inherit anything from the shell's `.sol-*` stylesheet. */}
|
||||
<Miles3 eyebrow="/ What We Offer /">
|
||||
{/* Big count-up stat over the dotted world map, then the
|
||||
capabilities carousel — both inside the same dark card. */}
|
||||
<DeliveredCounter />
|
||||
<PlatformCapabilities />
|
||||
</Miles3>
|
||||
<SolWingsSpotlight />
|
||||
<EVSection
|
||||
eyebrow="/ EV Fleet /"
|
||||
cardTitle="EV Fleet"
|
||||
cardSubtitle="Cleaner miles, lower costs"
|
||||
titleLead="AN ELECTRIC FLEET ON "
|
||||
titleAccent="YOUR ROUTES."
|
||||
features={EV_FLEET_FEATURES}
|
||||
ariaLabel="Doormile EV fleet"
|
||||
bannerImage="/images/ev fleet1.png"
|
||||
image="/images/ev section1.png"
|
||||
/>
|
||||
<SolutionsWhyDoormile />
|
||||
<SolMileTruthBand />
|
||||
<SolutionsResults />
|
||||
</SolutionsSectionsShell>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { blogPosts } from "@/data/blog";
|
||||
import { blogPosts } from "@/shared/data/blog";
|
||||
|
||||
/**
|
||||
* Client-side blog search. The site is a static export, so there is no search
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getCategories,
|
||||
formatDate,
|
||||
type BlogPost,
|
||||
} from "@/data/blog";
|
||||
} from "@/shared/data/blog";
|
||||
import BlogSearch from "./BlogSearch";
|
||||
|
||||
/**
|
||||
@@ -72,7 +72,7 @@ export default function BlogSidebar({ current }: { current?: BlogPost }) {
|
||||
|
||||
{/* CTA Card */}
|
||||
<section className="dm-blog-widget dm-blog-cta-card">
|
||||
<h2 className="dm-blog-cta-title">Planning delivery routes?</h2>
|
||||
<h2 className="dm-blog-cta-title">Planning logistics routes?</h2>
|
||||
<p className="dm-blog-cta-text">
|
||||
Talk to us about reducing wasted distance, missed windows, and avoidable
|
||||
vehicle time.
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useEffect } 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 { P, STRATEGIES, WINNER_INDEX, ENGINE_STEPS, CONSTRAINT_LIST, STRATEGY_SCORES } from "./theme";
|
||||
|
||||
const LogisticsBrainCanvas = dynamic(() => import("./LogisticsBrainCanvas"), { ssr: false });
|
||||
|
||||
/** Rounds a MotionValue to an integer for the animated stat counters.
|
||||
* Writes to the DOM imperatively so it never triggers a setState during the
|
||||
* parent's render (which `useTransform` emits synchronously on re-render). */
|
||||
function Counter({ mv }: { mv: MotionValue<number> }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
const write = (x: number) => {
|
||||
if (ref.current) ref.current.textContent = String(Math.round(x));
|
||||
};
|
||||
write(mv.get());
|
||||
return mv.on("change", write);
|
||||
}, [mv]);
|
||||
return <span ref={ref}>{Math.round(mv.get())}</span>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Active step index from scroll progress (−1 before the engine starts). */
|
||||
function stepFromProgress(p: number): number {
|
||||
let s = -1;
|
||||
for (let i = 0; i < ENGINE_STEPS.length; i++) if (p >= ENGINE_STEPS[i].at) s = i;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Persistent top rail: the 6 engine steps, with the current one highlighted. */
|
||||
function StepRail({ active }: { active: number }) {
|
||||
return (
|
||||
<div className="dm-lb-rail" aria-hidden>
|
||||
{ENGINE_STEPS.map((s, i) => {
|
||||
const state = i < active ? "done" : i === active ? "current" : "todo";
|
||||
return (
|
||||
<React.Fragment key={s.n}>
|
||||
{i > 0 && <span className={`dm-lb-rail__line is-${i <= active ? "on" : "off"}`} />}
|
||||
<div className={`dm-lb-rail__step is-${state}`}>
|
||||
<span className="dm-lb-rail__num">{i < active ? "✓" : s.n}</span>
|
||||
<span className="dm-lb-rail__title">{s.title}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One workflow card that travels through the story. The outer anchor pins it to
|
||||
* its stage position (Left / Center / Right / Center-Hero); the inner motion card
|
||||
* slides + scales into that anchor in lockstep with scroll, so as the camera moves
|
||||
* through the stages the card visibly moves with the narrative instead of sitting
|
||||
* fixed in one corner.
|
||||
*/
|
||||
function StoryCard({
|
||||
step,
|
||||
index,
|
||||
pos,
|
||||
opacity,
|
||||
y,
|
||||
x,
|
||||
scale,
|
||||
num,
|
||||
kicker,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
step: number;
|
||||
index: number;
|
||||
pos: "left" | "center" | "right" | "hero";
|
||||
opacity: MotionValue<number>;
|
||||
y: MotionValue<number>;
|
||||
x: MotionValue<number>;
|
||||
scale: MotionValue<number>;
|
||||
num: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
// Don't mount this beat's card until its step is active.
|
||||
if (step !== index) return null;
|
||||
return (
|
||||
<div className={`dm-lb-card-anchor is-${pos}`}>
|
||||
<motion.div className="dm-lb-card-story" style={{ opacity, y, x, scale }}>
|
||||
<div className="dm-lb-card-story__head">
|
||||
<span className="dm-lb-pillar__num">{num}</span>
|
||||
<span className="dm-lb-pillar__kicker">{kicker}</span>
|
||||
</div>
|
||||
<h3 className="dm-lb-pillar__title">{title}</h3>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Logistics Brain" — one sticky, fullscreen, scroll-driven cinematic WebGL
|
||||
* section. A single GSAP ScrollTrigger maps scroll position to a normalized
|
||||
* 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({ 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 [step, setStep] = useState(-1);
|
||||
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 lastStep = -1;
|
||||
const st = ScrollTrigger.create({
|
||||
trigger: el,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
// Match Workflow 1's responsiveness (0.4) so the camera + overlay track the
|
||||
// scroll with the same snappy feel — 0.5 made this section lag noticeably.
|
||||
scrub: 0.4,
|
||||
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 nstep = stepFromProgress(p);
|
||||
if (nstep !== lastStep) { lastStep = nstep; setStep(nstep); }
|
||||
},
|
||||
});
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 120);
|
||||
return () => { clearTimeout(refresh); st.kill(); };
|
||||
}, [scroll]);
|
||||
|
||||
// Overlay transforms.
|
||||
const introOpacity = useTransform(scroll, [0, 0.04, 0.1], [1, 1, 0]);
|
||||
// Persistent header (title + step rail) fades in after the intro and out at the finale.
|
||||
const headerOpacity = useTransform(scroll, [0.04, 0.1, P.finale - 0.04, P.finale + 0.02], [0, 1, 1, 0]);
|
||||
|
||||
// Per-beat kinetic-typography pillars (cross-fade across each story beat).
|
||||
const p1o = useTransform(scroll, [0.135, 0.165, 0.255, 0.275], [0, 1, 1, 0]);
|
||||
const p1y = useTransform(scroll, [0.135, 0.175], [26, 0]);
|
||||
const p2o = useTransform(scroll, [0.29, 0.32, 0.415, 0.435], [0, 1, 1, 0]);
|
||||
const p2y = useTransform(scroll, [0.29, 0.33], [26, 0]);
|
||||
const p3o = useTransform(scroll, [0.45, 0.48, 0.575, 0.595], [0, 1, 1, 0]);
|
||||
const p3y = useTransform(scroll, [0.45, 0.49], [26, 0]);
|
||||
const p4o = useTransform(scroll, [0.61, 0.64, 0.715, 0.735], [0, 1, 1, 0]);
|
||||
const p4y = useTransform(scroll, [0.61, 0.65], [26, 0]);
|
||||
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]);
|
||||
|
||||
// Horizontal slide + scale per beat — same windows as the opacity above, so the
|
||||
// card glides between its stage anchors (Left → Center → Right → Left → Center-Hero)
|
||||
// in lockstep with the camera. Each card enters from the direction of the previous
|
||||
// stage and drifts toward the next as it leaves, reading as one continuous travel.
|
||||
const p1x = useTransform(scroll, [0.135, 0.165, 0.255, 0.275], [-52, 0, 0, 52]);
|
||||
const p1s = useTransform(scroll, [0.135, 0.165, 0.255, 0.275], [0.965, 1, 1, 0.965]);
|
||||
const p2x = useTransform(scroll, [0.29, 0.32, 0.415, 0.435], [-52, 0, 0, 52]);
|
||||
const p2s = useTransform(scroll, [0.29, 0.32, 0.415, 0.435], [0.965, 1, 1, 0.965]);
|
||||
const p3x = useTransform(scroll, [0.45, 0.48, 0.575, 0.595], [-52, 0, 0, -52]);
|
||||
const p3s = useTransform(scroll, [0.45, 0.48, 0.575, 0.595], [0.965, 1, 1, 0.965]);
|
||||
const p4x = useTransform(scroll, [0.61, 0.64, 0.715, 0.735], [52, 0, 0, 52]);
|
||||
const p4s = useTransform(scroll, [0.61, 0.64, 0.715, 0.735], [0.965, 1, 1, 0.965]);
|
||||
// Hero (final selection): scales up a touch and holds center as it settles.
|
||||
const p5x = useTransform(scroll, [0.75, 0.78, 0.855, 0.875], [-52, 0, 0, 0]);
|
||||
const p5s = useTransform(scroll, [0.75, 0.78, 0.855, 0.875], [0.97, 1.05, 1.05, 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]);
|
||||
|
||||
const orders = useTransform(scroll, [P.finale, 0.97], [0, 59]);
|
||||
const cost = useTransform(scroll, [P.finale, 0.97], [0, 18]);
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="dm-lb-canvas">
|
||||
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive && pinState === "pinned"} />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay is mounted only once the section is pinned/activated, so its
|
||||
content (intro hint, header, story cards) can never be seen during the
|
||||
approach ("before"), where the sticky sits at the top of the tall
|
||||
section just off the previous workflow's seam. */}
|
||||
{pinState !== "before" && (
|
||||
<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 }}>
|
||||
<div className="dm-lb-eyebrow">
|
||||
<span className="dm-lb-dot" /> MileTruth Routing Engine
|
||||
</div>
|
||||
<StepRail active={step} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div className="dm-lb-scrollhint" style={{ opacity: introOpacity }}>
|
||||
<span>Scroll to see how every delivery is planned</span>
|
||||
<span className="dm-lb-arrow">↓</span>
|
||||
</motion.div>
|
||||
|
||||
{/* STEP 01 — Generate Routes (card anchored LEFT) */}
|
||||
<StoryCard step={step} index={0} pos="left" opacity={p1o} y={p1y} x={p1x} scale={p1s} num="01" kicker="Generate Routes" title="We create many delivery plans at once">
|
||||
<div className="dm-lb-chips">
|
||||
{STRATEGIES.map((s, i) => (
|
||||
<span key={s} className={`dm-lb-chip${i === WINNER_INDEX ? " dm-lb-chip--active" : ""}`}>{s}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="dm-lb-pillar__foot">6 different ways to deliver all 59 orders — generated in milliseconds.</p>
|
||||
</StoryCard>
|
||||
|
||||
{/* STEP 02 — Check Constraints (card anchored CENTER) */}
|
||||
<StoryCard step={step} index={1} pos="center" opacity={p2o} y={p2y} x={p2x} scale={p2s} num="02" kicker="Check Constraints" title="Every plan must respect real-world limits">
|
||||
<ul className="dm-lb-constraints">
|
||||
{CONSTRAINT_LIST.map((c) => (
|
||||
<li key={c.label}>
|
||||
<span className="dm-lb-constraints__icon">{c.icon}</span>
|
||||
<span className="dm-lb-constraints__label">{c.label}</span>
|
||||
<span className="dm-lb-constraints__note">{c.note}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="dm-lb-pillar__stat"><strong>59/59</strong> delivered <em>vs 34/59 when battery limits are ignored</em></p>
|
||||
</StoryCard>
|
||||
|
||||
{/* STEP 03 — Score & Compare (card anchored RIGHT) */}
|
||||
<StoryCard step={step} index={2} pos="right" opacity={p3o} y={p3y} x={p3x} scale={p3s} num="03" kicker="Score & Compare" title="Each plan is scored by total delivery cost">
|
||||
<ul className="dm-lb-board">
|
||||
{STRATEGY_SCORES.map((s) => (
|
||||
<li key={s.name} className={s.win ? "is-win" : ""}>
|
||||
<span className="dm-lb-board__name">{s.name}{s.win && <span className="dm-lb-board__tag">WINNER</span>}</span>
|
||||
<span className="dm-lb-board__track"><span className="dm-lb-board__fill" style={{ width: `${s.score}%` }} /></span>
|
||||
<span className="dm-lb-board__score">{s.score}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</StoryCard>
|
||||
|
||||
{/* STEP 04 — Guarantee On-Time (card anchored LEFT) */}
|
||||
<StoryCard step={step} index={3} pos="left" opacity={p4o} y={p4y} x={p4x} scale={p4s} num="04" kicker="Guarantee On-Time" title="Any plan even 1 minute late is rejected">
|
||||
<div className="dm-lb-sla">
|
||||
<span className="dm-lb-sla__badge">⏱️ On-time only</span>
|
||||
<span className="dm-lb-sla__x">✕ Late plan → dropped</span>
|
||||
</div>
|
||||
<p className="dm-lb-pillar__foot">We only keep plans that hit every promised delivery window.</p>
|
||||
</StoryCard>
|
||||
|
||||
{/* STEP 05 — Pick & Dispatch (card anchored CENTER, hero) */}
|
||||
<StoryCard step={step} index={4} pos="hero" opacity={p5o} y={p5y} x={p5x} scale={p5s} num="05" kicker="Pick & Dispatch" title="The winning plan is sent to the fleet">
|
||||
<div className="dm-lb-winner">✓ Multi-Trip selected — lowest cost, zero delays</div>
|
||||
<div className="dm-lb-chips">
|
||||
<span className="dm-lb-chip">EV Bikes</span>
|
||||
<span className="dm-lb-chip">Autos</span>
|
||||
<span className="dm-lb-chip">Cargo Trucks</span>
|
||||
</div>
|
||||
</StoryCard>
|
||||
|
||||
{/* STEP 06 — Results: KPI cards */}
|
||||
<motion.div className="dm-lb-finale" style={{ opacity: finaleOpacity }}>
|
||||
<motion.div className="dm-lb-kpis" style={{ y: finaleY }}>
|
||||
<div className="dm-lb-kpi">
|
||||
<span className="dm-lb-kpi__num"><Counter mv={orders} />/59</span>
|
||||
<span className="dm-lb-kpi__label">Orders Delivered</span>
|
||||
</div>
|
||||
<div className="dm-lb-kpi dm-lb-kpi--green">
|
||||
<span className="dm-lb-kpi__num">0</span>
|
||||
<span className="dm-lb-kpi__label">SLA Misses</span>
|
||||
</div>
|
||||
<div className="dm-lb-kpi">
|
||||
<span className="dm-lb-kpi__num"><Counter mv={cost} />%</span>
|
||||
<span className="dm-lb-kpi__label">Cost Saved</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div className="dm-lb-logo" style={{ opacity: taglineOpacity }}>
|
||||
<span className="dm-lb-logo__mark" />
|
||||
MileTruth
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{styles}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
/* Scroll length tuned for pacing: ~77vh per engine step (was 107vh) so the 6
|
||||
beats complete in noticeably less scrolling — closer to Workflow 1's cadence
|
||||
and with far less perceived empty space between workflows. Beat windows are
|
||||
progress-based (0…1), so they stay correctly aligned at any height. */
|
||||
.dm-lb { position: relative; height: 460vh; background: transparent; }
|
||||
.dm-lb-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden;
|
||||
will-change: transform; transform: translateZ(0); backface-visibility: hidden; }
|
||||
.dm-lb.is-pinned .dm-lb-sticky { position: fixed; top: 0; left: 0; }
|
||||
.dm-lb.is-after .dm-lb-sticky { position: absolute; top: auto; bottom: 0; }
|
||||
|
||||
.dm-lb-card {
|
||||
position: absolute !important; inset: 20px !important;
|
||||
border-radius: 28px !important; overflow: hidden !important;
|
||||
background: radial-gradient(120% 100% at 50% 0%, #12090c 0%, #0a070a 55%, #060507 100%) !important;
|
||||
border: 1px solid rgba(192,18,39,0.16) !important;
|
||||
box-shadow: 0 30px 90px -30px rgba(0,0,0,0.85), inset 0 1px 0 rgba(255,255,255,0.04) !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
@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: 20px !important; left: 20px !important; right: 20px !important; bottom: 0 !important;
|
||||
border-radius: 28px 28px 0 0 !important; border-bottom: none !important;
|
||||
/* Flush against the Innovation card below — drop the heavy downward shadow so it
|
||||
doesn't cast a dark band onto that card's top edge (the two read as one container). */
|
||||
box-shadow: 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-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 ----
|
||||
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: 13px; line-height: 1.35; letter-spacing: 0.18em; font-weight: 700; text-transform: uppercase;
|
||||
color: #ffffff; padding: 9px 20px; border-radius: 999px; background: rgba(192,18,39,0.16);
|
||||
border: 1px solid rgba(226,53,66,0.45); 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: nowrap; max-width: min(1160px, 96vw); }
|
||||
.dm-lb-rail__step { display: inline-flex; align-items: center; gap: 8px; padding: 6px 13px; border-radius: 999px; flex-shrink: 0;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
|
||||
backdrop-filter: blur(6px); transition: all 0.45s cubic-bezier(0.22,1,0.36,1); }
|
||||
.dm-lb-rail__num { width: 20px; height: 20px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 800; color: rgba(255,255,255,0.9); background: rgba(255,255,255,0.12); }
|
||||
.dm-lb-rail__title { font-size: clamp(12.5px, 1.05vw, 14px); font-weight: 700; letter-spacing: 0.04em; color: rgba(255,255,255,0.95); white-space: nowrap; }
|
||||
.dm-lb-rail__step.is-current { background: rgba(192,18,39,0.18); border-color: rgba(226,53,66,0.55); box-shadow: 0 0 22px -6px rgba(226,53,66,0.7); }
|
||||
.dm-lb-rail__step.is-current .dm-lb-rail__num { background: linear-gradient(135deg,#E2354A,#C01227); color: #fff; }
|
||||
.dm-lb-rail__step.is-current .dm-lb-rail__title { color: #fff; }
|
||||
.dm-lb-rail__step.is-done .dm-lb-rail__num { background: #22C55E; color: #04130a; }
|
||||
.dm-lb-rail__step.is-done .dm-lb-rail__title { color: rgba(255,255,255,0.92); }
|
||||
.dm-lb-rail__line { width: 14px; height: 1px; background: rgba(255,255,255,0.12); margin: 0 3px; transition: background 0.45s ease; }
|
||||
.dm-lb-rail__line.is-on { background: linear-gradient(90deg,#22C55E,#E2354A); }
|
||||
|
||||
.dm-lb-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: rgba(240,228,230,0.72); text-transform: uppercase; text-align: center; }
|
||||
.dm-lb-arrow { font-size: 18px; animation: dmLbBob 1.8s ease-in-out infinite; }
|
||||
@keyframes dmLbBob { 0%,100% { transform: translateY(0); opacity: 0.5; } 50% { transform: translateY(6px); opacity: 1; } }
|
||||
|
||||
/* ---- Story card: a premium light-glass panel that TRAVELS between stage
|
||||
anchors. The anchor pins the stage position; the inner card slides/scales into
|
||||
it (Left → Center → Right → Left → Center-Hero) in lockstep with scroll. ---- */
|
||||
.dm-lb-card-anchor { position: absolute; bottom: clamp(26px, 7vh, 64px); z-index: 6; pointer-events: none; }
|
||||
.dm-lb-card-anchor.is-left { left: clamp(18px, 4vw, 56px); }
|
||||
.dm-lb-card-anchor.is-right { right: clamp(18px, 4vw, 56px); }
|
||||
.dm-lb-card-anchor.is-center,
|
||||
.dm-lb-card-anchor.is-hero { left: 50%; transform: translateX(-50%); }
|
||||
/* Hero (final selection) sits a little higher + centred so it reads as the payoff. */
|
||||
.dm-lb-card-anchor.is-hero { bottom: clamp(40px, 9vh, 92px); }
|
||||
|
||||
.dm-lb-card-story { position: relative; width: min(440px, 84vw); pointer-events: auto;
|
||||
will-change: opacity, transform; transform-origin: bottom center;
|
||||
padding: 18px 20px; border-radius: 18px;
|
||||
/* Premium light glass — clean SaaS surface, brand red used only as a top accent. */
|
||||
background: rgba(255,255,255,0.94);
|
||||
border: 1px solid rgba(15,23,42,0.08); border-top: 3px solid #C01227;
|
||||
box-shadow: 0 28px 70px -34px rgba(15,23,42,0.45); }
|
||||
.dm-lb-card-anchor.is-hero .dm-lb-card-story { width: min(480px, 88vw);
|
||||
box-shadow: 0 38px 92px -34px rgba(192,18,39,0.4); }
|
||||
.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;
|
||||
background: linear-gradient(135deg, #E2354A, #C01227); border-radius: 7px; padding: 3px 8px; }
|
||||
.dm-lb-pillar__kicker { font-size: clamp(11px, 1.1vw, 13px); font-weight: 700; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; color: #C01227; }
|
||||
.dm-lb .dm-lb-pillar__title { margin: 0 0 12px !important; padding: 0 !important; color: #0f172a !important;
|
||||
font-weight: 700 !important; text-transform: none !important; letter-spacing: -0.015em !important;
|
||||
font-size: clamp(17px, 1.9vw, 24px) !important; line-height: 1.18 !important; }
|
||||
.dm-lb-chips { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 10px; }
|
||||
/* Strategy pills — white pills, soft border + light shadow, brand-red active state. */
|
||||
.dm-lb-chip { font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em; color: #334155;
|
||||
padding: 5px 12px; border-radius: 999px; background: #ffffff;
|
||||
border: 1px solid rgba(15,23,42,0.1); box-shadow: 0 1px 2px rgba(15,23,42,0.06); white-space: nowrap; }
|
||||
.dm-lb-chip--active { color: #ffffff; background: linear-gradient(135deg, #E2354A, #C01227);
|
||||
border-color: transparent; box-shadow: 0 6px 16px -6px rgba(192,18,39,0.5); }
|
||||
.dm-lb-pillar__foot { margin: 0; font-size: clamp(12px, 1.1vw, 13.5px); line-height: 1.45; color: #475569; }
|
||||
.dm-lb-pillar__stat { margin: 6px 0 0; font-size: clamp(12.5px, 1.2vw, 15px); color: #475569; }
|
||||
.dm-lb-pillar__stat strong { color: #16a34a; font-weight: 800; font-size: 1.25em; }
|
||||
.dm-lb-pillar__stat em { font-style: normal; color: #94a3b8; }
|
||||
|
||||
/* Constraints checklist (step 02) */
|
||||
.dm-lb-constraints { list-style: none; margin: 0 0 10px; padding: 0; display: grid; gap: 7px; }
|
||||
.dm-lb-constraints li { display: flex; align-items: center; gap: 9px; }
|
||||
.dm-lb-constraints__icon { font-size: 14px; width: 20px; text-align: center; }
|
||||
.dm-lb-constraints__label { font-size: 13px; font-weight: 700; color: #0f172a; min-width: 84px; }
|
||||
.dm-lb-constraints__note { font-size: 12px; color: #64748b; }
|
||||
|
||||
/* Scored leaderboard (step 03) */
|
||||
.dm-lb-board { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||
.dm-lb-board li { display: grid; grid-template-columns: 104px 1fr 26px; align-items: center; gap: 9px; }
|
||||
.dm-lb-board__name { font-size: 11.5px; font-weight: 600; color: #64748b; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.dm-lb-board li.is-win .dm-lb-board__name { color: #0f172a; font-weight: 800; }
|
||||
.dm-lb-board__tag { font-size: 8px; font-weight: 800; letter-spacing: 0.08em; color: #fff;
|
||||
background: linear-gradient(135deg,#E2354A,#C01227); padding: 2px 5px; border-radius: 5px; }
|
||||
.dm-lb-board__track { height: 7px; border-radius: 999px; background: rgba(15,23,42,0.08); overflow: hidden; }
|
||||
.dm-lb-board__fill { display: block; height: 100%; border-radius: 999px; background: rgba(100,116,139,0.45); }
|
||||
.dm-lb-board li.is-win .dm-lb-board__fill { background: linear-gradient(90deg,#E2354A,#C01227); box-shadow: 0 0 12px rgba(226,53,66,0.4); }
|
||||
.dm-lb-board__score { font-size: 12px; font-weight: 700; color: #64748b; text-align: right; }
|
||||
.dm-lb-board li.is-win .dm-lb-board__score { color: #0f172a; }
|
||||
|
||||
/* SLA badges (step 04) */
|
||||
.dm-lb-sla { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.dm-lb-sla__badge { font-size: 12px; font-weight: 700; color: #15803d; background: rgba(34,197,94,0.1);
|
||||
border: 1px solid rgba(34,197,94,0.3); padding: 6px 12px; border-radius: 999px; }
|
||||
.dm-lb-sla__x { font-size: 12px; font-weight: 700; color: #b91c1c; background: rgba(239,68,68,0.08);
|
||||
border: 1px solid rgba(239,68,68,0.28); padding: 6px 12px; border-radius: 999px; }
|
||||
|
||||
/* Winner banner (step 05) */
|
||||
.dm-lb-winner { font-size: 13.5px; font-weight: 700; color: #0f172a; margin-bottom: 10px; padding: 9px 13px; border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(192,18,39,0.08), rgba(34,197,94,0.08)); border: 1px solid rgba(226,53,66,0.32); }
|
||||
|
||||
/* ---- Finale: KPI cards ---- */
|
||||
.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.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); }
|
||||
.dm-lb-kpi--green .dm-lb-kpi__num { color: #4ade80; text-shadow: 0 0 32px rgba(34,197,94,0.6); }
|
||||
.dm-lb-kpi__label { font-size: clamp(10px, 1.1vw, 13px); letter-spacing: 0.14em; text-transform: uppercase; color: rgba(232,222,224,0.62); }
|
||||
|
||||
.dm-lb-logo { display: inline-flex; align-items: center; gap: 12px; font-size: clamp(22px, 3vw, 40px); font-weight: 800;
|
||||
letter-spacing: -0.01em; color: #fff; margin-bottom: 18px; text-shadow: 0 0 30px rgba(192,18,39,0.55); }
|
||||
.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); }
|
||||
|
||||
/* Hide the step titles on narrower screens so the rail stays a single tidy row of numbers. */
|
||||
@media (max-width: 1000px) {
|
||||
.dm-lb-rail__title { display: none; }
|
||||
.dm-lb-rail__step { padding: 5px 7px; }
|
||||
.dm-lb-rail__line { width: 9px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-lb { height: 400vh; }
|
||||
.dm-lb-kpis { gap: 12px; }
|
||||
.dm-lb-kpi { min-width: 96px; padding: 14px 14px; }
|
||||
/* On phones every stage collapses to one centred, full-width position — the
|
||||
horizontal travel only reads on wider screens. */
|
||||
.dm-lb-card-anchor,
|
||||
.dm-lb-card-anchor.is-left,
|
||||
.dm-lb-card-anchor.is-right,
|
||||
.dm-lb-card-anchor.is-center,
|
||||
.dm-lb-card-anchor.is-hero { left: 50%; right: auto; transform: translateX(-50%); bottom: clamp(20px, 5vh, 44px); }
|
||||
.dm-lb-card-story,
|
||||
.dm-lb-card-anchor.is-hero .dm-lb-card-story { width: calc(100vw - 28px); padding: 14px 16px; }
|
||||
.dm-lb-board li { grid-template-columns: 88px 1fr 24px; }
|
||||
.dm-lb-constraints__note { display: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dm-lb-arrow { animation: none !important; }
|
||||
}
|
||||
`;
|
||||
@@ -74,7 +74,7 @@ export const CONSTRAINT_LIST = [
|
||||
{ icon: "🔋", label: "Battery", note: "EV range & recharge stops" },
|
||||
{ icon: "📍", label: "Distance", note: "Total km per route" },
|
||||
{ icon: "📦", label: "Capacity", note: "Orders each vehicle can carry" },
|
||||
{ icon: "⏱️", label: "Time / SLA", note: "Promised delivery windows" },
|
||||
{ icon: "⏱️", label: "Time / SLA", note: "Promised arrival windows" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -119,7 +119,7 @@ export type PhaseKey = keyof typeof P;
|
||||
* scroll-progress threshold where each step becomes the active one.
|
||||
*/
|
||||
export const ENGINE_STEPS = [
|
||||
{ n: "01", at: P.routes, title: "Generate Routes", caption: "Many delivery plans created at once" },
|
||||
{ n: "01", at: P.routes, title: "Generate Routes", caption: "Many route plans created at once" },
|
||||
{ n: "02", at: P.ev, title: "Check Constraints", caption: "Battery, distance, capacity & time" },
|
||||
{ n: "03", at: P.network, title: "Score & Compare", caption: "Every plan ranked by total cost" },
|
||||
{ n: "04", at: P.sla, title: "Guarantee On-Time", caption: "Late plans rejected automatically" },
|
||||
@@ -129,9 +129,9 @@ export const ENGINE_STEPS = [
|
||||
|
||||
export const PHASE_CAPTIONS: Record<PhaseKey, string> = {
|
||||
birth: "Initializing the logistics brain",
|
||||
routes: "Simulating delivery strategies",
|
||||
routes: "Simulating routing strategies",
|
||||
ev: "Recalculating around EV constraints",
|
||||
network: "Mapping the live delivery network",
|
||||
network: "Mapping the live logistics network",
|
||||
sla: "Enforcing on-time SLA reliability",
|
||||
ecosystem: "Autonomous fleet in motion",
|
||||
finale: "",
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion, useTransform, type MotionValue } from "framer-motion";
|
||||
import { COLORS, KPIS, Kpi, rgba } from "./constants";
|
||||
|
||||
type Props = {
|
||||
/** shared scroll progress (0 → 1) */
|
||||
scroll: MotionValue<number>;
|
||||
};
|
||||
|
||||
const COUNT_START = 0.7;
|
||||
const COUNT_END = 0.97;
|
||||
|
||||
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)));
|
||||
let n = kpi.before + (kpi.after - kpi.before) * t;
|
||||
|
||||
// Apply high-frequency live operational fluctuations on settled state
|
||||
if (t > 0.95) {
|
||||
n = n * (1 + jitter);
|
||||
}
|
||||
|
||||
const afterSide = t > 0.5;
|
||||
const prefix = afterSide ? kpi.prefixAfter ?? "" : kpi.prefixBefore ?? "";
|
||||
|
||||
if (kpi.key === "delayed" && kpi.after === 0 && t > 0.95) {
|
||||
return `0`;
|
||||
}
|
||||
|
||||
return `${prefix}${Math.round(n)}${kpi.suffix ?? ""}`;
|
||||
});
|
||||
|
||||
const label = useTransform(scroll, (v) =>
|
||||
v > (COUNT_START + COUNT_END) / 2 ? kpi.labelAfter : kpi.labelBefore,
|
||||
);
|
||||
|
||||
const fromColor = kpi.key === "orders" ? COLORS.cyan : COLORS.red;
|
||||
const toColor = COLORS.green;
|
||||
const accent = useTransform(
|
||||
scroll,
|
||||
[COUNT_START, COUNT_END],
|
||||
[rgba(fromColor, 1), rgba(toColor, 1)],
|
||||
);
|
||||
const glow = useTransform(
|
||||
scroll,
|
||||
[COUNT_START, COUNT_END],
|
||||
[rgba(fromColor, 0.28), rgba(toColor, 0.32)],
|
||||
);
|
||||
const boxShadow = useTransform(glow, (g) => `0 10px 40px -12px ${g}, inset 0 1px 0 rgba(255,255,255,0.06)`);
|
||||
const borderColor = useTransform(
|
||||
scroll,
|
||||
[COUNT_START, COUNT_END],
|
||||
[rgba(fromColor, 0.4), rgba(toColor, 0.45)],
|
||||
);
|
||||
|
||||
// Improvement arrow appears once optimized.
|
||||
const arrowOpacity = useTransform(scroll, [COUNT_END - 0.12, COUNT_END], [0, 1]);
|
||||
// Bar fills as the counter morphs from before → after.
|
||||
const fillScale = useTransform(scroll, [COUNT_START, COUNT_END], [0.12, 1]);
|
||||
|
||||
const trendColor = kpi.goodWhenLower ? COLORS.green : (kpi.key === "orders" ? COLORS.cyan : COLORS.green);
|
||||
|
||||
const sparklinePath = React.useMemo(() => {
|
||||
switch (kpi.key) {
|
||||
case "distance":
|
||||
case "vehicles":
|
||||
return "M 0,3 C 16,3 32,17 64,17"; // Downward trend
|
||||
case "orders":
|
||||
return "M 0,17 C 16,6 32,19 64,8"; // Stable high wavy trend
|
||||
case "delayed":
|
||||
case "cost":
|
||||
return "M 0,1 C 16,1 28,19 64,19"; // Sharp drop
|
||||
default:
|
||||
return "M 0,10 L 64,10";
|
||||
}
|
||||
}, [kpi.key]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="dm-opt-metric"
|
||||
style={{ boxShadow, borderColor, animationDelay: `${0.06 * index}s` }}
|
||||
>
|
||||
<div className="dm-opt-metric__top">
|
||||
<motion.span className="dm-opt-metric__label">{label}</motion.span>
|
||||
<motion.span className="dm-opt-metric__arrow" style={{ opacity: arrowOpacity, color: COLORS.green }}>
|
||||
{kpi.goodWhenLower ? "▼" : "▲"}
|
||||
</motion.span>
|
||||
</div>
|
||||
<motion.div className="dm-opt-metric__value" style={{ color: accent }}>
|
||||
{value}
|
||||
</motion.div>
|
||||
|
||||
{/* Sparkline trend graphic */}
|
||||
<div className="dm-opt-metric__sparkline" style={{ position: "absolute", bottom: 8, right: 12, opacity: 0.32, width: 64, height: 20, pointerEvents: "none" }}>
|
||||
<svg width="100%" height="100%" viewBox="0 0 64 20" fill="none">
|
||||
<defs>
|
||||
<linearGradient id={`grad-${kpi.key}`} x1="0" y1="0" x2="0" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor={trendColor} stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor={trendColor} stopOpacity="0.0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={sparklinePath}
|
||||
stroke={trendColor}
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d={`${sparklinePath} L 64,20 L 0,20 Z`}
|
||||
fill={`url(#grad-${kpi.key})`}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="dm-opt-metric__bar">
|
||||
<motion.div
|
||||
className="dm-opt-metric__fill"
|
||||
style={{ background: accent, scaleX: fillScale }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
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} jitter={jitters[i]} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(MetricsPanel);
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ScrollReveal } from "@/animations/Reveal";
|
||||
import { blogPosts } from "@/data/blog";
|
||||
import { blogPosts } from "@/shared/data/blog";
|
||||
|
||||
export default function BlogGrid() {
|
||||
const blogs = blogPosts;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { ScrollReveal } from "@/animations/Reveal";
|
||||
import { getAdjacentPosts, getRelatedPosts } from "@/data/blog";
|
||||
import { getAdjacentPosts, getRelatedPosts } from "@/shared/data/blog";
|
||||
|
||||
export default function BlogPostFooter({ slug }: { slug: string }) {
|
||||
const { prev, next } = getAdjacentPosts(slug);
|
||||
|
||||
@@ -259,7 +259,7 @@ export default function ConnectedLogistics() {
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-165dfa5 elementor-widget__width-initial elementor-widget elementor-widget-text-editor" data-id="165dfa5" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||
<div className="elementor-widget-container">
|
||||
<p>Learn from every delivery. Get smarter with every mile traveled.</p>
|
||||
<p>Learn from every shipment. Get smarter with every mile traveled.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
331
src/components/sections/ConnectedLogisticsSection.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useInView } from "framer-motion";
|
||||
import { ScrollReveal, Magnetic } from "@/animations/Reveal";
|
||||
|
||||
interface ConnectedLogisticsSectionProps {
|
||||
id?: string;
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
imageSrc?: string;
|
||||
imageAlt?: string;
|
||||
items?: Array<{ title: string; text: string }>;
|
||||
showButton?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ITEMS = [
|
||||
{ title: "Real-Time Visibility", text: "See every shipment, every vehicle, every handoff—live. No blind spots." },
|
||||
{ title: "Dynamic Route Adjustment", text: "AI continuously optimizes routes based on traffic, weather, and real-world conditions." },
|
||||
{ title: "Proactive Delay Prevention", text: "Detect SLA risks hours before they become problems. Act, don't react." },
|
||||
{ title: "Execution Intelligence", text: "Learn from every shipment. Get smarter with every mile traveled." },
|
||||
];
|
||||
|
||||
export default function ConnectedLogisticsSection({
|
||||
id = "vehicle-ai",
|
||||
eyebrow = "/ Connected Logistics /",
|
||||
title = "Smart logistics solutions we deliver for modern supply chains",
|
||||
imageSrc = "/images/ev solution 4.png",
|
||||
imageAlt = "Connected Logistics",
|
||||
items = DEFAULT_ITEMS,
|
||||
showButton = false,
|
||||
}: ConnectedLogisticsSectionProps) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(sectionRef, { once: true, margin: "-50px" });
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className="elementor elementor-61" data-elementor-type="wp-page" data-elementor-id="61" id={id} style={{ width: "100%", scrollMarginTop: "100px" }}>
|
||||
<div className="elementor-element elementor-element-89a0ca1 e-con-full e-flex cut-corner-no sticky-container-off e-con e-parent" data-id="89a0ca1" data-element_type="container" data-e-type="container">
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
/* Ensure the inner content wrapper is responsive and never overflows */
|
||||
.elementor-element-fdb2e58 {
|
||||
width: 100% !important;
|
||||
max-width: 650px !important;
|
||||
}
|
||||
|
||||
/* Make heading font size fluid and responsive */
|
||||
.elementor-element-7500280 .logico-title {
|
||||
font-size: clamp(26px, 3.5vw, 54px) !important;
|
||||
line-height: 1.25em !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
/* Fix text colors for the dark background */
|
||||
.elementor-element-71c3e1d .logico-title {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.elementor-element-71c3e1d p {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
|
||||
/* Make sure paragraphs are responsive on all screen widths */
|
||||
.elementor-element-165dfa5 {
|
||||
width: 100% !important;
|
||||
max-width: min(619px, 100%) !important;
|
||||
}
|
||||
.elementor-element-3888a1e {
|
||||
width: 100% !important;
|
||||
max-width: min(526px, 100%) !important;
|
||||
}
|
||||
|
||||
/* Sizing and identical padding on all 4 sides for Connected Logistics Image container */
|
||||
.elementor-element-99768ba .elementor-widget-container {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
padding: 40px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.elementor-element-99768ba img.wp-image-4481 {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
object-fit: cover !important;
|
||||
border-radius: 25px !important;
|
||||
margin: 0 auto !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* ── Left image: fill the row instead of floating centred in it ──
|
||||
The two columns are 50/50 and the row is align-items:center. The
|
||||
image is width:100%/height:auto, so its height is fixed by the
|
||||
file's aspect ratio (ev solution 4.png is 1426x1103 landscape,
|
||||
1.29:1) — at a 1440px viewport that is a 555x429 box inside a
|
||||
705px-tall row. The 276px left over was split evenly by
|
||||
align-items:center, giving ~98px of dead space above AND below,
|
||||
on top of the widget-container's intentional 40px inset — 163px
|
||||
of gap at each end, and it grows as the viewport narrows (190px
|
||||
at 1280px) because the image shrinks with the column while the
|
||||
text column's height stays put.
|
||||
|
||||
Stretching the image column to the row height and letting the
|
||||
image fill it (object-fit:cover, already the declared value) makes
|
||||
the picture 625px instead of 429px, so it now tracks the 705px
|
||||
text column and the dead space collapses to the intended 40px
|
||||
inset. cover scales without distorting; because the source is
|
||||
landscape in a now-portrait slot it crops horizontally rather
|
||||
than letter-boxing.
|
||||
|
||||
Everything here is scoped to #${id} because ConnectedLogistics.tsx
|
||||
(Home) reuses these exact Elementor class names with no wrapping
|
||||
id — unscoped rules would move Home's section too. Desktop only;
|
||||
the stacked <=1024px layout is untouched. */
|
||||
@media (min-width: 1025px) {
|
||||
#${id} .elementor-element-9ffed33 {
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
#${id} .elementor-element-96343ba > div,
|
||||
#${id} .elementor-element-99768ba,
|
||||
#${id} .elementor-element-99768ba .elementor-widget-container {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
#${id} .elementor-element-99768ba img.wp-image-4481 {
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
object-position: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop/Laptop (min-width: 1025px) column width and flex rules */
|
||||
@media (min-width: 1025px) {
|
||||
.elementor-element-9ffed33 {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.elementor-element-96343ba,
|
||||
.elementor-element-71c3e1d {
|
||||
width: 50% !important;
|
||||
max-width: 50% !important;
|
||||
flex: 1 1 50% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.elementor-element-9ffed33 {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.elementor-element-96343ba,
|
||||
.elementor-element-71c3e1d {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.elementor-element-96343ba > div {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.elementor-element-96343ba {
|
||||
margin-top: 0px !important;
|
||||
}
|
||||
|
||||
.elementor-element-71c3e1d {
|
||||
padding-bottom: 24px !important;
|
||||
}
|
||||
|
||||
.elementor-element-165dfa5,
|
||||
.elementor-element-3888a1e {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.elementor-element-99768ba {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.elementor-element-99768ba .elementor-widget-container {
|
||||
padding: 10px !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.elementor-element.elementor-element-99768ba .elementor-widget-container img.wp-image-4481 {
|
||||
width: 100% !important;
|
||||
max-width: 600px !important;
|
||||
border-radius: 25px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.elementor-element-99768ba {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.elementor-element-71c3e1d {
|
||||
padding-bottom: 12px !important;
|
||||
}
|
||||
|
||||
.elementor-element-99768ba .elementor-widget-container {
|
||||
padding: 0px !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.elementor-element.elementor-element-99768ba .elementor-widget-container img.wp-image-4481 {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
object-fit: cover !important;
|
||||
border-radius: 25px !important;
|
||||
}
|
||||
}
|
||||
` }} />
|
||||
<div className="elementor-element elementor-element-9ffed33 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="9ffed33" data-element_type="container" data-e-type="container" data-settings="{"background_background":"classic"}">
|
||||
|
||||
{/* Image side */}
|
||||
<div className="elementor-element elementor-element-96343ba e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="96343ba" data-element_type="container">
|
||||
<ScrollReveal delay={0.1} duration={0.9} yOffset={30} className="w-full h-full">
|
||||
<div className="elementor-element elementor-element-99768ba elementor-widget elementor-widget-image" data-id="99768ba" data-element_type="widget" data-e-type="widget" data-widget_type="image.default">
|
||||
<div className="elementor-widget-container">
|
||||
<Image
|
||||
fetchPriority="high"
|
||||
priority
|
||||
width={578}
|
||||
height={790}
|
||||
src={imageSrc}
|
||||
className="attachment-full size-full wp-image-4481"
|
||||
alt={imageAlt}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
objectFit: "cover",
|
||||
borderRadius: "25px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* Content side */}
|
||||
<div className="elementor-element elementor-element-71c3e1d e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="71c3e1d" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-fdb2e58 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="fdb2e58" data-element_type="container" data-e-type="container">
|
||||
|
||||
<ScrollReveal delay={0.1} duration={0.8} yOffset={20}>
|
||||
<div className="elementor-element elementor-element-0136e6e elementor-widget elementor-widget-logico_heading" data-id="0136e6e" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">{eyebrow}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.2} duration={0.85} yOffset={25}>
|
||||
<div className="elementor-element elementor-element-7500280 elementor-widget elementor-widget-logico_heading" data-id="7500280" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<h2 className="logico-title">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Staggered List items */}
|
||||
{items.map((item, index) => (
|
||||
<ScrollReveal key={index} delay={0.25 + index * 0.1} duration={0.8} yOffset={25}>
|
||||
<div className="elementor-element elementor-element-4cc07a7 elementor-widget elementor-widget-logico_heading" data-id="4cc07a7" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">{item.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-165dfa5 elementor-widget__width-initial elementor-widget elementor-widget-text-editor" data-id="165dfa5" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||
<div className="elementor-widget-container">
|
||||
<p>{item.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
|
||||
{showButton && (
|
||||
<ScrollReveal delay={0.65} duration={0.8} yOffset={20}>
|
||||
<div className="elementor-element elementor-element-c0a45e4 elementor-widget elementor-widget-logico_button" data-id="c0a45e4" data-element_type="widget" data-e-type="widget" data-widget_type="logico_button.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="button-widget">
|
||||
<div className="button-container">
|
||||
<Magnetic>
|
||||
<Link href="/solutions" className="logico-alter-button">Explore more</Link>
|
||||
</Magnetic>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Absolutely Positioned Block Decoration Badge */}
|
||||
<div
|
||||
className="elementor-element elementor-element-0efc59e elementor-absolute elementor-widget elementor-widget-logico_block_decoration"
|
||||
data-id="0efc59e"
|
||||
data-element_type="widget"
|
||||
data-settings='{"_position":"absolute"}'
|
||||
data-widget_type="logico_block_decoration.default"
|
||||
style={{ position: "absolute" }}
|
||||
>
|
||||
<div className="elementor-widget-container">
|
||||
<div className={`block-decoration animation-enable block-decoration-style-1 ${isInView ? "animated" : ""}`}>
|
||||
<div className="block-decoration-item"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
src/components/sections/DeliveredCounter.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
/* ============================================================
|
||||
DELIVERED COUNTER — Solutions page
|
||||
|
||||
The big outlined stat that, in the Logico reference, sits between the
|
||||
mile cards and the "what we do" carousel: a large count-up number over a
|
||||
dotted world map, with a rotated caption alongside it.
|
||||
|
||||
Rebuilt with Doormile branding — the site's red accent instead of teal,
|
||||
the project's own dotted-map asset (public/images/bg-map.png), and a
|
||||
logistics stat. It renders INSIDE the Miles3 dark card (as a sibling of
|
||||
the carousel), so it reads as part of the same section. Transparent
|
||||
surface; everything scoped to #dcount-root.
|
||||
============================================================ */
|
||||
|
||||
const TARGET = 24_836_517;
|
||||
const DURATION = 2200;
|
||||
|
||||
/** de-DE groups thousands with ".", matching the reference's 358.895.941. */
|
||||
function format(n: number): string {
|
||||
return Math.round(n).toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
export default function DeliveredCounter() {
|
||||
const rootRef = useRef<HTMLElement>(null);
|
||||
const [val, setVal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
let raf = 0;
|
||||
let fired = false;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const e = entries[0];
|
||||
if (!e.isIntersecting || fired) return;
|
||||
fired = true;
|
||||
io.disconnect();
|
||||
if (reduce) { setVal(TARGET); return; }
|
||||
const start = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const p = Math.min(1, (now - start) / DURATION);
|
||||
const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
|
||||
setVal(TARGET * eased);
|
||||
if (p < 1) raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
},
|
||||
{ threshold: 0.35 }
|
||||
);
|
||||
|
||||
io.observe(el);
|
||||
return () => { io.disconnect(); cancelAnimationFrame(raf); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: CSS }} />
|
||||
<section id="dcount-root" ref={rootRef} aria-label="Deliveries completed to date">
|
||||
<span className="dc-map" aria-hidden="true" />
|
||||
<div className="dc-fig">
|
||||
<span className="dc-num">{format(val)}</span>
|
||||
<span className="dc-label" aria-hidden="true">
|
||||
<b>Parcels delivered</b>
|
||||
<i>end to end</i>
|
||||
</span>
|
||||
<span className="dc-sr">
|
||||
{format(TARGET)} parcels delivered end to end
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
#dcount-root {
|
||||
--pc-red-bright: #E2354A;
|
||||
position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#dcount-root, #dcount-root * { font-family: var(--font-manrope), "Manrope", sans-serif; box-sizing: border-box; }
|
||||
|
||||
/* Dotted world map backdrop — naturally overlays the statistic counter */
|
||||
#dcount-root .dc-map {
|
||||
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||
width: min(1300px, 98%); height: 100%; min-height: 380px; z-index: 0; pointer-events: none;
|
||||
background: url(/images/bg-map.png) center / contain no-repeat;
|
||||
filter: invert(1) brightness(2.2);
|
||||
opacity: 0.18;
|
||||
-webkit-mask-image: radial-gradient(85% 85% at 50% 50%, #000 50%, transparent 100%);
|
||||
mask-image: radial-gradient(85% 85% at 50% 50%, #000 50%, transparent 100%);
|
||||
}
|
||||
|
||||
#dcount-root .dc-fig {
|
||||
position: relative; z-index: 1;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: clamp(12px, 2vw, 30px);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Big outlined numeral, red stroke, transparent fill */
|
||||
#dcount-root .dc-num {
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 2px var(--pc-red-bright);
|
||||
font-size: clamp(52px, 11vw, 180px);
|
||||
font-weight: 500; line-height: 0.95; letter-spacing: -0.015em;
|
||||
font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||
text-shadow: 0 0 40px rgba(226,53,74,0.18);
|
||||
max-width: 100%;
|
||||
}
|
||||
/* Firefox/older engines without text-stroke: fall back to a faint red fill so
|
||||
the number never disappears. */
|
||||
@supports not ((-webkit-text-stroke: 2px red)) {
|
||||
#dcount-root .dc-num { color: rgba(226,53,74,0.85); }
|
||||
}
|
||||
|
||||
/* Rotated caption, reading bottom-to-top like the reference */
|
||||
#dcount-root .dc-label {
|
||||
writing-mode: vertical-rl; transform: rotate(180deg);
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
color: #fff; line-height: 1.15; padding-bottom: 4px;
|
||||
}
|
||||
#dcount-root .dc-label b { font-size: clamp(12px, 1.1vw, 15px); font-weight: 700; letter-spacing: 0.02em; }
|
||||
#dcount-root .dc-label i { font-style: normal; color: rgba(255,255,255,0.5); font-size: clamp(11px, 1vw, 14px); font-weight: 600; }
|
||||
|
||||
/* Screen-reader-only final figure (the visible number animates via aria-hidden fig) */
|
||||
#dcount-root .dc-sr {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
#dcount-root .dc-num { font-size: clamp(40px, 9vw, 110px); -webkit-text-stroke-width: 1.8px; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#dcount-root { padding: 24px 0 20px; }
|
||||
#dcount-root .dc-fig {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
#dcount-root .dc-num {
|
||||
font-size: clamp(30px, 8.8vw, 48px);
|
||||
-webkit-text-stroke-width: 1.5px;
|
||||
letter-spacing: -0.02em;
|
||||
text-align: center;
|
||||
}
|
||||
#dcount-root .dc-label {
|
||||
writing-mode: horizontal-tb;
|
||||
transform: none;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
#dcount-root .dc-label b { font-size: 12.5px; opacity: 0.95; }
|
||||
#dcount-root .dc-label i { font-size: 11.5px; opacity: 0.65; }
|
||||
#dcount-root .dc-label i::before { content: "·"; margin-right: 6px; color: rgba(226,53,74,0.8); }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
#dcount-root .dc-num {
|
||||
font-size: clamp(26px, 8.2vw, 36px);
|
||||
-webkit-text-stroke-width: 1.3px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -13,7 +13,7 @@ const CARDS_DATA = [
|
||||
{
|
||||
index: 1,
|
||||
title: "Operational Visibility",
|
||||
desc: "Real-time tracking and centralized control provide complete visibility across every shipment, vehicle, and delivery milestone.",
|
||||
desc: "Real-time tracking and centralized control provide complete visibility across every shipment, vehicle, and logistics milestone.",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="7" width="16" height="10" rx="2" ry="2" />
|
||||
@@ -26,7 +26,7 @@ const CARDS_DATA = [
|
||||
{
|
||||
index: 2,
|
||||
title: "Intelligent Routing",
|
||||
desc: "AI-powered route optimization reduces travel time, improves delivery accuracy, and maximizes fleet utilization.",
|
||||
desc: "AI-powered route optimization reduces travel time, improves routing accuracy, and maximizes fleet utilization.",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
@@ -49,7 +49,7 @@ const CARDS_DATA = [
|
||||
{
|
||||
index: 4,
|
||||
title: "Scalable Network",
|
||||
desc: "Flexible logistics infrastructure supports growth across cities, regions, and high-volume delivery operations without disruption.",
|
||||
desc: "Flexible logistics infrastructure supports growth across cities, regions, and high-volume logistics operations without disruption.",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 3.5 2 5.5a7 7 0 0 1-7 7h-3" />
|
||||
|
||||
@@ -135,7 +135,7 @@ const DEFAULT_FEATURES: EVFeature[] = [
|
||||
</svg>
|
||||
),
|
||||
title: "Charging Integration",
|
||||
desc: "Seamlessly integrate charging stops without compromising delivery windows or SLA commitments.",
|
||||
desc: "Seamlessly integrate charging stops without compromising arrival windows or SLA commitments.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
@@ -342,8 +342,67 @@ function DashboardPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LazyVideo
|
||||
* ---------------------------------------------------------------------------
|
||||
* The workflow videos sit thousands of pixels below the fold (e.g. /miletruth
|
||||
* renders one at document y≈9978), but a plain `<video autoPlay muted loop>`
|
||||
* makes Chrome buffer the ENTIRE file at page load even while it is off-screen
|
||||
* and still paused — and `preload="none"` does not stop it, because the browser
|
||||
* keeps buffering to satisfy the pending autoplay. Measured on /miletruth that
|
||||
* was 80.7 MB of mp4 pulled before the user scrolled at all (the 40 MB
|
||||
* Doormile-quantum-video3.mp4 fetched twice over restarted byte ranges).
|
||||
* On localhost that is free; on a deployed site it saturates the connection and
|
||||
* the media pipeline exactly while the user is scrolling.
|
||||
*
|
||||
* Attaching the source only once the element nears the viewport defers all of
|
||||
* it. Playback then starts exactly as before — `.evnd__video` is sized purely
|
||||
* by CSS (width/height 100%), so an empty <video> occupies the identical box
|
||||
* and nothing shifts.
|
||||
*/
|
||||
function LazyVideo({ src, ariaLabel }: { src: string; ariaLabel: string }) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const [shouldLoad, setShouldLoad] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || shouldLoad) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
setShouldLoad(true);
|
||||
io.disconnect();
|
||||
}
|
||||
},
|
||||
// ~1.3 viewports of lead time: enough for the fetch to be well under way
|
||||
// before the section arrives, without paying for it at page load.
|
||||
{ rootMargin: "1200px 0px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [shouldLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldLoad) ref.current?.load();
|
||||
}, [shouldLoad]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
className="evnd__video"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
aria-label={ariaLabel}
|
||||
src={shouldLoad ? src : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EVSection({
|
||||
bannerImage = "/images/bg-header-5.webp",
|
||||
bannerImage = "/images/ev.webp",
|
||||
cardNumber = "",
|
||||
cardTitle = "EV Logistics",
|
||||
cardSubtitle = "Cleaner miles, lower costs",
|
||||
@@ -351,8 +410,8 @@ export default function EVSection({
|
||||
titleLead = "BUILT FOR ELECTRIC. ",
|
||||
titleAccent = "NOT ADAPTED.",
|
||||
features = DEFAULT_FEATURES,
|
||||
image = "/images/premium-ev-van.webp",
|
||||
imageAlt = "DoorMile electric delivery van",
|
||||
image = "/images/ev.webp",
|
||||
imageAlt = "DoorMile electric fleet van",
|
||||
badges = DEFAULT_BADGES,
|
||||
stats = DEFAULT_STATS,
|
||||
gapTop = false,
|
||||
@@ -368,6 +427,7 @@ export default function EVSection({
|
||||
}: EVSectionProps) {
|
||||
const bannerRef = useRef<HTMLDivElement>(null);
|
||||
const useDashboard = !!metrics && metrics.length > 0;
|
||||
const bottomBarItems = useDashboard ? barStats : stats;
|
||||
const isVideo = image.endsWith(".mp4");
|
||||
|
||||
/* In dashboard mode the workflow's accent theme is applied at the .evnd
|
||||
@@ -767,20 +827,30 @@ export default function EVSection({
|
||||
.evnd-feature__icon-container {
|
||||
width: 48px; height: 48px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
color: #ef4444 !important;
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease, 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);
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
color: #ff5555 !important;
|
||||
}
|
||||
|
||||
.evnd-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
color: #ef4444 !important;
|
||||
stroke: #ef4444 !important;
|
||||
}
|
||||
.evnd-icon path,
|
||||
.evnd-icon circle,
|
||||
.evnd-icon polygon,
|
||||
.evnd-icon rect {
|
||||
stroke: #ef4444 !important;
|
||||
}
|
||||
|
||||
.evnd-feature__title {
|
||||
@@ -1062,11 +1132,11 @@ export default function EVSection({
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
padding: 12px 24px;
|
||||
padding: 16px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.evnd__bar-item:not(:last-child)::after {
|
||||
content: '';
|
||||
@@ -1078,27 +1148,42 @@ export default function EVSection({
|
||||
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-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
opacity: 0.9;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
text-align: center;
|
||||
line-height: 1.25;
|
||||
width: 100%;
|
||||
}
|
||||
.evnd__bar-val {
|
||||
color: #ef4444 !important;
|
||||
font-weight: 800;
|
||||
font-size: clamp(30px, 3.8vw, 52px);
|
||||
line-height: 1.15;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
/* Non-numeric bar values (EVStat.text) — the bar's numeric clamp()
|
||||
(up to 56px) is too large for words/phrases; size down and let
|
||||
longer phrases (e.g. "Cost + Distance + SLA") wrap cleanly. */
|
||||
.evnd__bar-val--text {
|
||||
font-size: clamp(16px, 1.8vw, 22px);
|
||||
font-size: clamp(15px, 2vw, 22px);
|
||||
line-height: 1.25;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@keyframes evndGlow { 0%,100% { opacity: 0.75; } 50% { opacity: 1; } }
|
||||
@@ -1146,20 +1231,27 @@ export default function EVSection({
|
||||
}
|
||||
.evnd__bar {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px 0;
|
||||
gap: 0;
|
||||
width: calc(100% - 8px);
|
||||
max-width: calc(100% - 8px);
|
||||
margin: 32px auto 0;
|
||||
padding: 24px 0;
|
||||
margin: 28px auto 0;
|
||||
padding: 12px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.evnd.evnd--cards {
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 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; }
|
||||
.evnd__bar-item {
|
||||
padding: 16px 12px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.evnd__bar-item::after { display: none !important; }
|
||||
.evnd__bar-item:nth-child(even) { border-right: none; }
|
||||
.evnd__bar-item:nth-child(n+3) { border-bottom: none; }
|
||||
.evnd__bar-label { min-height: 32px; font-size: 12.5px; }
|
||||
.evnd__bar-val--text { font-size: clamp(14px, 3.8vw, 18px); }
|
||||
|
||||
/* ---- Scene overlay cleanup (mobile) ----
|
||||
Keep only the primary top-left KPI badge; the second (bottom-right)
|
||||
@@ -1237,10 +1329,10 @@ export default function EVSection({
|
||||
@media (max-width: 480px) {
|
||||
.evnd { padding: 40px 16px 48px; }
|
||||
.evnd { padding-inline: 0; }
|
||||
.evnd__bar { grid-template-columns: repeat(2, 1fr); gap: 18px 0; padding: 22px 0; }
|
||||
.evnd__bar-item { padding: 10px 12px; gap: 8px; }
|
||||
.evnd__bar-val { font-size: clamp(26px, 9vw, 34px); }
|
||||
.evnd__bar-label { font-size: 12.5px; }
|
||||
.evnd__bar { grid-template-columns: repeat(2, 1fr); gap: 0; padding: 8px 0; }
|
||||
.evnd__bar-item { padding: 14px 10px; }
|
||||
.evnd__bar-val { font-size: clamp(22px, 7vw, 30px); }
|
||||
.evnd__bar-label { font-size: 12px; min-height: 32px; }
|
||||
|
||||
/* feature cards (homepage): tighter, no overflow, more room for text */
|
||||
.evnd-feature {
|
||||
@@ -1296,7 +1388,7 @@ export default function EVSection({
|
||||
data-settings='{"background_background":"classic"}'
|
||||
style={{
|
||||
backgroundPosition: "center 0px",
|
||||
backgroundImage: `url(${bannerImage})`,
|
||||
backgroundImage: `url("${encodeURI(bannerImage)}")`,
|
||||
backgroundSize: "cover",
|
||||
backgroundRepeat: "no-repeat",
|
||||
position: "relative",
|
||||
@@ -1416,23 +1508,19 @@ export default function EVSection({
|
||||
{mediaSlot ? (
|
||||
mediaSlot
|
||||
) : isVideo ? (
|
||||
<video
|
||||
className="evnd__video"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
aria-label={imageAlt}
|
||||
>
|
||||
<source src={image} type="video/mp4" />
|
||||
</video>
|
||||
<LazyVideo src={image} ariaLabel={imageAlt} />
|
||||
) : (
|
||||
/* Same reason as LazyVideo: these stills are multi-MB raw
|
||||
PNGs (e.g. /solutions' "ev section1.png" is 2.6 MB at
|
||||
document y≈6793) and were being fetched and decoded
|
||||
eagerly while far below the fold. */
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
className="evnd__img"
|
||||
src={image}
|
||||
alt={imageAlt}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
{badges[0] && (
|
||||
@@ -1488,27 +1576,25 @@ export default function EVSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOTTOM BAR — full-width KPI summary strip below the workflow
|
||||
content (Scene → Insight/Decision card → KPI bar). Required across
|
||||
all breakpoints. In dashboard mode it summarises the same workflow
|
||||
metrics; the homepage section uses its own stat set. */}
|
||||
<div className="evnd__bar">
|
||||
{(useDashboard ? (barStats ?? metrics!) : stats).map((s) => (
|
||||
<div className="evnd__bar-item" key={s.label}>
|
||||
<span className="evnd__bar-label">{s.label}</span>
|
||||
{s.text ? (
|
||||
<b className="evnd__bar-val evnd__bar-val--text">{s.text}</b>
|
||||
) : (
|
||||
<CountUp
|
||||
value={s.value}
|
||||
decimals={s.decimals}
|
||||
suffix={s.suffix}
|
||||
className="evnd__bar-val"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{bottomBarItems && (
|
||||
<div className="evnd__bar">
|
||||
{bottomBarItems.map((s) => (
|
||||
<div className="evnd__bar-item" key={s.label}>
|
||||
<span className="evnd__bar-label">{s.label}</span>
|
||||
{s.text ? (
|
||||
<b className="evnd__bar-val evnd__bar-val--text">{s.text}</b>
|
||||
) : (
|
||||
<CountUp
|
||||
value={s.value}
|
||||
decimals={s.decimals}
|
||||
suffix={s.suffix}
|
||||
className="evnd__bar-val"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -169,30 +169,78 @@ export default function EmpowermentHowItWorks() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: single column — dividers rotate from vertical to horizontal. */
|
||||
/* Mobile: compact numbered editorial index — number in a narrow
|
||||
left column, title/description/divider sharing one right
|
||||
content column, laid out on the existing milestone markup via
|
||||
CSS Grid (no duplicated JSX, no new step markup). */
|
||||
@media (max-width: 640px) {
|
||||
.ehw-head {
|
||||
margin-bottom: clamp(40px, 10vw, 56px);
|
||||
}
|
||||
|
||||
.ehw-sequence {
|
||||
row-gap: 0;
|
||||
}
|
||||
|
||||
.ehw-step {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
column-gap: 14px;
|
||||
flex-basis: 100%;
|
||||
padding-left: 0;
|
||||
padding-top: clamp(28px, 8vw, 38px);
|
||||
padding-top: clamp(28px, 8vw, 36px);
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(17, 17, 17, 0.14);
|
||||
}
|
||||
.ehw-step:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
.ehw-step:nth-child(4) {
|
||||
border-top: 1px solid rgba(17, 17, 17, 0.14);
|
||||
}
|
||||
.ehw-step:hover {
|
||||
border-left-color: transparent;
|
||||
|
||||
.ehw-step:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Compact solid index number — replaces the oversized pale
|
||||
outline treatment used on desktop/tablet. */
|
||||
.ehw-step-num {
|
||||
font-size: clamp(44px, 13vw, 56px);
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
align-self: start;
|
||||
font-size: clamp(32px, 9vw, 40px);
|
||||
line-height: 1;
|
||||
color: #c8102e;
|
||||
-webkit-text-stroke: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Hidden completely — no reserved gap left in its place since a
|
||||
display:none grid item takes no track space. */
|
||||
.ehw-step-kicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ehw-step-title {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
font-size: clamp(22px, 6vw, 26px) !important;
|
||||
}
|
||||
|
||||
.ehw-step-desc {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
max-width: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Divider lives inside the description box (content column
|
||||
only), so it never runs underneath the number column. */
|
||||
.ehw-step-desc::after {
|
||||
content: "";
|
||||
display: block;
|
||||
margin-top: clamp(24px, 6vw, 28px);
|
||||
height: 1px;
|
||||
background-color: rgba(17, 17, 17, 0.14);
|
||||
}
|
||||
|
||||
.ehw-step:last-child .ehw-step-desc::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ export default function EmpowermentPrograms() {
|
||||
__html: `
|
||||
.epr-section {
|
||||
background: #ffffff;
|
||||
padding: clamp(78px, 9vw, 132px) 0;
|
||||
padding-top: clamp(28px, 3.2vw, 48px);
|
||||
padding-bottom: clamp(78px, 9vw, 132px);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -90,7 +91,11 @@ export default function EmpowermentPrograms() {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 0.38fr) minmax(0, 0.62fr);
|
||||
gap: clamp(32px, 5vw, 78px);
|
||||
align-items: start;
|
||||
/* stretch (not start): .epr-aside-wrap must span the full row
|
||||
height — the tall Programs column's implicit-row height — so it
|
||||
defines the sticky travel distance. The visual box no longer
|
||||
lives here, so stretching this item no longer distorts it. */
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -98,12 +103,55 @@ export default function EmpowermentPrograms() {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.epr-aside {
|
||||
position: sticky;
|
||||
top: 110px;
|
||||
/* Responsibility split (desktop sticky architecture):
|
||||
.epr-aside-wrap — plain grid item, stretched to the full
|
||||
Programs-list row height. Defines travel
|
||||
distance. No sizing responsibility of its
|
||||
own beyond that.
|
||||
.epr-aside-sticky — owns position: sticky. Its height is
|
||||
explicitly clamped to the viewport
|
||||
(100dvh minus header clearance and a
|
||||
bottom safe-margin), so it can never grow
|
||||
taller than what's actually visible —
|
||||
the tall min-height that previously fought
|
||||
the sticky top offset is gone entirely.
|
||||
.epr-aside — the visual composition (ghost number, map,
|
||||
heading, paragraph). Fills the sticky
|
||||
frame's height and arranges its existing
|
||||
content inside it; owns the decorative
|
||||
bleed clipping (overflow: hidden). */
|
||||
.epr-aside-wrap {
|
||||
min-width: 0;
|
||||
min-height: clamp(480px, 44vw, 660px);
|
||||
padding: clamp(18px, 3vw, 34px) 0 clamp(24px, 3.5vw, 42px);
|
||||
}
|
||||
|
||||
.epr-aside-sticky {
|
||||
/* 132px clears the site header's fixed state (~122px tall once
|
||||
scrolled) with a small margin; only grows on very tall
|
||||
viewports. This is viewport-height-derived, NOT content-height
|
||||
derived — unlike the old top formula, it never has to react to
|
||||
how tall the aside's own content happens to be. */
|
||||
--epr-sticky-top: clamp(132px, 12dvh, 160px);
|
||||
--epr-sticky-bottom-safe: 28px;
|
||||
position: sticky;
|
||||
top: var(--epr-sticky-top);
|
||||
height: calc(100dvh - var(--epr-sticky-top) - var(--epr-sticky-bottom-safe));
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
/* Lets the visual composition react to the frame's OWN resolved
|
||||
height (container query below) rather than guessing from the
|
||||
viewport or the write-up's vw-based type scale. */
|
||||
container-type: size;
|
||||
container-name: epr-frame;
|
||||
}
|
||||
|
||||
.epr-aside {
|
||||
--epr-aside-pad-t: clamp(18px, 3vw, 34px);
|
||||
--epr-aside-pad-b: clamp(24px, 3.5vw, 42px);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: var(--epr-aside-pad-t) 0 var(--epr-aside-pad-b);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -166,6 +214,26 @@ export default function EmpowermentPrograms() {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Constrained-frame fallback: the sticky frame is short (short
|
||||
desktop viewport height), so the decorative ghost number/map
|
||||
shrink first — spacing second — while the heading and paragraph
|
||||
stay full size and fully visible, per spec. Placed after the base
|
||||
.epr-aside-num/-content/-map rules so it wins the cascade at
|
||||
matching container sizes (equal selector specificity; source
|
||||
order decides). */
|
||||
@container epr-frame (max-height: 660px) {
|
||||
.epr-aside { padding-top: clamp(12px, 1.8vw, 22px); padding-bottom: clamp(16px, 2.2vw, 26px); }
|
||||
.epr-aside-content { gap: clamp(12px, 1.6vw, 20px); }
|
||||
.epr-aside-num { font-size: clamp(90px, 11vw, 180px); }
|
||||
}
|
||||
|
||||
@container epr-frame (max-height: 560px) {
|
||||
.epr-aside { padding-top: clamp(8px, 1.2vw, 14px); padding-bottom: clamp(10px, 1.6vw, 18px); }
|
||||
.epr-aside-content { gap: clamp(6px, 1vw, 12px); }
|
||||
.epr-aside-num { font-size: clamp(48px, 7vw, 100px); }
|
||||
.epr-aside-map { opacity: 0.14; }
|
||||
}
|
||||
|
||||
.epr-list {
|
||||
display: grid;
|
||||
gap: clamp(28px, 4vw, 52px);
|
||||
@@ -283,7 +351,6 @@ export default function EmpowermentPrograms() {
|
||||
/* Laptop: sticky stays enabled, just a narrower gutter. */
|
||||
@media (max-width: 1180px) {
|
||||
.epr-stage { grid-template-columns: minmax(220px, 0.34fr) minmax(0, 0.66fr); gap: clamp(28px, 4vw, 46px); }
|
||||
.epr-aside { min-height: 420px; }
|
||||
.epr-row:nth-child(even) { margin-left: 0; }
|
||||
}
|
||||
|
||||
@@ -291,9 +358,11 @@ export default function EmpowermentPrograms() {
|
||||
of pinning, since there's no longer enough vertical runway next to
|
||||
the (now narrower, shorter) program list for pinning to read well. */
|
||||
@media (max-width: 1023px) {
|
||||
.epr-aside {
|
||||
.epr-aside-sticky {
|
||||
position: relative;
|
||||
top: auto;
|
||||
height: auto;
|
||||
container-type: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +396,7 @@ export default function EmpowermentPrograms() {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.epr-section { padding-top: 54px; padding-bottom: 54px; }
|
||||
.epr-section { padding-top: 28px; padding-bottom: 54px; }
|
||||
.epr-title {
|
||||
font-size: clamp(34px, 10vw, 40px) !important;
|
||||
letter-spacing: -0.045em !important;
|
||||
@@ -357,18 +426,22 @@ export default function EmpowermentPrograms() {
|
||||
</div>
|
||||
|
||||
<div className="epr-stage">
|
||||
<aside className="epr-aside">
|
||||
<div className="epr-aside-map" aria-hidden="true">
|
||||
<IndustryWorldMap />
|
||||
<div className="epr-aside-wrap">
|
||||
<div className="epr-aside-sticky">
|
||||
<aside className="epr-aside">
|
||||
<div className="epr-aside-map" aria-hidden="true">
|
||||
<IndustryWorldMap />
|
||||
</div>
|
||||
<ScrollReveal delay={0.05} duration={0.8} yOffset={24} className="epr-aside-content">
|
||||
<div className="epr-aside-num">05</div>
|
||||
<p className="epr-aside-copy">Building tomorrow’s <em>women entrepreneurs.</em></p>
|
||||
<p className="epr-aside-desc">
|
||||
Every enterprise starts with one decision. Our pillars move women from idea to ownership through mentorship, real industry partnerships, and hands-on venture building across the logistics ecosystem.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</aside>
|
||||
</div>
|
||||
<ScrollReveal delay={0.05} duration={0.8} yOffset={24} className="epr-aside-content">
|
||||
<div className="epr-aside-num">05</div>
|
||||
<p className="epr-aside-copy">Building tomorrow’s <em>women entrepreneurs.</em></p>
|
||||
<p className="epr-aside-desc">
|
||||
Every enterprise starts with one decision. Our pillars move women from idea to ownership through mentorship, real industry partnerships, and hands-on venture building across the logistics ecosystem.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ScrollReveal delay={0.1} duration={0.8} yOffset={24}>
|
||||
|
||||
@@ -265,7 +265,14 @@ export default function EmpowermentStories() {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.est-section { padding-top: 54px; padding-bottom: 54px; }
|
||||
.est-section { padding-top: 54px; padding-bottom: 6px; }
|
||||
/* .est-feature's margin-bottom (clamp(44px, 6vw, 76px)) was sized
|
||||
to precede an .est-list testimonial row block that no longer
|
||||
exists in this component's JSX. Since .est-feature is now the
|
||||
section's last child, that margin was pure dead space stacking
|
||||
on top of .est-section's own padding-bottom above — the real
|
||||
source of the oversized mobile gap before the next section. */
|
||||
.est-feature { margin-bottom: 0; }
|
||||
.est-title {
|
||||
font-size: clamp(34px, 10vw, 40px) !important;
|
||||
letter-spacing: -0.045em !important;
|
||||
|
||||
@@ -301,8 +301,8 @@ export default function HowItWorksHero() {
|
||||
<div className="text-content">
|
||||
<p>
|
||||
See how Doormile connects first, mid, and
|
||||
last mile into a seamless delivery
|
||||
experience powered by MileTruth™ AI.
|
||||
last mile into a seamless end-to-end
|
||||
logistics experience powered by MileTruth™ AI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,8 +341,8 @@ export default function HowItWorksHero() {
|
||||
<div className="text-content">
|
||||
<p>
|
||||
See how Doormile connects first, mid, and
|
||||
last mile into a seamless delivery
|
||||
experience powered by MileTruth™ AI.
|
||||
last mile into a seamless end-to-end
|
||||
logistics experience powered by MileTruth™ AI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
376
src/components/sections/MileTruthCanvas.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { Html } from "@react-three/drei";
|
||||
import { EffectComposer, Bloom } from "@react-three/postprocessing";
|
||||
import { KernelSize } from "postprocessing";
|
||||
import * as THREE from "three";
|
||||
|
||||
/* ============================================================
|
||||
MileTruthCanvas — the "MileTruth AI" learning loop.
|
||||
|
||||
A CLOSED cycle, not a line: shipment data enters, the AI core
|
||||
reads it, a recommendation is issued, the result is measured,
|
||||
and that result feeds the next shipment. Packets travel the
|
||||
whole ring and visibly return to where they started — the
|
||||
shape itself is the pitch ("every shipment makes the next one
|
||||
smarter"). Each station is labelled on screen, so the diagram
|
||||
explains itself without reading the copy beside it.
|
||||
|
||||
Deliberately lightweight — this and the Quantum section are
|
||||
the ONLY two places on the Solutions page that touch Three.js.
|
||||
============================================================ */
|
||||
|
||||
const RED = "#C01227";
|
||||
const RED_BRIGHT = "#E2354A";
|
||||
const STEEL = "#8b8b96";
|
||||
const WHITE = "#f4f4f6";
|
||||
|
||||
/** Loop radii (world units) and the tilt that turns the flat ring
|
||||
* into a readable orbit with depth. */
|
||||
const RX = 2.55;
|
||||
const RZ = 1.7;
|
||||
const TILT = 0.55;
|
||||
|
||||
/** Packets. Offsets are deliberately uneven so one isn't parked on
|
||||
* a station at all times. SPEED ~= one full lap every 12s. */
|
||||
const PACKET_OFFSETS = [0, 0.17, 0.34, 0.62, 0.81];
|
||||
const SPEED = 0.085;
|
||||
|
||||
/** Where the AI core sits on the loop (station 02). */
|
||||
const INGEST_U = 0.25;
|
||||
|
||||
/**
|
||||
* Analytic point on the loop. u=0 is the left-hand station and u
|
||||
* increases left -> back -> right -> front, so the cycle reads
|
||||
* clockwise on screen. Kept analytic (rather than curve.getPointAt)
|
||||
* so packets pass exactly through each station.
|
||||
*/
|
||||
function pointAt(u: number, out = new THREE.Vector3()) {
|
||||
const th = Math.PI + u * Math.PI * 2;
|
||||
return out.set(RX * Math.cos(th), 0, RZ * Math.sin(th));
|
||||
}
|
||||
|
||||
/** Shortest distance between two positions on a unit-length loop. */
|
||||
function loopDist(a: number, b: number) {
|
||||
const d = Math.abs(a - b) % 1;
|
||||
return Math.min(d, 1 - d);
|
||||
}
|
||||
|
||||
/**
|
||||
* How hard the core is "ingesting" right now: 1 when a packet is on
|
||||
* the AI station, falling off either side. Derived purely from the
|
||||
* clock, so every component can recompute it without shared state.
|
||||
*/
|
||||
function ingestEnergy(t: number) {
|
||||
let e = 0;
|
||||
for (const off of PACKET_OFFSETS) {
|
||||
const u = (t * SPEED + off) % 1;
|
||||
e = Math.max(e, Math.exp(-Math.pow(loopDist(u, INGEST_U) / 0.045, 2)));
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
type Station = {
|
||||
u: number;
|
||||
n: string;
|
||||
name: string;
|
||||
figure: string;
|
||||
color: string;
|
||||
/** Label offset along Y, in loop-space units. Negative parks the label
|
||||
* below the station — used for the front stop, whose artwork grows
|
||||
* upward and would otherwise sit under its own caption. */
|
||||
labelY: number;
|
||||
};
|
||||
|
||||
/** Illustrative sample figures — see the "Illustrative" marker rendered
|
||||
* by MileTruthNetwork. Swap these four strings for real numbers. */
|
||||
const STATIONS: Station[] = [
|
||||
{ u: 0, n: "01", name: "Shipment Data", figure: "12,480 analysed", color: STEEL, labelY: 0.95 },
|
||||
{ u: 0.25, n: "02", name: "MileTruth AI", figure: "148 patterns found", color: RED_BRIGHT, labelY: 1.15 },
|
||||
{ u: 0.5, n: "03", name: "Recommendation", figure: "36 actions issued", color: RED_BRIGHT, labelY: 0.95 },
|
||||
{ u: 0.75, n: "04", name: "Measured Result", figure: "+6.2% on-time", color: WHITE, labelY: -0.75 },
|
||||
];
|
||||
|
||||
/* ---------- the loop itself ---------- */
|
||||
|
||||
function LoopPath() {
|
||||
const geo = useMemo(() => {
|
||||
const pts: THREE.Vector3[] = [];
|
||||
for (let i = 0; i < 96; i++) pts.push(pointAt(i / 96));
|
||||
const curve = new THREE.CatmullRomCurve3(pts, true, "centripetal");
|
||||
// A tube (not lineBasicMaterial) so the path actually catches light
|
||||
// and bloom — thin lines washed out to nothing in the old scene.
|
||||
return new THREE.TubeGeometry(curve, 220, 0.016, 8, true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<mesh geometry={geo}>
|
||||
<meshBasicMaterial color={RED} transparent opacity={0.55} depthWrite={false} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- station artwork ---------- */
|
||||
|
||||
/** Each station gets a distinct silhouette, so the four steps stay
|
||||
* tellable apart even before you read their labels. */
|
||||
function StationArt({ index, color }: { index: number; color: string }) {
|
||||
const mat = (
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.9}
|
||||
roughness={0.35}
|
||||
metalness={0.1}
|
||||
/>
|
||||
);
|
||||
|
||||
if (index === 0) {
|
||||
// Shipment data — a stack of parcels.
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[-0.13, 0.07, 0]}>
|
||||
<boxGeometry args={[0.2, 0.2, 0.2]} />
|
||||
{mat}
|
||||
</mesh>
|
||||
<mesh position={[0.12, 0.05, 0.05]} rotation={[0, 0.5, 0]}>
|
||||
<boxGeometry args={[0.16, 0.16, 0.16]} />
|
||||
{mat}
|
||||
</mesh>
|
||||
<mesh position={[0, 0.26, -0.04]} rotation={[0, 0.25, 0]}>
|
||||
<boxGeometry args={[0.15, 0.15, 0.15]} />
|
||||
{mat}
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (index === 1) {
|
||||
// MileTruth AI — handled by <AiCore/>, which needs its own frame loop.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (index === 2) {
|
||||
// Recommendation — a marker inside a ring, i.e. an issued action.
|
||||
return (
|
||||
<group position={[0, 0.15, 0]}>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.13, 20, 20]} />
|
||||
{mat}
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.26, 0.018, 10, 40]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.7} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Measured result — three rising bars.
|
||||
return (
|
||||
<group>
|
||||
{[0.14, 0.24, 0.36].map((h, i) => (
|
||||
<mesh key={h} position={[(i - 1) * 0.16, h / 2, 0]}>
|
||||
<boxGeometry args={[0.1, h, 0.1]} />
|
||||
{mat}
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function StationNode({
|
||||
station,
|
||||
index,
|
||||
compact,
|
||||
reduced,
|
||||
}: {
|
||||
station: Station;
|
||||
index: number;
|
||||
compact: boolean;
|
||||
reduced: boolean;
|
||||
}) {
|
||||
const pos = useMemo(() => pointAt(station.u).toArray() as [number, number, number], [station.u]);
|
||||
|
||||
return (
|
||||
<group position={pos}>
|
||||
{index === 1 ? <AiCore reduced={reduced} /> : <StationArt index={index} color={station.color} />}
|
||||
{/* Pad on the loop, so each station reads as a stop rather than
|
||||
a mesh floating near the path. */}
|
||||
{/* Always red, never the station colour — a white or steel pad reads
|
||||
as a grey smudge under the artwork rather than as a stop. */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[0.32, 32]} />
|
||||
<meshBasicMaterial color={RED_BRIGHT} transparent opacity={0.12} depthWrite={false} />
|
||||
</mesh>
|
||||
|
||||
<Html center pointerEvents="none" zIndexRange={[12, 0]} position={[0, station.labelY, 0]}>
|
||||
<div className="mtn-lbl">
|
||||
<span className="mtn-lbl-n">{station.n}</span>
|
||||
<span className="mtn-lbl-name">{station.name}</span>
|
||||
{!compact && <span className="mtn-lbl-fig">{station.figure}</span>}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- station 02: the AI core ---------- */
|
||||
|
||||
/**
|
||||
* The brain sits ON the loop as station 02 rather than in the middle of
|
||||
* it. A centre core reads as a second, competing object and — from this
|
||||
* camera — hides whatever station sits directly behind it.
|
||||
*/
|
||||
function AiCore({ reduced }: { reduced: boolean }) {
|
||||
const innerRef = useRef<THREE.Mesh>(null);
|
||||
const shellRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
const t = reduced ? 0 : state.clock.elapsedTime;
|
||||
// Steady mid-glow when motion is reduced, so the core still reads as
|
||||
// the active element without animating.
|
||||
const e = reduced ? 0.45 : ingestEnergy(t);
|
||||
|
||||
if (innerRef.current) {
|
||||
innerRef.current.scale.setScalar(1 + e * 0.14);
|
||||
const m = innerRef.current.material as THREE.MeshStandardMaterial;
|
||||
m.emissiveIntensity = 0.8 + e * 1.2;
|
||||
}
|
||||
if (shellRef.current) {
|
||||
if (!reduced) shellRef.current.rotation.y = t * 0.25;
|
||||
const m = shellRef.current.material as THREE.MeshBasicMaterial;
|
||||
m.opacity = 0.2 + e * 0.35;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={[0, 0.22, 0]}>
|
||||
<mesh ref={innerRef}>
|
||||
<icosahedronGeometry args={[0.26, 1]} />
|
||||
<meshStandardMaterial
|
||||
color={RED}
|
||||
emissive={RED_BRIGHT}
|
||||
emissiveIntensity={0.8}
|
||||
roughness={0.3}
|
||||
metalness={0.15}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh ref={shellRef} rotation={[0.4, 0, 0]}>
|
||||
<icosahedronGeometry args={[0.46, 1]} />
|
||||
<meshBasicMaterial color={RED_BRIGHT} wireframe transparent opacity={0.22} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- packets travelling the loop ---------- */
|
||||
|
||||
function Packet({ offset, reduced }: { offset: number; reduced: boolean }) {
|
||||
const ref = useRef<THREE.Group>(null);
|
||||
const vec = useMemo(() => new THREE.Vector3(), []);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!ref.current) return;
|
||||
// When reduced, packets freeze at their offsets rather than
|
||||
// disappearing — the ring still shows that something flows.
|
||||
const t = reduced ? 0 : state.clock.elapsedTime;
|
||||
const u = (t * SPEED + offset) % 1;
|
||||
pointAt(u, vec);
|
||||
ref.current.position.set(vec.x, 0.16, vec.z);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={ref}>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.07, 12, 12]} />
|
||||
<meshBasicMaterial color={WHITE} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.15, 12, 12]} />
|
||||
<meshBasicMaterial color={RED_BRIGHT} transparent opacity={0.25} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- fit ---------- */
|
||||
|
||||
/** Half-width of the widest station label ("Recommendation"), plus a
|
||||
* little breathing room, in CSS pixels. */
|
||||
const LABEL_HALF_PX = 60;
|
||||
|
||||
/**
|
||||
* Scales the loop so the outermost stations AND their labels stay inside
|
||||
* the canvas at any width. The labels are DOM, so their size is fixed in
|
||||
* pixels while the scene is measured in world units — the fit therefore
|
||||
* has to be computed against the pixel size, not the aspect ratio alone.
|
||||
* Vertical fit needs no handling: with a fixed camera the visible world
|
||||
* height is constant regardless of how short the card gets.
|
||||
*/
|
||||
function FitLoop({ children }: { children: React.ReactNode }) {
|
||||
const size = useThree((s) => s.size);
|
||||
const viewport = useThree((s) => s.viewport);
|
||||
|
||||
const scale = useMemo(() => {
|
||||
const pxPerWorld = size.width / viewport.width;
|
||||
const room = size.width / 2 - LABEL_HALF_PX;
|
||||
return Math.max(0.55, Math.min(1, room / (RX * pxPerWorld)));
|
||||
}, [size.width, viewport.width]);
|
||||
|
||||
return <group scale={scale}>{children}</group>;
|
||||
}
|
||||
|
||||
/* ---------- scene ---------- */
|
||||
|
||||
export default function MileTruthCanvas({
|
||||
reduced = false,
|
||||
isMobile = false,
|
||||
active = true,
|
||||
}: {
|
||||
reduced?: boolean;
|
||||
isMobile?: boolean;
|
||||
active?: boolean;
|
||||
}) {
|
||||
const packets = isMobile ? PACKET_OFFSETS.slice(0, 3) : PACKET_OFFSETS;
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
flat
|
||||
frameloop={active ? "always" : "never"}
|
||||
dpr={[1, isMobile || reduced ? 1.2 : 1.6]}
|
||||
camera={{ position: [0, 0, 7.6], fov: 40, near: 0.1, far: 60 }}
|
||||
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
|
||||
>
|
||||
<color attach="background" args={["#0c0c10"]} />
|
||||
<ambientLight intensity={0.75} />
|
||||
<directionalLight position={[2.5, 4, 4]} intensity={1.1} />
|
||||
|
||||
{/* One fixed tilt on the whole loop. Nothing spins the group —
|
||||
the reading order has to hold still to stay legible. */}
|
||||
<FitLoop>
|
||||
<group rotation={[TILT, 0, 0]}>
|
||||
{/* Faint disc inside the ring — grounds the ellipse as a plane so
|
||||
the loop reads as an orbit rather than a floating outline. */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[RX * 0.92, 48]} />
|
||||
<meshBasicMaterial color={RED} transparent opacity={0.035} depthWrite={false} />
|
||||
</mesh>
|
||||
<LoopPath />
|
||||
{STATIONS.map((s, i) => (
|
||||
<StationNode key={s.n} station={s} index={i} compact={isMobile} reduced={reduced} />
|
||||
))}
|
||||
{packets.map((off) => (
|
||||
<Packet key={off} offset={off} reduced={reduced} />
|
||||
))}
|
||||
</group>
|
||||
</FitLoop>
|
||||
|
||||
<EffectComposer enabled={!isMobile} multisampling={isMobile ? 0 : 2}>
|
||||
<Bloom kernelSize={KernelSize.SMALL} luminanceThreshold={0.25} intensity={0.4} mipmapBlur />
|
||||
</EffectComposer>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
@@ -260,9 +260,6 @@ export default function MileTruthHero() {
|
||||
}
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.miletruth-hero .elementor-element-86f3204 {
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
.miletruth-hero-container {
|
||||
min-height: 600px;
|
||||
padding: 80px 0 !important;
|
||||
@@ -290,6 +287,15 @@ export default function MileTruthHero() {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
/* Horizontal inset switches to 10px here (not at 1024px) to match the
|
||||
breakpoint every other MileTruth section (Intro, Workflow1/2/4) uses
|
||||
for the same 20px -> 10px step, so the Hero's left/right edges stay
|
||||
aligned with the rest of the page through the whole 768-1024px range. */
|
||||
@media (max-width: 767px) {
|
||||
.miletruth-hero .elementor-element-86f3204 {
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 580px) {
|
||||
.miletruth-hero-container {
|
||||
min-height: 500px;
|
||||
@@ -358,7 +364,7 @@ export default function MileTruthHero() {
|
||||
</h1>
|
||||
<div className="content-slider-item-text logico-content-wrapper-2">
|
||||
<div className="text-content">
|
||||
<p>Optimizes every stage of the delivery journey for maximum efficiency.</p>
|
||||
<p>Optimizes every stage of the logistics journey for maximum efficiency.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
212
src/components/sections/MileTruthIntro.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
const MILETRUTH_METRICS = [
|
||||
{
|
||||
icon: (
|
||||
<svg className="mti-icon" width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
),
|
||||
value: "100+",
|
||||
label: "parallel route permutations evaluated per order",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="mti-icon" width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
),
|
||||
value: "38ms",
|
||||
label: "decision latency before instant auto-dispatch",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="mti-icon" width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<polyline points="9 12 11 14 15 10" />
|
||||
</svg>
|
||||
),
|
||||
value: "99.9%",
|
||||
label: "guaranteed on-time SLA delivery accuracy",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="mti-icon" width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 18l-9.5-9.5-5 5L1 6" />
|
||||
<polyline points="17 18 23 18 23 12" />
|
||||
</svg>
|
||||
),
|
||||
value: "-22%",
|
||||
label: "reduction in total fuel & operational costs",
|
||||
},
|
||||
];
|
||||
|
||||
export default function MileTruthIntro() {
|
||||
return (
|
||||
<>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
.mti-shell {
|
||||
width: calc(100% - 40px);
|
||||
margin: 40px 20px clamp(36px, 4vw, 56px);
|
||||
}
|
||||
.mti {
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
padding: clamp(36px, 4.5vw, 56px) clamp(24px, 4vw, 48px);
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.mti__inner {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.mti__head {
|
||||
max-width: 860px;
|
||||
margin-bottom: clamp(36px, 4.5vw, 52px);
|
||||
}
|
||||
.mti__eyebrow {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: #6b7280;
|
||||
margin-bottom: 14px;
|
||||
text-transform: uppercase;
|
||||
display: block;
|
||||
}
|
||||
.mti__divider {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
margin: 0 0 clamp(20px, 2.4vw, 28px);
|
||||
width: 100%;
|
||||
}
|
||||
.mti__heading {
|
||||
margin: 0 0 clamp(16px, 2vw, 24px);
|
||||
color: #111827 !important;
|
||||
font-weight: 800 !important;
|
||||
font-size: clamp(32px, 4.2vw, 56px) !important;
|
||||
line-height: 1.12 !important;
|
||||
letter-spacing: -0.025em;
|
||||
text-transform: none !important;
|
||||
}
|
||||
.mti__accent {
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
.mti__desc {
|
||||
font-size: clamp(16px, 1.15vw, 18px);
|
||||
line-height: 1.65;
|
||||
color: #4b5563;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
max-width: 780px;
|
||||
}
|
||||
.mti-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: clamp(20px, 3vw, 36px);
|
||||
padding-top: clamp(28px, 3.5vw, 40px);
|
||||
}
|
||||
.mti-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.mti-card:last-child {
|
||||
border-right: none;
|
||||
padding-right: 0;
|
||||
}
|
||||
.mti-card-icon {
|
||||
color: #111827;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.35s ease, color 0.35s ease;
|
||||
}
|
||||
.mti-card:hover .mti-card-icon {
|
||||
transform: translateY(-4px) scale(1.08);
|
||||
color: #dc2626;
|
||||
}
|
||||
.mti-icon {
|
||||
transition: stroke 0.35s ease;
|
||||
}
|
||||
|
||||
.mti-card-val {
|
||||
font-size: clamp(32px, 3.2vw, 46px);
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.mti-card-label {
|
||||
font-size: 14.5px;
|
||||
line-height: 1.5;
|
||||
color: #4b5563;
|
||||
font-weight: 450;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.mti-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
.mti-card {
|
||||
border-right: none;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.mti-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.mti-shell {
|
||||
width: calc(100% - 20px);
|
||||
margin: 24px 10px 32px;
|
||||
}
|
||||
.mti {
|
||||
padding: 28px 20px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<div className="mti-shell">
|
||||
<section className="mti" aria-label="MileTruth AI introduction">
|
||||
<div className="mti__inner">
|
||||
<div className="mti__head">
|
||||
<span className="mti__eyebrow">/ MileTruth AI /</span>
|
||||
<hr className="mti__divider" />
|
||||
<h2 className="mti__heading">
|
||||
Routing is a <span className="mti__accent">Decision Problem.</span>
|
||||
</h2>
|
||||
<p className="mti__desc">
|
||||
Routing is not simply one static line between two points. Orders change, vehicles have strict capacity limits, and traffic dictates what is possible. MileTruth evaluates every option in real time to commit to the single strongest plan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 4-Column Animated SVG KPI Grid */}
|
||||
<div className="mti-grid">
|
||||
{MILETRUTH_METRICS.map((m) => (
|
||||
<div className="mti-card" key={m.value}>
|
||||
<div className="mti-card-icon">{m.icon}</div>
|
||||
<div className="mti-card-val">{m.value}</div>
|
||||
<div className="mti-card-label">{m.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
src/components/sections/MileTruthNetwork.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
/* ============================================================
|
||||
MileTruthNetwork — gated mount for MileTruthCanvas (the lightweight
|
||||
R3F data -> AI -> recommendations -> improvement scene). Mirrors
|
||||
WorkflowScene.tsx: code-split, mounts a little before it scrolls
|
||||
into view, pauses the render loop off-screen, and respects
|
||||
prefers-reduced-motion.
|
||||
============================================================ */
|
||||
|
||||
const MileTruthCanvas = dynamic(() => import("./MileTruthCanvas"), { ssr: false });
|
||||
|
||||
export default function MileTruthNetwork({ ariaLabel }: { ariaLabel?: string }) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const [mountScene, setMountScene] = useState(false);
|
||||
const [active, setActive] = 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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Single observer: mount the scene a little before it scrolls into view and
|
||||
// keep it active from then on (this scene is small/light enough that a
|
||||
// second "pause when off-screen" observer isn't worth the added race risk
|
||||
// between two independent IntersectionObservers on the same element).
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const mountIo = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
setMountScene(true);
|
||||
setActive(true);
|
||||
mountIo.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "50% 0px" },
|
||||
);
|
||||
mountIo.observe(el);
|
||||
return () => mountIo.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="mtn-scene" role="img" aria-label={ariaLabel ?? "MileTruth AI network"}>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
.mtn-scene { position: relative; width: 100%; height: 100%; min-height: 360px; }
|
||||
.mtn-scene canvas { display: block; width: 100% !important; height: 100% !important; }
|
||||
|
||||
/* Station labels. Rendered as DOM by drei <Html> inside the canvas
|
||||
wrapper, so the text stays crisp at any DPR and can use the
|
||||
site's own type scale. */
|
||||
.mtn-lbl { display: flex; flex-direction: column; align-items: center; gap: 2px; white-space: nowrap; text-align: center; user-select: none; pointer-events: none; }
|
||||
.mtn-lbl-n { font-size: 9.5px; font-weight: 800; letter-spacing: 0.14em; color: #E2354A; line-height: 1; }
|
||||
.mtn-lbl-name { font-size: 12px; font-weight: 700; color: #fff; line-height: 1.2; text-shadow: 0 1px 6px rgba(0,0,0,0.8); }
|
||||
.mtn-lbl-fig { font-size: 10.5px; font-weight: 500; color: rgba(255,255,255,0.6); line-height: 1.2; }
|
||||
|
||||
/* Sample-data marker — these figures are illustrative, not audited. */
|
||||
.mtn-tag { position: absolute; right: 12px; bottom: 12px; z-index: 13; display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 999px; background: rgba(12,12,16,0.72); border: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.5); font-size: 10px; font-weight: 600; letter-spacing: 0.04em; pointer-events: none; }
|
||||
.mtn-tag i { width: 4px; height: 4px; border-radius: 50%; background: #E2354A; display: block; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mtn-scene { min-height: 300px; }
|
||||
.mtn-lbl-name { font-size: 11px; }
|
||||
.mtn-tag { right: 8px; bottom: 8px; font-size: 9px; padding: 4px 8px; }
|
||||
}
|
||||
`}} />
|
||||
{mountScene && <MileTruthCanvas reduced={reduced} isMobile={isMobile} active={active} />}
|
||||
{mountScene && (
|
||||
<span className="mtn-tag" aria-hidden="true"><i />Illustrative — sample insight cycle</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,26 @@ import FirstMileIcon from "../icons/FirstMileIcon";
|
||||
import MidMileIcon from "../icons/MidMileIcon";
|
||||
import LastMileIcon from "../icons/LastMileIcon";
|
||||
|
||||
export default function Miles3() {
|
||||
interface Miles3Props {
|
||||
/** Eyebrow above the heading. Defaults to the original How It Works label. */
|
||||
eyebrow?: string;
|
||||
/** Section heading. Defaults to the original How It Works copy. */
|
||||
heading?: string;
|
||||
/**
|
||||
* Extra content rendered INSIDE the same dark card, directly below the
|
||||
* First/Mid/Last Mile grid — so it reads as one continuous section rather
|
||||
* than a new one. Used on the Solutions page for the Platform Capabilities
|
||||
* carousel. When present, the card also gains a bottom pad so the appended
|
||||
* block never touches the rounded edge.
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Miles3({
|
||||
eyebrow = "/ How It Works /",
|
||||
heading = "Doormile connects first, mid, and last mile into a seamless end-to-end logistics experience",
|
||||
children,
|
||||
}: Miles3Props = {}) {
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@@ -21,6 +40,7 @@ export default function Miles3() {
|
||||
.elementor-element-c36a604 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
width: auto;
|
||||
margin: 20px 20px 0 20px;
|
||||
background-color: #1F1F1F;
|
||||
@@ -39,6 +59,7 @@ export default function Miles3() {
|
||||
.elementor-element-77d1265 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
max-width: 1630px;
|
||||
margin: 0 auto;
|
||||
@@ -104,6 +125,13 @@ export default function Miles3() {
|
||||
not grid-template-columns directly, or the grid never collapses on mobile. */
|
||||
--e-con-grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
/* Same trap for the ROWS: ".e-con.e-grid" also drives grid-template-rows
|
||||
from a var that defaults to repeat(2, 1fr), reserving a second, EMPTY
|
||||
row (plus the 70px row-gap) under the 3 mile cards — ~380px of dead
|
||||
space before whatever follows in the card. "auto" sizes the one real
|
||||
row to its content and still lets the 2-col/1-col breakpoints create
|
||||
implicit rows. */
|
||||
--e-con-grid-template-rows: auto;
|
||||
grid-auto-flow: row;
|
||||
gap: 70px 60px;
|
||||
width: 100%;
|
||||
@@ -161,6 +189,25 @@ export default function Miles3() {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Continuation block (Platform Capabilities carousel) — lives inside the
|
||||
same dark card, below the mile grid. Its own top border + generous
|
||||
top/bottom rhythm keep it reading as "more of this section". The
|
||||
card itself has no bottom padding, so this block owns the bottom
|
||||
breathing room too. */
|
||||
.miles3-continue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
align-self: stretch;
|
||||
margin-top: 28px;
|
||||
padding-bottom: clamp(60px, 6vw, 90px);
|
||||
}
|
||||
|
||||
/* ---- Responsive (grid breakpoints mirror the original section-miles3
|
||||
cascade: 3-col@120gap >1200, 3-col@40gap ≤1200, 2-col ≤1020, 1-col
|
||||
≤480). The ≤1200 step is what keeps columns wide enough at laptop
|
||||
@@ -190,12 +237,12 @@ export default function Miles3() {
|
||||
<div className="elementor-element elementor-element-5bc90f3 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="5bc90f3" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-176d17f elementor-widget elementor-widget-logico_heading" data-id="176d17f" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">/ How It Works /</div>
|
||||
<div className="logico-title">{eyebrow}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-63a9de5 elementor-widget elementor-widget-logico_heading" data-id="63a9de5" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<h3 className="logico-title">Doormile connects first, mid, and last mile into a seamless delivery experience</h3>
|
||||
<h3 className="logico-title">{heading}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,7 +290,7 @@ export default function Miles3() {
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-3ae1ce0 elementor-widget elementor-widget-text-editor" data-id="3ae1ce0" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||
<div className="elementor-widget-container">
|
||||
<p>Smooth inter-city delivery through strategically located hubs with live tracking.</p>
|
||||
<p>Smooth inter-city logistics through strategically located hubs with live tracking.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,12 +313,14 @@ export default function Miles3() {
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-1057c22 elementor-widget elementor-widget-text-editor" data-id="1057c22" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||
<div className="elementor-widget-container">
|
||||
<p>Packages are delivered to customers with real-time updates and proof of delivery.</p>
|
||||
<p>Packages reach customers with real-time updates and proof of delivery.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children ? <div className="miles3-continue">{children}</div> : null}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
332
src/components/sections/NeuralField.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
/* ============================================================
|
||||
NeuralField — the animated neural-network + particle backdrop behind the
|
||||
MileTruth™ AI band on /solutions.
|
||||
|
||||
Deliberately a plain 2D canvas rather than the R3F MileTruthCanvas scene:
|
||||
this one sits *behind* body copy at low opacity, so it has to stay cheap
|
||||
enough to run under the fold of a long marketing page. It follows the same
|
||||
rules as the other canvases on the site (WorkflowScene, MileTruthNetwork):
|
||||
|
||||
· DPR-aware, resized from the parent box (ResizeObserver)
|
||||
· rAF paused while off-screen and while the tab is hidden
|
||||
· prefers-reduced-motion paints ONE static frame and stops
|
||||
|
||||
Three layers, back to front:
|
||||
1. particle field — tiny drifting motes, no links
|
||||
2. neural mesh — drifting nodes + proximity links, accent-tinted as
|
||||
the two ends get closer
|
||||
3. signals — a few bright packets travelling node-to-node, which
|
||||
is what reads as "a decision engine thinking"
|
||||
============================================================ */
|
||||
|
||||
type Node = {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
r: number;
|
||||
hub: boolean;
|
||||
/** phase offset so hub glow does not pulse in lockstep */
|
||||
ph: number;
|
||||
};
|
||||
|
||||
type Mote = { x: number; y: number; vy: number; sway: number; ph: number; a: number };
|
||||
|
||||
type Signal = { a: number; b: number; t: number; sp: number };
|
||||
|
||||
/** Distance at which two nodes stop being linked (CSS px). */
|
||||
const LINK = 132;
|
||||
/** Pointer radius that brightens the mesh. */
|
||||
const CURSOR = 170;
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const h = hex.replace("#", "");
|
||||
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
||||
const n = parseInt(full, 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
export default function NeuralField({
|
||||
accent = "#E2354A",
|
||||
tone = "dark",
|
||||
className,
|
||||
}: {
|
||||
accent?: string;
|
||||
/** Surface the field is painted on. "light" inks the mesh near-black instead
|
||||
* of white and drops the additive glow, which is invisible on paper. */
|
||||
tone?: "dark" | "light";
|
||||
className?: string;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const parent = canvas?.parentElement;
|
||||
if (!canvas || !parent) return;
|
||||
const ctx = canvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const [ar, ag, ab] = hexToRgb(accent);
|
||||
const light = tone === "light";
|
||||
// The ink the mesh fades towards when a link is long / far from the pointer.
|
||||
const [ir, ig, ib] = light ? [17, 17, 17] : [255, 255, 255];
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let nodes: Node[] = [];
|
||||
let motes: Mote[] = [];
|
||||
let signals: Signal[] = [];
|
||||
let raf = 0;
|
||||
let visible = true;
|
||||
let running = false;
|
||||
let last = 0;
|
||||
let nextSignal = 600;
|
||||
const pointer = { x: -9999, y: -9999 };
|
||||
|
||||
const rand = (a: number, b: number) => a + Math.random() * (b - a);
|
||||
|
||||
function build() {
|
||||
const area = w * h;
|
||||
// One node per ~24k css px², clamped — enough to read as a mesh at
|
||||
// 1440px wide without turning the O(n²) link pass into real work.
|
||||
const count = Math.max(16, Math.min(72, Math.round(area / 24000)));
|
||||
nodes = Array.from({ length: count }, (_, i) => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: rand(-0.14, 0.14),
|
||||
vy: rand(-0.11, 0.11),
|
||||
r: rand(1.1, 2.3),
|
||||
hub: i % 9 === 0,
|
||||
ph: Math.random() * Math.PI * 2,
|
||||
}));
|
||||
motes = Array.from({ length: Math.min(140, Math.round(area / 9000)) }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vy: rand(-0.22, -0.06),
|
||||
sway: rand(6, 20),
|
||||
ph: Math.random() * Math.PI * 2,
|
||||
a: rand(0.06, 0.28),
|
||||
}));
|
||||
signals = [];
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const rect = parent!.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
w = rect.width;
|
||||
h = rect.height;
|
||||
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);
|
||||
build();
|
||||
if (reduce) draw(0, 0);
|
||||
}
|
||||
|
||||
function spawnSignal() {
|
||||
if (nodes.length < 2 || signals.length >= 3) return;
|
||||
const a = Math.floor(Math.random() * nodes.length);
|
||||
// Prefer a neighbour that is actually linked, so the packet rides a
|
||||
// visible edge instead of crossing empty space.
|
||||
let b = -1;
|
||||
let best = Infinity;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (i === a) continue;
|
||||
const d = Math.hypot(nodes[i].x - nodes[a].x, nodes[i].y - nodes[a].y);
|
||||
if (d < LINK && d < best && Math.random() > 0.45) {
|
||||
best = d;
|
||||
b = i;
|
||||
}
|
||||
}
|
||||
if (b < 0) return;
|
||||
signals.push({ a, b, t: 0, sp: rand(0.5, 0.95) });
|
||||
}
|
||||
|
||||
function draw(dt: number, time: number) {
|
||||
ctx!.clearRect(0, 0, w, h);
|
||||
|
||||
/* ---- 1. particle field ---- */
|
||||
for (const m of motes) {
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(m.x + Math.sin(time * 0.0004 + m.ph) * m.sway, m.y, 0.9, 0, Math.PI * 2);
|
||||
ctx!.fillStyle = "rgba(" + ir + "," + ig + "," + ib + "," + m.a * (light ? 0.4 : 0.55) + ")";
|
||||
ctx!.fill();
|
||||
}
|
||||
|
||||
/* ---- 2. neural mesh ---- */
|
||||
ctx!.lineWidth = 1;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const a = nodes[i];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const b = nodes[j];
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 > LINK * LINK) continue;
|
||||
const d = Math.sqrt(d2);
|
||||
const prox = 1 - d / LINK;
|
||||
// Near the pointer the mesh warms towards the accent and brightens.
|
||||
const mx = (a.x + b.x) * 0.5;
|
||||
const my = (a.y + b.y) * 0.5;
|
||||
const cur = Math.max(0, 1 - Math.hypot(mx - pointer.x, my - pointer.y) / CURSOR);
|
||||
const tint = Math.min(1, prox * 0.85 + cur * 0.6);
|
||||
const alpha = (prox * 0.3 + cur * 0.35) * (light ? 0.6 : 1);
|
||||
const r = Math.round(ir + (ar - ir) * tint);
|
||||
const g = Math.round(ig + (ag - ig) * tint);
|
||||
const bl = Math.round(ib + (ab - ib) * tint);
|
||||
ctx!.strokeStyle = "rgba(" + r + "," + g + "," + bl + "," + alpha.toFixed(3) + ")";
|
||||
ctx!.beginPath();
|
||||
ctx!.moveTo(a.x, a.y);
|
||||
ctx!.lineTo(b.x, b.y);
|
||||
ctx!.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
const cur = Math.max(0, 1 - Math.hypot(n.x - pointer.x, n.y - pointer.y) / CURSOR);
|
||||
if (n.hub) {
|
||||
const pulse = 0.5 + 0.5 * Math.sin(time * 0.0016 + n.ph);
|
||||
ctx!.save();
|
||||
// An additive bloom only reads on a dark surface; on paper it muddies.
|
||||
if (!light) {
|
||||
ctx!.shadowColor = "rgba(" + ar + "," + ag + "," + ab + ",0.9)";
|
||||
ctx!.shadowBlur = 10 + pulse * 12;
|
||||
}
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(n.x, n.y, n.r + 0.7 + pulse * 0.6, 0, Math.PI * 2);
|
||||
ctx!.fillStyle = "rgba(" + ar + "," + ag + "," + ab + "," + (0.5 + pulse * 0.35) * (light ? 0.7 : 1) + ")";
|
||||
ctx!.fill();
|
||||
ctx!.restore();
|
||||
} else {
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(n.x, n.y, n.r, 0, Math.PI * 2);
|
||||
ctx!.fillStyle = "rgba(" + ir + "," + ig + "," + ib + "," + (0.16 + cur * 0.4) * (light ? 0.55 : 1) + ")";
|
||||
ctx!.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 3. travelling signals ---- */
|
||||
for (const s of signals) {
|
||||
const a = nodes[s.a];
|
||||
const b = nodes[s.b];
|
||||
if (!a || !b) continue;
|
||||
const x = a.x + (b.x - a.x) * s.t;
|
||||
const y = a.y + (b.y - a.y) * s.t;
|
||||
// Fade in and out so packets never pop at the endpoints.
|
||||
const fade = Math.sin(s.t * Math.PI);
|
||||
ctx!.save();
|
||||
if (!light) {
|
||||
ctx!.shadowColor = "rgba(" + ar + "," + ag + "," + ab + ",1)";
|
||||
ctx!.shadowBlur = 14;
|
||||
}
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(x, y, 1.9, 0, Math.PI * 2);
|
||||
// On paper the packet is the accent itself; on dark it burns towards white.
|
||||
ctx!.fillStyle = light
|
||||
? "rgba(" + ar + "," + ag + "," + ab + "," + fade + ")"
|
||||
: "rgba(255," + Math.round(ag * 0.7 + 90) + "," + Math.round(ab * 0.7 + 90) + "," + fade + ")";
|
||||
ctx!.fill();
|
||||
ctx!.restore();
|
||||
}
|
||||
|
||||
if (reduce) return;
|
||||
|
||||
/* ---- integrate ---- */
|
||||
const step = Math.min(3, dt / 16.67);
|
||||
for (const n of nodes) {
|
||||
n.x += n.vx * step;
|
||||
n.y += n.vy * step;
|
||||
if (n.x < -20) n.x = w + 20;
|
||||
if (n.x > w + 20) n.x = -20;
|
||||
if (n.y < -20) n.y = h + 20;
|
||||
if (n.y > h + 20) n.y = -20;
|
||||
}
|
||||
for (const m of motes) {
|
||||
m.y += m.vy * step;
|
||||
if (m.y < -10) {
|
||||
m.y = h + 10;
|
||||
m.x = Math.random() * w;
|
||||
}
|
||||
}
|
||||
for (let i = signals.length - 1; i >= 0; i--) {
|
||||
signals[i].t += (signals[i].sp * step) / 60;
|
||||
if (signals[i].t >= 1) signals.splice(i, 1);
|
||||
}
|
||||
nextSignal -= dt;
|
||||
if (nextSignal <= 0) {
|
||||
spawnSignal();
|
||||
nextSignal = rand(700, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
function frame(time: number) {
|
||||
const dt = last ? time - last : 16.67;
|
||||
last = time;
|
||||
draw(dt, time);
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (running || reduce) return;
|
||||
running = true;
|
||||
last = 0;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
function stop() {
|
||||
running = false;
|
||||
cancelAnimationFrame(raf);
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(parent);
|
||||
resize();
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
visible = entries.some((e) => e.isIntersecting);
|
||||
if (visible && !document.hidden) start();
|
||||
else stop();
|
||||
},
|
||||
{ rootMargin: "120px 0px" },
|
||||
);
|
||||
io.observe(canvas);
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) stop();
|
||||
else if (visible) start();
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pointer.x = e.clientX - rect.left;
|
||||
pointer.y = e.clientY - rect.top;
|
||||
};
|
||||
const onLeave = () => {
|
||||
pointer.x = -9999;
|
||||
pointer.y = -9999;
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
// Listener on the section (not the canvas — it is pointer-events: none).
|
||||
const host = parent.closest("section") ?? parent;
|
||||
host.addEventListener("pointermove", onMove as EventListener);
|
||||
host.addEventListener("pointerleave", onLeave);
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
ro.disconnect();
|
||||
io.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
host.removeEventListener("pointermove", onMove as EventListener);
|
||||
host.removeEventListener("pointerleave", onLeave);
|
||||
};
|
||||
}, [accent, tone]);
|
||||
|
||||
return <canvas ref={canvasRef} className={className} aria-hidden="true" />;
|
||||
}
|
||||
1121
src/components/sections/PlatformCapabilities.tsx
Normal file
525
src/components/sections/QuantumStage.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import WorkflowScene from "./WorkflowScene";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
}
|
||||
|
||||
function Svg({ children, size = 18 }: { children: React.ReactNode; size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const IcLegacy = <Svg><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 3" /></Svg>;
|
||||
const IcCpu = <Svg><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3" /></Svg>;
|
||||
const IcBrain = <Svg><path d="M12 2a9 9 0 019 9c0 5-4 9-9 9s-9-4-9-9a9 9 0 019-9z" /><path d="M12 8v8M8 12h8" /></Svg>;
|
||||
const IcTarget = <Svg><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" /></Svg>;
|
||||
const IcDispatch = <Svg><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" /></Svg>;
|
||||
|
||||
export default function QuantumStage() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [simCount, setSimCount] = useState(148);
|
||||
|
||||
// Live dynamic step simulation loop
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setActiveStep((prev) => {
|
||||
const next = (prev + 1) % 4;
|
||||
if (next === 0) {
|
||||
setSimCount(Math.floor(140 + Math.random() * 90));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 2200);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const frame = root.querySelector(".qs-frame");
|
||||
const tradCol = root.querySelector(".qs-col--trad");
|
||||
const qSteps = Array.from(root.querySelectorAll(".qs-qstep"));
|
||||
const qDot = root.querySelector(".qs-qdot");
|
||||
if (!frame || !tradCol || qSteps.length === 0) return;
|
||||
|
||||
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
||||
gsap.set([frame, tradCol, ...qSteps], { opacity: 1, y: 0, scale: 1 });
|
||||
return;
|
||||
}
|
||||
gsap.set(frame, { opacity: 0, y: 26 });
|
||||
gsap.set(tradCol, { opacity: 0, y: 26 });
|
||||
gsap.set(qSteps, { opacity: 0.2, y: 14, scale: 0.96 });
|
||||
if (qDot) gsap.set(qDot, { scale: 0.5, opacity: 0 });
|
||||
|
||||
const play = () => {
|
||||
const tl = gsap.timeline();
|
||||
tl.to(frame, { opacity: 1, y: 0, duration: 0.8, ease: "power3.out" }, 0)
|
||||
.to(tradCol, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, 0.15);
|
||||
|
||||
qSteps.forEach((el, i) => {
|
||||
tl.to(el, { opacity: 1, y: 0, scale: 1, duration: 0.4, ease: "back.out(1.4)" }, 0.6 + i * 0.22);
|
||||
});
|
||||
|
||||
if (qDot) {
|
||||
tl.to(qDot, { opacity: 1, scale: 1, duration: 0.4, ease: "back.out(2)" }, 0.6 + qSteps.length * 0.22);
|
||||
}
|
||||
};
|
||||
|
||||
const st = ScrollTrigger.create({ trigger: root, start: "top 78%", once: true, onEnter: play });
|
||||
return () => st.kill();
|
||||
}, []);
|
||||
|
||||
const STEP_STATUSES = [
|
||||
`⚡ RUNNING ${simCount} PARALLEL PERMUTATIONS`,
|
||||
`🧠 SCORING SLA, TRAFFIC & FUEL COSTS`,
|
||||
`🎯 OPTIMAL ROUTE MATCH LOCKED (99.8% EFFICIENCY)`,
|
||||
`🚀 INSTANT AUTO-DISPATCH EXECUTED (0.02s)`,
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="qs">
|
||||
<style dangerouslySetInnerHTML={{ __html: CSS }} />
|
||||
<div className="qs-frame">
|
||||
<WorkflowScene variant="logistics" ariaLabel="Quantum decision engine — parallel route simulation" />
|
||||
</div>
|
||||
|
||||
<div className="qs-compare">
|
||||
{/* Traditional baseline */}
|
||||
<div className="qs-col qs-col--trad">
|
||||
<div className="qs-col-head">
|
||||
<span className="qs-col-tag">
|
||||
<span className="qs-tag-dot qs-tag-dot--muted" />
|
||||
Traditional Approach
|
||||
</span>
|
||||
<span className="qs-col-subtag">Legacy Sequential</span>
|
||||
</div>
|
||||
|
||||
<div className="qs-col-steps qs-col-steps--trad">
|
||||
<div className="qs-step-pill qs-step-pill--trad">
|
||||
<span className="qs-step-ic">{IcLegacy}</span>
|
||||
<div className="qs-step-txt">
|
||||
<b>Single Route Calculation</b>
|
||||
<span>Linear, non-simulated path</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="qs-col-arrow-v">↓</span>
|
||||
<div className="qs-step-pill qs-step-pill--trad">
|
||||
<span className="qs-step-ic">{IcDispatch}</span>
|
||||
<div className="qs-step-txt">
|
||||
<b>Manual Dispatch</b>
|
||||
<span>Static SLA execution</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="qs-col-foot">
|
||||
<span className="qs-metric-chip">1 Route Evaluated</span>
|
||||
<span className="qs-metric-chip qs-metric-chip--warn">High Exception Risk</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantum Engine */}
|
||||
<div className="qs-col qs-col--q">
|
||||
<div className="qs-col-head">
|
||||
<span className="qs-col-tag qs-col-tag--accent">
|
||||
<span className="qs-tag-dot qs-tag-dot--active" />
|
||||
Quantum Engine
|
||||
</span>
|
||||
<span className="qs-col-sys">{STEP_STATUSES[activeStep]}</span>
|
||||
</div>
|
||||
|
||||
<div className="qs-col-steps qs-col-steps--accent">
|
||||
<div
|
||||
className={`qs-qstep qs-step-pill qs-step-pill--q ${
|
||||
activeStep === 0 ? "qs-step-pill--active" : ""
|
||||
}`}
|
||||
onClick={() => setActiveStep(0)}
|
||||
>
|
||||
<div className="qs-step-head">
|
||||
<span className="qs-step-ic qs-step-ic--accent">{IcCpu}</span>
|
||||
</div>
|
||||
<div className="qs-step-txt">
|
||||
<b>{simCount}+ Parallel Simulations</b>
|
||||
<span>Instant route permutations</span>
|
||||
</div>
|
||||
{activeStep === 0 && <div className="qs-step-bar" />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`qs-qstep qs-step-pill qs-step-pill--q ${
|
||||
activeStep === 1 ? "qs-step-pill--active" : ""
|
||||
}`}
|
||||
onClick={() => setActiveStep(1)}
|
||||
>
|
||||
<div className="qs-step-head">
|
||||
<span className="qs-step-ic qs-step-ic--accent">{IcBrain}</span>
|
||||
</div>
|
||||
<div className="qs-step-txt">
|
||||
<b>AI Outcome Scoring</b>
|
||||
<span>Evaluates SLA, traffic & cost</span>
|
||||
</div>
|
||||
{activeStep === 1 && <div className="qs-step-bar" />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`qs-qstep qs-step-pill qs-step-pill--q ${
|
||||
activeStep === 2 ? "qs-step-pill--active" : ""
|
||||
}`}
|
||||
onClick={() => setActiveStep(2)}
|
||||
>
|
||||
<div className="qs-step-head">
|
||||
<span className="qs-step-ic qs-step-ic--accent">{IcTarget}</span>
|
||||
</div>
|
||||
<div className="qs-step-txt">
|
||||
<b>Optimal Route Match</b>
|
||||
<span>Highest efficiency path</span>
|
||||
</div>
|
||||
{activeStep === 2 && <div className="qs-step-bar" />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`qs-qstep qs-step-pill qs-step-pill--q qs-col-final ${
|
||||
activeStep === 3 ? "qs-step-pill--active" : ""
|
||||
}`}
|
||||
onClick={() => setActiveStep(3)}
|
||||
>
|
||||
<div className="qs-step-head">
|
||||
<span className="qs-step-ic qs-step-ic--live">{IcDispatch}</span>
|
||||
<span className="qs-qdot" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="qs-step-txt">
|
||||
<b>Instant Auto-Dispatch</b>
|
||||
<span>Optimized in real time</span>
|
||||
</div>
|
||||
{activeStep === 3 && <div className="qs-step-bar" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="qs-col-foot">
|
||||
<span className="qs-metric-chip qs-metric-chip--good">✓ {simCount}+ Scenarios Checked</span>
|
||||
<span className="qs-metric-chip qs-metric-chip--good">✓ Zero SLA Risk</span>
|
||||
<span className="qs-metric-chip qs-metric-chip--good">✓ Real-Time Sync</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
.qs { position: relative; }
|
||||
|
||||
#sw-root #quantum h2,
|
||||
#sw-root #quantum .sw-h,
|
||||
#quantum h2,
|
||||
#quantum .sw-h { color: #111827 !important; }
|
||||
|
||||
#sw-root #quantum h2 .accent,
|
||||
#sw-root #quantum .sw-h .accent,
|
||||
#quantum h2 .accent,
|
||||
#quantum .sw-h .accent { color: #dc2626 !important; }
|
||||
|
||||
#sw-root #quantum p,
|
||||
#sw-root #quantum .sw-lede,
|
||||
#quantum p,
|
||||
#quantum .sw-lede { color: #4b5563 !important; }
|
||||
|
||||
.qs-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: auto;
|
||||
min-height: min(84vh, 900px);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
background: #0d0d11;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
box-shadow: 0 24px 60px -20px rgba(0,0,0,0.15);
|
||||
}
|
||||
.qs-frame .wf-scene {
|
||||
position: absolute;
|
||||
inset: clamp(16px, 3vw, 32px);
|
||||
width: auto;
|
||||
height: auto;
|
||||
aspect-ratio: auto;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* ---- Redesigned Comparison Grid (White & Red Theme with Tight Bottom Padding) ---- */
|
||||
.qs-compare {
|
||||
position: relative;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 0px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2.2fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Card Base */
|
||||
.qs-col {
|
||||
padding: 20px 22px;
|
||||
border-radius: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Traditional Card - Muted Off-White */
|
||||
.qs-col--trad {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
/* Quantum Card - White & Soft Crimson Gradient */
|
||||
.qs-col--q {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fff6f6 100%);
|
||||
border: 1.5px solid rgba(220,38,38,0.22);
|
||||
box-shadow: 0 14px 36px -8px rgba(220,38,38,0.08), 0 2px 6px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
/* Header strip */
|
||||
.qs-col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
.qs-col-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #4b5563;
|
||||
}
|
||||
.qs-col-tag--accent {
|
||||
color: #dc2626;
|
||||
}
|
||||
.qs-tag-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.qs-tag-dot--muted {
|
||||
background: #9ca3af;
|
||||
}
|
||||
.qs-tag-dot--active {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 10px rgba(220,38,38,0.6);
|
||||
animation: qsPulse 2s infinite ease-in-out;
|
||||
}
|
||||
@keyframes qsPulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
.qs-col-subtag, .qs-col-sys {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.qs-col-sys {
|
||||
color: #dc2626;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Steps Pipeline Layout */
|
||||
.qs-col-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.qs-col-steps--trad {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.qs-col-steps--accent {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Step Pills */
|
||||
.qs-step-pill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
|
||||
}
|
||||
.qs-step-pill--trad {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
}
|
||||
.qs-step-pill--q {
|
||||
background: #ffffff;
|
||||
border: 1.5px solid #f3f4f6;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.qs-step-pill--q:hover {
|
||||
border-color: rgba(220,38,38,0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(220,38,38,0.08);
|
||||
}
|
||||
.qs-step-pill--active {
|
||||
border-color: #dc2626 !important;
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 8px 24px rgba(220,38,38,0.18) !important;
|
||||
transform: translateY(-3px) !important;
|
||||
}
|
||||
.qs-col-final {
|
||||
background: rgba(220,38,38,0.04) !important;
|
||||
border-color: rgba(220,38,38,0.3) !important;
|
||||
}
|
||||
|
||||
/* Animated active step progress bar */
|
||||
.qs-step-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: #dc2626;
|
||||
width: 100%;
|
||||
animation: qsBar 2.4s linear infinite;
|
||||
}
|
||||
@keyframes qsBar {
|
||||
0% { width: 0%; }
|
||||
100% { width: 100%; }
|
||||
}
|
||||
|
||||
.qs-step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
.qs-step-num {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.qs-step-ic {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qs-step-ic--accent {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
.qs-step-ic--live {
|
||||
background: #dc2626;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(220,38,38,0.4);
|
||||
}
|
||||
|
||||
.qs-step-txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
}
|
||||
.qs-step-txt b {
|
||||
color: #111827;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.qs-step-txt span {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.qs-col-arrow-v {
|
||||
color: #d1d5db;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Radar pulse dot on final step */
|
||||
.qs-qdot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 10px rgba(220,38,38,0.8);
|
||||
}
|
||||
|
||||
/* Footer chips */
|
||||
.qs-col-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.qs-metric-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
.qs-metric-chip--warn {
|
||||
background: #fffbe6;
|
||||
border-color: #fef08a;
|
||||
color: #b45309;
|
||||
}
|
||||
.qs-metric-chip--good {
|
||||
background: #ecfdf5;
|
||||
border-color: #a7f3d0;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.qs-compare {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.qs-col-steps--accent {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.qs-col-steps--accent {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.qs-frame { min-height: 52vh; border-radius: 20px; }
|
||||
}
|
||||
`;
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ContentBlock,
|
||||
formatDate,
|
||||
estimateReadingTime,
|
||||
} from "@/data/blog";
|
||||
} from "@/shared/data/blog";
|
||||
|
||||
function ContentRenderer({ block }: { block: ContentBlock }) {
|
||||
switch (block.type) {
|
||||
|
||||
@@ -3,7 +3,29 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { preload } from "react-dom";
|
||||
|
||||
export default function SolutionsHero() {
|
||||
export interface HeroSlide {
|
||||
heading: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Original industry-focused copy from the PHP Solutions page
|
||||
* (doormile-site/includes/sections/hero/solutions-hero-section.php).
|
||||
* Kept as the default so the Industries page renders the original hero verbatim;
|
||||
* the Solutions page passes its own offerings copy.
|
||||
*/
|
||||
const DEFAULT_SLIDES: HeroSlide[] = [
|
||||
{
|
||||
heading: "Solutions for Every Industry",
|
||||
text: "Discover how Doormile's connected logistics platform serves diverse industries with tailored solutions.",
|
||||
},
|
||||
{
|
||||
heading: "One Platform. All Verticals",
|
||||
text: "Doormile adapts to the unique challenges of your vertical—not the other way around.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SolutionsHero({ slides = DEFAULT_SLIDES }: { slides?: HeroSlide[] } = {}) {
|
||||
preload("/images/home1-slide-1.webp", { as: "image" });
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
|
||||
@@ -233,11 +255,11 @@ export default function SolutionsHero() {
|
||||
<div className="slide-content">
|
||||
<div className="slide-content-inner">
|
||||
<h1 className="content-slider-item-heading logico-content-wrapper-1">
|
||||
<span className="heading-content">Solutions for Every Industry</span>
|
||||
<span className="heading-content">{slides[0].heading}</span>
|
||||
</h1>
|
||||
<div className="content-slider-item-text logico-content-wrapper-2">
|
||||
<div className="text-content">
|
||||
<p>Discover how Doormile's connected logistics platform serves diverse industries with tailored solutions.</p>
|
||||
<p>{slides[0].text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,11 +285,11 @@ export default function SolutionsHero() {
|
||||
<div className="slide-content">
|
||||
<div className="slide-content-inner">
|
||||
<h1 className="content-slider-item-heading logico-content-wrapper-1">
|
||||
<span className="heading-content">One Platform. All Verticals</span>
|
||||
<span className="heading-content">{slides[1].heading}</span>
|
||||
</h1>
|
||||
<div className="content-slider-item-text logico-content-wrapper-2">
|
||||
<div className="text-content">
|
||||
<p>Doormile adapts to the unique challenges of your vertical—not the other way around.</p>
|
||||
<p>{slides[1].text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
917
src/components/sections/SolutionsMileTruthAnimation.tsx
Normal file
@@ -0,0 +1,917 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface SolutionsMileTruthAnimationProps {
|
||||
variant?: "compact" | "full";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Palette — ink + Doormile red, and nothing else.
|
||||
|
||||
Red carries everything the AI is responsible for (the core, the
|
||||
optimised route, the confirmed outcomes). Ink at low opacity carries
|
||||
structure and anything the AI rejected. There is deliberately no third
|
||||
hue: an enterprise console reads as premium because it is restrained,
|
||||
not because it is colourful.
|
||||
------------------------------------------------------------------ */
|
||||
const INK = "#0E0E12";
|
||||
const RED = "#E2354A";
|
||||
const RED_DEEP = "#C01227";
|
||||
|
||||
/* ==================================================================
|
||||
LAYER GEOMETRY — one 680×400 board, three columns.
|
||||
|
||||
22 ┄┄ 158 inputs (live signals arriving)
|
||||
206 ┄┄ 430 core (the engine deciding)
|
||||
452 ┄┄ 658 outcome (what the decision protected)
|
||||
|
||||
The card frame, the viewBox and the wrapper's aspect-ratio are fixed;
|
||||
only the interior is composed here.
|
||||
================================================================== */
|
||||
|
||||
/** Layer 1. Six live feeds — enough to read as "many inputs" without the
|
||||
* column turning into a list nobody scans. */
|
||||
const INPUTS: { label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
label: "Fleet GPS",
|
||||
icon: (
|
||||
<>
|
||||
<path d="M8 13.2C8 13.2 12.6 9.3 12.6 6.5A4.6 4.6 0 1 0 3.4 6.5C3.4 9.3 8 13.2 8 13.2Z" />
|
||||
<circle cx="8" cy="6.4" r="1.6" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Traffic flow",
|
||||
icon: (
|
||||
<>
|
||||
<rect x="4.6" y="2.4" width="6.8" height="11.2" rx="2.4" />
|
||||
<circle cx="8" cy="5.4" r="0.9" />
|
||||
<circle cx="8" cy="8" r="0.9" />
|
||||
<circle cx="8" cy="10.6" r="0.9" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Weather",
|
||||
icon: (
|
||||
<path d="M4.8 11.6A2.6 2.6 0 0 1 5.4 6.5 3.6 3.6 0 0 1 12 7.1 2.4 2.4 0 0 1 11.7 11.6Z" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Order load",
|
||||
icon: (
|
||||
<>
|
||||
<path d="M3 5.4 8 2.8 13 5.4V11L8 13.6 3 11Z" />
|
||||
<path d="M3 5.4 8 8m0 0 5-2.6M8 8v5.6" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Driver status",
|
||||
icon: (
|
||||
<>
|
||||
<circle cx="8" cy="5.6" r="2.2" />
|
||||
<path d="M3.9 13.2a4.1 4.1 0 0 1 8.2 0" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Vehicle health",
|
||||
icon: <path d="M2.8 8h2.6L7 4.8 9.2 11.6 10.7 8h2.5" />,
|
||||
},
|
||||
];
|
||||
|
||||
/** Layer 2. A four-stage network — the shape of a decision, not decoration. */
|
||||
const CORE = { cx: 318, cy: 200 };
|
||||
const NET: number[][] = [
|
||||
[152, 200, 248], // inputs
|
||||
[132, 178, 222, 268], // hidden A
|
||||
[132, 178, 222, 268], // hidden B
|
||||
[168, 232], // decisions
|
||||
];
|
||||
const NET_X = [236, 288, 346, 400];
|
||||
|
||||
/** The one path lit red — the decision actually taken this cycle. Each entry
|
||||
* is [layer, fromIndex, toIndex]. */
|
||||
const LIVE_PATH: [number, number, number][] = [
|
||||
[0, 1, 1],
|
||||
[0, 0, 0],
|
||||
[1, 1, 2],
|
||||
[1, 0, 1],
|
||||
[2, 2, 0],
|
||||
[2, 1, 0],
|
||||
];
|
||||
const liveKey = (l: number, a: number, b: number) => `${l}-${a}-${b}`;
|
||||
const LIVE_SET = new Set(LIVE_PATH.map(([l, a, b]) => liveKey(l, a, b)));
|
||||
|
||||
/** Nodes that fire — one per layer, following LIVE_PATH. */
|
||||
const ACTIVE_NODES = new Set(["0-1", "1-1", "2-2", "3-0"]);
|
||||
|
||||
/** Layer 3. What the decision protected, in the order ops would confirm it. */
|
||||
const OUTCOMES = ["Delay avoided", "SLA protected", "Driver updated", "Customer notified"];
|
||||
|
||||
/** The header's cycling state — the engine narrating itself. */
|
||||
const PHASES = ["Analysing", "Correlating", "Deciding", "Optimised"] as const;
|
||||
|
||||
/* The CSS below stills every animation under prefers-reduced-motion, but the
|
||||
route pod is SMIL, which CSS cannot stop — so the preference is read here
|
||||
too and the pod is simply not rendered. Subscribed rather than set from an
|
||||
effect so it stays correct if the user flips the OS setting mid-session. */
|
||||
const REDUCE_QUERY = "(prefers-reduced-motion: reduce)";
|
||||
const subscribeReduce = (cb: () => void) => {
|
||||
const mq = window.matchMedia(REDUCE_QUERY);
|
||||
mq.addEventListener("change", cb);
|
||||
return () => mq.removeEventListener("change", cb);
|
||||
};
|
||||
const useReducedMotion = () =>
|
||||
React.useSyncExternalStore(
|
||||
subscribeReduce,
|
||||
() => window.matchMedia(REDUCE_QUERY).matches,
|
||||
() => false
|
||||
);
|
||||
|
||||
export default function SolutionsMileTruthAnimation({
|
||||
variant = "full",
|
||||
className = "",
|
||||
}: SolutionsMileTruthAnimationProps) {
|
||||
const isCompact = variant === "compact";
|
||||
|
||||
const [phase, setPhase] = useState(0);
|
||||
const [signals, setSignals] = useState(247);
|
||||
const still = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompact || still) return;
|
||||
const t = setInterval(() => setPhase((p) => (p + 1) % PHASES.length), 2400);
|
||||
return () => clearInterval(t);
|
||||
}, [isCompact, still]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompact || still) return;
|
||||
// A live counter that drifts rather than spins — telemetry, not a slot machine.
|
||||
const t = setInterval(() => setSignals(244 + Math.floor(Math.random() * 8)), 1600);
|
||||
return () => clearInterval(t);
|
||||
}, [isCompact, still]);
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<div
|
||||
className={`sol-s05-svg-core ${className}`}
|
||||
style={{ width: "100%", height: "100%", position: "relative" }}
|
||||
>
|
||||
<style jsx>{`
|
||||
.sol-compact-route-dash {
|
||||
stroke-dasharray: 4 6;
|
||||
animation: solCDashFlow 2s linear infinite;
|
||||
}
|
||||
@keyframes solCDashFlow {
|
||||
to {
|
||||
stroke-dashoffset: -20;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sol-compact-route-dash {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
<path
|
||||
d="M 10 30 H 90 M 10 70 H 90 M 30 10 V 90 M 70 10 V 90"
|
||||
stroke="rgba(14,14,16,0.1)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M 30 70 C 45 30, 55 30, 70 30"
|
||||
stroke={RED}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
className="sol-compact-route-dash"
|
||||
/>
|
||||
<circle cx="30" cy="70" r="4" fill={INK} />
|
||||
<circle cx="70" cy="30" r="4" fill={RED} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sol-mt-console ${className}`}
|
||||
style={{ position: "relative", width: "100%", height: "100%" }}
|
||||
>
|
||||
<style jsx>{`
|
||||
.sol-mtc-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ---- Signal flow. Long gaps, slow cycle: data arriving, not a
|
||||
barber's pole. ---- */
|
||||
.sol-mtc-feed {
|
||||
stroke-dasharray: 13 42;
|
||||
animation: solMtcFeed 2.6s linear infinite;
|
||||
}
|
||||
@keyframes solMtcFeed {
|
||||
to {
|
||||
stroke-dashoffset: -55;
|
||||
}
|
||||
}
|
||||
|
||||
.sol-mtc-synapse {
|
||||
stroke-dasharray: 9 34;
|
||||
animation: solMtcFeed 1.5s linear infinite;
|
||||
}
|
||||
|
||||
.sol-mtc-route-flow {
|
||||
stroke-dasharray: 10 20;
|
||||
animation: solMtcRouteFlow 1.6s linear infinite;
|
||||
}
|
||||
@keyframes solMtcRouteFlow {
|
||||
to {
|
||||
stroke-dashoffset: -30;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- The core: two counter-rotating rings and a breathing glow.
|
||||
This is the only thing on the board allowed to move
|
||||
continuously, because it is the thing that never stops. ---- */
|
||||
.sol-mtc-ring {
|
||||
transform-origin: 318px 200px;
|
||||
animation: solMtcSpin 34s linear infinite;
|
||||
}
|
||||
.sol-mtc-ring--b {
|
||||
animation-duration: 22s;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
@keyframes solMtcSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sol-mtc-glow {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: solMtcGlow 6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes solMtcGlow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Firing node: a halo that expands and clears. ---- */
|
||||
.sol-mtc-fire {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: solMtcFire 2.4s ease-out infinite;
|
||||
}
|
||||
@keyframes solMtcFire {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(2.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Live dots on the input chips. ---- */
|
||||
.sol-mtc-live {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: solMtcLive 2.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes solMtcLive {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Outcome ticks confirming in sequence. ---- */
|
||||
.sol-mtc-tick {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: solMtcTick 4.8s ease-out infinite;
|
||||
}
|
||||
@keyframes solMtcTick {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
35%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.9);
|
||||
}
|
||||
}
|
||||
|
||||
.sol-mtc-phase {
|
||||
transition: fill 0.25s ease, stroke 0.25s ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sol-mtc-feed,
|
||||
.sol-mtc-synapse,
|
||||
.sol-mtc-route-flow,
|
||||
.sol-mtc-ring,
|
||||
.sol-mtc-glow,
|
||||
.sol-mtc-fire,
|
||||
.sol-mtc-live,
|
||||
.sol-mtc-tick {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 680 400"
|
||||
className="sol-mtc-svg"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="MileTruth AI decision engine: live telemetry feeds — fleet GPS, traffic, weather, order load, driver status and vehicle health — flow into a neural decision core, which returns an optimised route that avoids delay and holds the 99.2% SLA at a 41-minute ETA."
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="solMtcClip">
|
||||
<rect x="8" y="8" width="664" height="384" rx="26" />
|
||||
</clipPath>
|
||||
|
||||
<pattern id="solMtcDots" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||
<circle cx="14" cy="14" r="0.85" fill="rgba(14,14,16,0.055)" />
|
||||
</pattern>
|
||||
|
||||
{/* Glass: the card is white, so depth comes from a barely-there
|
||||
vertical wash rather than blur — anything stronger reads as dirt. */}
|
||||
<linearGradient id="solMtcGlass" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#FFFFFF" stopOpacity="0.9" />
|
||||
<stop offset="60%" stopColor="#FFFFFF" stopOpacity="0" />
|
||||
<stop offset="100%" stopColor={INK} stopOpacity="0.022" />
|
||||
</linearGradient>
|
||||
|
||||
<radialGradient id="solMtcCoreGlow">
|
||||
<stop offset="0%" stopColor={RED} stopOpacity="0.16" />
|
||||
<stop offset="45%" stopColor={RED} stopOpacity="0.06" />
|
||||
<stop offset="100%" stopColor={RED} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="solMtcRouteGrad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor={RED_DEEP} />
|
||||
<stop offset="100%" stopColor={RED} />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="solMtcCardShadow" x="-12%" y="-14%" width="124%" height="132%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="16" floodColor={INK} floodOpacity="0.07" />
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="2" floodColor={INK} floodOpacity="0.05" />
|
||||
</filter>
|
||||
|
||||
<filter id="solMtcChipShadow" x="-25%" y="-55%" width="150%" height="210%">
|
||||
<feDropShadow dx="0" dy="1.5" stdDeviation="2.5" floodColor={INK} floodOpacity="0.07" />
|
||||
</filter>
|
||||
|
||||
<filter id="solMtcNodeGlow" x="-160%" y="-160%" width="420%" height="420%">
|
||||
<feGaussianBlur stdDeviation="2.4" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* ============ CARD ============ */}
|
||||
<rect
|
||||
x="8"
|
||||
y="8"
|
||||
width="664"
|
||||
height="384"
|
||||
rx="26"
|
||||
fill="#FFFFFF"
|
||||
stroke="rgba(14,14,16,0.09)"
|
||||
strokeWidth="1"
|
||||
filter="url(#solMtcCardShadow)"
|
||||
/>
|
||||
|
||||
<g clipPath="url(#solMtcClip)">
|
||||
<rect x="8" y="60" width="664" height="266" fill="url(#solMtcDots)" />
|
||||
<rect x="8" y="8" width="664" height="384" fill="url(#solMtcGlass)" />
|
||||
|
||||
{/* ============ HEADER ============ */}
|
||||
<circle cx="34" cy="34" r="4" fill={RED} />
|
||||
<circle cx="34" cy="34" r="4" fill={RED} className="sol-mtc-fire" />
|
||||
<text
|
||||
x="47"
|
||||
y="38"
|
||||
fill="rgba(14,14,16,0.6)"
|
||||
fontSize="11"
|
||||
fontWeight="800"
|
||||
letterSpacing="1.35"
|
||||
fontFamily="Manrope, sans-serif"
|
||||
>
|
||||
MILETRUTH™ AI DECISION ENGINE
|
||||
</text>
|
||||
|
||||
<g filter="url(#solMtcChipShadow)">
|
||||
<rect
|
||||
x="438"
|
||||
y="20"
|
||||
width="214"
|
||||
height="28"
|
||||
rx="14"
|
||||
fill="#FFFFFF"
|
||||
stroke="rgba(226,53,74,0.35)"
|
||||
strokeWidth="1.1"
|
||||
/>
|
||||
</g>
|
||||
<circle cx="456" cy="34" r="4" fill={RED} />
|
||||
<circle cx="456" cy="34" r="4" fill={RED} className="sol-mtc-live" />
|
||||
<text
|
||||
x="470"
|
||||
y="38"
|
||||
fill={INK}
|
||||
fontSize="10.5"
|
||||
fontWeight="800"
|
||||
fontFamily="Manrope, sans-serif"
|
||||
>
|
||||
{PHASES[phase]}
|
||||
<tspan fill="rgba(14,14,16,0.4)">{" · "}</tspan>
|
||||
{signals} live signals
|
||||
</text>
|
||||
|
||||
<line x1="8" y1="60" x2="672" y2="60" stroke="rgba(14,14,16,0.07)" strokeWidth="1" />
|
||||
|
||||
{/* ============ COLUMN LABELS ============ */}
|
||||
{[
|
||||
{ x: 91, t: "LIVE INPUTS" },
|
||||
{ x: 318, t: "NEURAL CORE" },
|
||||
{ x: 555, t: "PROTECTED OUTCOME" },
|
||||
].map((c) => (
|
||||
<text
|
||||
key={c.t}
|
||||
x={c.x}
|
||||
y="84"
|
||||
textAnchor="middle"
|
||||
fill="rgba(14,14,16,0.38)"
|
||||
fontSize="9.5"
|
||||
fontWeight="800"
|
||||
letterSpacing="1.25"
|
||||
fontFamily="Manrope, sans-serif"
|
||||
>
|
||||
{c.t}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* ============================================================
|
||||
LAYER 1 — LIVE INPUTS
|
||||
============================================================ */}
|
||||
{INPUTS.map((inp, i) => {
|
||||
const top = 96 + i * 37;
|
||||
return (
|
||||
<g key={inp.label}>
|
||||
<g filter="url(#solMtcChipShadow)">
|
||||
<rect
|
||||
x="24"
|
||||
y={top}
|
||||
width="134"
|
||||
height="26"
|
||||
rx="9"
|
||||
fill="#FFFFFF"
|
||||
stroke="rgba(14,14,16,0.085)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x="32"
|
||||
y={top + 5}
|
||||
width="16"
|
||||
height="16"
|
||||
rx="5.5"
|
||||
fill="rgba(226,53,74,0.08)"
|
||||
/>
|
||||
<g
|
||||
transform={`translate(32, ${top + 5})`}
|
||||
stroke={RED_DEEP}
|
||||
strokeWidth="1.15"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
>
|
||||
{inp.icon}
|
||||
</g>
|
||||
<text
|
||||
x="56"
|
||||
y={top + 17}
|
||||
fill="rgba(14,14,16,0.78)"
|
||||
fontSize="9.5"
|
||||
fontWeight="700"
|
||||
fontFamily="Manrope, sans-serif"
|
||||
>
|
||||
{inp.label}
|
||||
</text>
|
||||
<circle cx="147" cy={top + 13} r="2.6" fill={RED} fillOpacity="0.25" />
|
||||
<circle
|
||||
cx="147"
|
||||
cy={top + 13}
|
||||
r="2.6"
|
||||
fill={RED}
|
||||
className="sol-mtc-live"
|
||||
style={{ animationDelay: `${i * 0.32}s` }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Feeds converging on the core's input layer. Two chips per input
|
||||
node, which is why the column reads as convergence. */}
|
||||
{INPUTS.map((inp, i) => {
|
||||
const cy = 96 + i * 37 + 13;
|
||||
const ny = NET[0][Math.floor(i / 2)];
|
||||
const d = `M 160 ${cy} C 190 ${cy}, 202 ${ny}, ${NET_X[0] - 5} ${ny}`;
|
||||
return (
|
||||
<g key={`feed-${inp.label}`}>
|
||||
{/* The wire is always drawn; only the signal on it moves. Without
|
||||
the base line the dashes read as debris floating in the gap. */}
|
||||
<path d={d} stroke={INK} strokeOpacity="0.13" strokeWidth="1" fill="none" />
|
||||
<path
|
||||
d={d}
|
||||
stroke={RED}
|
||||
strokeOpacity="0.75"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
className="sol-mtc-feed"
|
||||
style={{ animationDelay: `${i * 0.4}s` }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ============================================================
|
||||
LAYER 2 — THE DECISION ENGINE
|
||||
============================================================ */}
|
||||
<ellipse
|
||||
cx={CORE.cx}
|
||||
cy={CORE.cy}
|
||||
rx="116"
|
||||
ry="108"
|
||||
fill="url(#solMtcCoreGlow)"
|
||||
className="sol-mtc-glow"
|
||||
/>
|
||||
<circle
|
||||
cx={CORE.cx}
|
||||
cy={CORE.cy}
|
||||
r="110"
|
||||
fill="none"
|
||||
stroke="rgba(14,14,16,0.13)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2 11"
|
||||
className="sol-mtc-ring"
|
||||
/>
|
||||
<circle
|
||||
cx={CORE.cx}
|
||||
cy={CORE.cy}
|
||||
r="97"
|
||||
fill="none"
|
||||
stroke="rgba(226,53,74,0.3)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="3 15"
|
||||
className="sol-mtc-ring sol-mtc-ring--b"
|
||||
/>
|
||||
|
||||
{/* Synapses. The full mesh is drawn faint; only the path the engine
|
||||
actually took this cycle is lit and carries signal. */}
|
||||
{NET.slice(0, -1).map((layer, l) =>
|
||||
layer.map((y1, a) =>
|
||||
NET[l + 1].map((y2, b) => {
|
||||
const live = LIVE_SET.has(liveKey(l, a, b));
|
||||
return (
|
||||
<line
|
||||
key={`e-${l}-${a}-${b}`}
|
||||
x1={NET_X[l]}
|
||||
y1={y1}
|
||||
x2={NET_X[l + 1]}
|
||||
y2={y2}
|
||||
stroke={live ? RED : INK}
|
||||
strokeOpacity={live ? 0.55 : 0.1}
|
||||
strokeWidth={live ? 1.4 : 0.8}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)
|
||||
)}
|
||||
{LIVE_PATH.map(([l, a, b]) => (
|
||||
<line
|
||||
key={`live-${l}-${a}-${b}`}
|
||||
x1={NET_X[l]}
|
||||
y1={NET[l][a]}
|
||||
x2={NET_X[l + 1]}
|
||||
y2={NET[l + 1][b]}
|
||||
stroke="#FFFFFF"
|
||||
strokeOpacity="0.85"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
className="sol-mtc-synapse"
|
||||
style={{ animationDelay: `${l * 0.3}s` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{NET.map((layer, l) =>
|
||||
layer.map((y, i) => {
|
||||
const on = ACTIVE_NODES.has(`${l}-${i}`);
|
||||
return (
|
||||
<g key={`n-${l}-${i}`}>
|
||||
{on && (
|
||||
<>
|
||||
{/* Lit, not neon — a soft halo at half opacity rather than
|
||||
a bloom that turns the node into a lens flare. */}
|
||||
<circle
|
||||
cx={NET_X[l]}
|
||||
cy={y}
|
||||
r="5"
|
||||
fill={RED}
|
||||
fillOpacity="0.45"
|
||||
filter="url(#solMtcNodeGlow)"
|
||||
/>
|
||||
<circle
|
||||
cx={NET_X[l]}
|
||||
cy={y}
|
||||
r="6"
|
||||
fill="none"
|
||||
stroke={RED}
|
||||
strokeWidth="1.4"
|
||||
className="sol-mtc-fire"
|
||||
style={{ animationDelay: `${l * 0.28}s` }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<circle
|
||||
cx={NET_X[l]}
|
||||
cy={y}
|
||||
r={on ? 4.6 : 3.6}
|
||||
fill={on ? RED : "#FFFFFF"}
|
||||
stroke={on ? RED : "rgba(14,14,16,0.28)"}
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Decision leaving the core for the outcome column. */}
|
||||
{[
|
||||
{ d: "M 406 168 C 428 168, 432 138, 449 132" },
|
||||
{ d: "M 406 232 C 428 232, 432 252, 449 258" },
|
||||
].map((c, i) => (
|
||||
<g key={`out-${i}`}>
|
||||
<path d={c.d} stroke={RED} strokeOpacity="0.28" strokeWidth="1.3" fill="none" />
|
||||
<path
|
||||
d={c.d}
|
||||
stroke={RED}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
className="sol-mtc-feed"
|
||||
style={{ animationDelay: `${i * 0.5}s` }}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* ============================================================
|
||||
LAYER 3 — THE PROTECTED OUTCOME
|
||||
============================================================ */}
|
||||
<rect
|
||||
x="452"
|
||||
y="94"
|
||||
width="206"
|
||||
height="80"
|
||||
rx="13"
|
||||
fill="rgba(14,14,16,0.02)"
|
||||
stroke="rgba(14,14,16,0.06)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Rejected route in grey. It has to WANDER, not shadow the red one —
|
||||
two near-identical curves read as a rendering artefact. */}
|
||||
<path
|
||||
d="M 472 148 C 492 167, 524 169, 546 156 C 568 143, 572 112, 598 110 C 616 108, 626 118, 638 124"
|
||||
stroke="rgba(14,14,16,0.24)"
|
||||
strokeWidth="1.8"
|
||||
strokeDasharray="4 5"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
id="solMtcAiRoute"
|
||||
d="M 472 148 C 520 143, 580 130, 638 124"
|
||||
stroke="url(#solMtcRouteGrad)"
|
||||
strokeWidth="7"
|
||||
strokeOpacity="0.14"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 472 148 C 520 143, 580 130, 638 124"
|
||||
stroke="url(#solMtcRouteGrad)"
|
||||
strokeWidth="2.6"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 472 148 C 520 143, 580 130, 638 124"
|
||||
stroke="#FFFFFF"
|
||||
strokeOpacity="0.55"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
className="sol-mtc-route-flow"
|
||||
/>
|
||||
<circle cx="472" cy="148" r="4" fill={INK} />
|
||||
<circle cx="638" cy="124" r="5.5" fill="#FFFFFF" stroke={RED} strokeWidth="2" />
|
||||
<circle cx="638" cy="124" r="2" fill={RED} />
|
||||
{!still && (
|
||||
<g>
|
||||
<circle cx="0" cy="0" r="5" fill={RED} fillOpacity="0.4" filter="url(#solMtcNodeGlow)" />
|
||||
<circle cx="0" cy="0" r="3.2" fill={RED} />
|
||||
<animateMotion dur="4.6s" repeatCount="indefinite">
|
||||
<mpath href="#solMtcAiRoute" xlinkHref="#solMtcAiRoute" />
|
||||
</animateMotion>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Legend below the panel. Labels sat on top of the curves before,
|
||||
which made both unreadable. */}
|
||||
<g>
|
||||
<line
|
||||
x1="454"
|
||||
y1="188"
|
||||
x2="468"
|
||||
y2="188"
|
||||
stroke="rgba(14,14,16,0.3)"
|
||||
strokeWidth="1.8"
|
||||
strokeDasharray="4 4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x="474"
|
||||
y="191"
|
||||
fill="rgba(14,14,16,0.42)"
|
||||
fontSize="9.5"
|
||||
fontWeight="800"
|
||||
letterSpacing="0.5"
|
||||
fontFamily="Manrope, sans-serif"
|
||||
>
|
||||
Original 58m
|
||||
</text>
|
||||
<line
|
||||
x1="556"
|
||||
y1="188"
|
||||
x2="570"
|
||||
y2="188"
|
||||
stroke={RED}
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x="576"
|
||||
y="191"
|
||||
fill={RED_DEEP}
|
||||
fontSize="9.5"
|
||||
fontWeight="800"
|
||||
letterSpacing="0.5"
|
||||
fontFamily="Manrope, sans-serif"
|
||||
>
|
||||
MileTruth 41m
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{OUTCOMES.map((o, i) => {
|
||||
const cy = 212 + i * 28;
|
||||
return (
|
||||
<g key={o}>
|
||||
<circle
|
||||
cx="462"
|
||||
cy={cy}
|
||||
r="7"
|
||||
fill={RED}
|
||||
fillOpacity="0.18"
|
||||
className="sol-mtc-tick"
|
||||
style={{ animationDelay: `${i * 0.55}s` }}
|
||||
/>
|
||||
<circle cx="462" cy={cy} r="7" fill="#FFFFFF" stroke={RED} strokeWidth="1.4" />
|
||||
<path
|
||||
d={`M 458.8 ${cy} 461.2 ${cy + 2.4} 465.4 ${cy - 2.6}`}
|
||||
stroke={RED_DEEP}
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<text
|
||||
x="478"
|
||||
y={cy + 3.6}
|
||||
fill="rgba(14,14,16,0.8)"
|
||||
fontSize="10.5"
|
||||
fontWeight="700"
|
||||
fontFamily="Manrope, sans-serif"
|
||||
>
|
||||
{o}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ============ KPI STRIP ============ */}
|
||||
<line x1="8" y1="326" x2="672" y2="326" stroke="rgba(14,14,16,0.07)" strokeWidth="1" />
|
||||
<rect x="8" y="326" width="664" height="66" fill="rgba(14,14,16,0.018)" />
|
||||
<line x1="230" y1="342" x2="230" y2="376" stroke="rgba(14,14,16,0.085)" strokeWidth="1" />
|
||||
<line x1="450" y1="342" x2="450" y2="376" stroke="rgba(14,14,16,0.085)" strokeWidth="1" />
|
||||
|
||||
{[
|
||||
{
|
||||
x: 54,
|
||||
v: "41 min",
|
||||
l: "OPTIMISED ETA",
|
||||
ic: (
|
||||
<>
|
||||
<circle cx="13" cy="13" r="6.6" />
|
||||
<path d="M13 8.8V13l3 2" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
x: 275,
|
||||
v: "17 min",
|
||||
l: "TIME SAVED",
|
||||
ic: <path d="M14.2 5.6 8 13.6h4.2L11.4 20.4 17.6 12.4h-4.2Z" />,
|
||||
},
|
||||
{
|
||||
x: 496,
|
||||
v: "99.2%",
|
||||
l: "SLA PROTECTED",
|
||||
ic: (
|
||||
<>
|
||||
<path d="M13 5.4 19 8v5c0 4.1-3 6.6-6 7.6-3-1-6-3.5-6-7.6V8Z" />
|
||||
<path d="m10.4 12.9 2 2 3.4-3.6" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
].map((k) => (
|
||||
<g key={k.l}>
|
||||
<rect x={k.x} y="337" width="26" height="26" rx="8.5" fill="rgba(226,53,74,0.08)" />
|
||||
<g
|
||||
transform={`translate(${k.x}, 337)`}
|
||||
stroke={RED_DEEP}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
>
|
||||
{k.ic}
|
||||
</g>
|
||||
<text
|
||||
x={k.x + 36}
|
||||
y="355"
|
||||
fill={INK}
|
||||
fontSize="17"
|
||||
fontWeight="800"
|
||||
letterSpacing="-0.3"
|
||||
fontFamily="Manrope, sans-serif"
|
||||
>
|
||||
{k.v}
|
||||
</text>
|
||||
<text
|
||||
x={k.x + 36}
|
||||
y="371"
|
||||
fill="rgba(14,14,16,0.45)"
|
||||
fontSize="9.5"
|
||||
fontWeight="800"
|
||||
letterSpacing="1.15"
|
||||
fontFamily="Manrope, sans-serif"
|
||||
>
|
||||
{k.l}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3654
src/components/sections/SolutionsSections.tsx
Normal file
3678
src/components/sections/SolutionsWorkflow.tsx
Normal file
@@ -59,10 +59,10 @@ export default function TheDoormileWay() {
|
||||
<p>
|
||||
<strong>
|
||||
We connect first mile and last mile into one seamless system, reducing delays and improving efficiency. With real-time tracking and digital workflows,
|
||||
<br /> every delivery is faster, reliable, and fully transparent.
|
||||
<br /> every shipment is faster, reliable, and fully transparent.
|
||||
</strong>
|
||||
</p>
|
||||
<p>Seamless logistics powered by real-time tracking, digital processes, and reliable delivery execution.</p>
|
||||
<p>Seamless logistics powered by real-time tracking, digital processes, and reliable logistics execution.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +118,7 @@ export default function TheDoormileWay() {
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-74d14e3 elementor-widget elementor-widget-text-editor" data-id="74d14e3" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||
<div className="elementor-widget-container">
|
||||
<p>Real-time monitoring helps prevent delays before they impact deliveries.</p>
|
||||
<p>Real-time monitoring helps prevent delays before they impact operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +141,7 @@ export default function TheDoormileWay() {
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-74cfdf7 elementor-widget elementor-widget-text-editor" data-id="74cfdf7" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||
<div className="elementor-widget-container">
|
||||
<p>One unified system manages the entire delivery process seamlessly.</p>
|
||||
<p>One unified system manages the entire logistics process seamlessly.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { ScrollReveal } from "../../animations/Reveal";
|
||||
|
||||
type Stat = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type Stage = {
|
||||
img: string;
|
||||
label: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
desc: string;
|
||||
points: string[];
|
||||
stats?: Stat[];
|
||||
};
|
||||
|
||||
const STAGES: Stage[] = [
|
||||
{
|
||||
img: "/images/first-mile-approach.webp",
|
||||
label: "Stage 01",
|
||||
title: "First Mile Warehouse",
|
||||
subtitle: "Consolidation & Prep",
|
||||
desc: "Incoming shipments are securely loaded, checked, and queued for transfer in our high-capacity fulfillment centers.",
|
||||
points: ["AI-scheduled pickups", "Dynamic load consolidation", "Yard & dock management"],
|
||||
stats: [
|
||||
{ value: "14,250", label: "Parcels Processed" },
|
||||
{ value: "99.98%", label: "Sorting Accuracy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
img: "/images/mid-mile-approach.webp",
|
||||
label: "Stage 02",
|
||||
title: "Mid Mile Transit",
|
||||
subtitle: "Hub-to-Hub Transport",
|
||||
desc: "Freight is routed dynamically through our network of strategically located hubs using automated sortation and linehaul scheduling.",
|
||||
points: ["Optimised line-haul routing", "Cross-docking & sortation", "Live SLA monitoring"],
|
||||
stats: [
|
||||
{ value: "1,240+", label: "Daily Line-Hauls" },
|
||||
{ value: "98.5%", label: "SLA Adherence" }
|
||||
]
|
||||
},
|
||||
{
|
||||
img: "/images/last-mile-approach.webp",
|
||||
label: "Stage 03",
|
||||
title: "Last Mile Delivery",
|
||||
subtitle: "Hub to Doorstep",
|
||||
desc: "The final handoff. Our routing engine optimizes multi-stop itineraries to deliver parcels directly to customers' doorsteps in record time.",
|
||||
points: ["Multi-stop route optimisation", "Precise delivery windows", "Digital proof of delivery"],
|
||||
stats: [
|
||||
{ value: "450K+", label: "Happy Deliveries" },
|
||||
{ value: "2.8 Hours", label: "Average Turnaround" }
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
export default function WhyChooseDoormile() {
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
/* =====================================================================
|
||||
"Why Businesses Choose Doormile" — First / Mid / Last Mile stage cards.
|
||||
===================================================================== */
|
||||
.wcd-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: auto;
|
||||
margin: -250px 20px 20px 20px;
|
||||
background-color: #1F1F1F;
|
||||
border-radius: 0 0 25px 25px;
|
||||
padding: 50px 0 110px;
|
||||
}
|
||||
.wcd-inner {
|
||||
width: 100%;
|
||||
max-width: 1630px;
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wcd-head {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 0 0 70px;
|
||||
}
|
||||
@keyframes wcd-float {
|
||||
0% { transform: translateX(-50%) translateY(0px) scale(1); }
|
||||
50% { transform: translateX(-50%) translateY(-12px) scale(1.02); }
|
||||
100% { transform: translateX(-50%) translateY(0px) scale(1); }
|
||||
}
|
||||
.wcd-head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 75%;
|
||||
transform: translateX(-50%);
|
||||
width: min(1180px, 95%);
|
||||
aspect-ratio: 2 / 1;
|
||||
background: url('/images/bg-map.png') center / contain no-repeat;
|
||||
opacity: 0.06;
|
||||
filter: invert(1);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
animation: wcd-float 20s ease-in-out infinite;
|
||||
}
|
||||
.wcd-card-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.wcd-eyebrow {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 2.1429em;
|
||||
letter-spacing: 0.5px;
|
||||
color: #FFFFFF;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.logico-front-end .wcd-section h2.wcd-heading {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: clamp(34px, 4.4vw, 60px);
|
||||
font-weight: 500;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
text-transform: uppercase;
|
||||
color: #FFFFFF;
|
||||
-webkit-text-fill-color: #FFFFFF;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.wcd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 30px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Premium Glassmorphism & Card Interaction */
|
||||
.wcd-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: border-color 0.4s cubic-bezier(0.165, 0.84, 0.44, 1),
|
||||
box-shadow 0.4s cubic-bezier(0.165, 0.84, 0.44, 1),
|
||||
transform 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
}
|
||||
.wcd-card:hover {
|
||||
border-color: #c01227 !important;
|
||||
box-shadow: 0 20px 40px rgba(192, 18, 39, 0.15), inset 0 0 20px rgba(255, 255, 255, 0.02) !important;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
.wcd-card-media {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wcd-card-media img {
|
||||
object-fit: cover;
|
||||
transition: transform 0.6s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
}
|
||||
.wcd-card:hover .wcd-card-media img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Body Data Container (Unified Div) */
|
||||
.wcd-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Modern Pill Badge for Label */
|
||||
.wcd-card-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
padding: 6px 14px;
|
||||
background: rgba(192, 18, 39, 0.08);
|
||||
border: 1px solid rgba(192, 18, 39, 0.25);
|
||||
border-radius: 100px;
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #ff3344;
|
||||
margin: 0 0 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.wcd-card:hover .wcd-card-label {
|
||||
background: rgba(192, 18, 39, 0.18);
|
||||
border-color: #c01227;
|
||||
box-shadow: 0 0 10px rgba(192, 18, 39, 0.15);
|
||||
}
|
||||
|
||||
.logico-front-end .wcd-section h3.wcd-card-title {
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #FFFFFF;
|
||||
-webkit-text-fill-color: #FFFFFF;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.wcd-card-subtitle {
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin: 0 0 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.wcd-card-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
margin: 0 0 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wcd-card-desc {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.wcd-card-points {
|
||||
list-style: none;
|
||||
margin: 0 0 30px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.wcd-section .wcd-card-points li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding-left: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.wcd-section .wcd-card-points li::before {
|
||||
content: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wcd-card-points .wcd-check {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 0.08em;
|
||||
color: #c01227;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.wcd-card:hover .wcd-card-points .wcd-check {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Integrated Stats Section */
|
||||
.wcd-card-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.wcd-stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wcd-stat-value {
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #FFFFFF;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.wcd-card:hover .wcd-stat-value {
|
||||
color: #ff3344;
|
||||
}
|
||||
.wcd-stat-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.wcd-section { margin: 0 10px 10px 10px; padding: 40px 0 80px; }
|
||||
.wcd-inner { padding: 0; }
|
||||
.wcd-grid { grid-template-columns: 1fr; gap: 24px; }
|
||||
.wcd-card-body { padding: 32px; }
|
||||
.logico-front-end .wcd-section h3.wcd-card-title { font-size: 24px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.wcd-section { margin: 0 10px 10px 10px; border-radius: 0 0 20px 20px; padding: 30px 0 64px; }
|
||||
.wcd-inner { padding: 0; }
|
||||
.wcd-card-body { padding: 24px; }
|
||||
}
|
||||
`}} />
|
||||
|
||||
<section className="wcd-section">
|
||||
<div className="wcd-inner">
|
||||
<ScrollReveal delay={0.1}>
|
||||
<div className="wcd-head">
|
||||
<div className="wcd-eyebrow">/ Why Businesses Choose Doormile /</div>
|
||||
<h2 className="wcd-heading">The connected approach delivers measurable advantages</h2>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="wcd-grid">
|
||||
{STAGES.map((stage, index) => (
|
||||
<ScrollReveal
|
||||
key={stage.label}
|
||||
delay={index * 0.15}
|
||||
duration={0.8}
|
||||
yOffset={50}
|
||||
className="wcd-card-wrapper"
|
||||
>
|
||||
<article className="wcd-card" style={{ height: "100%" }}>
|
||||
<div className="wcd-card-media">
|
||||
<Image
|
||||
src={stage.img}
|
||||
alt={stage.title}
|
||||
fill
|
||||
sizes="(max-width: 1020px) 100vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
{/* Single Parent Div for All Data Content */}
|
||||
<div className="wcd-card-body">
|
||||
<div className="wcd-card-label">{stage.label}</div>
|
||||
|
||||
<h3 className="wcd-card-title">{stage.title}</h3>
|
||||
|
||||
{stage.subtitle && (
|
||||
<div className="wcd-card-subtitle">{stage.subtitle}</div>
|
||||
)}
|
||||
|
||||
<div className="wcd-card-divider" />
|
||||
|
||||
<p className="wcd-card-desc">{stage.desc}</p>
|
||||
|
||||
<ul className="wcd-card-points">
|
||||
{stage.points.map((point) => (
|
||||
<li key={point}>
|
||||
<svg
|
||||
className="wcd-check"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Integrated Statistics Grid */}
|
||||
{stage.stats && stage.stats.length > 0 && (
|
||||
<div className="wcd-card-stats">
|
||||
{stage.stats.map((stat) => (
|
||||
<div key={stat.label} className="wcd-stat-item">
|
||||
<span className="wcd-stat-value">{stat.value}</span>
|
||||
<span className="wcd-stat-label">{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import EVSection, { type EVFeature, type EVStat } from "./EVSection";
|
||||
// EVSection embeds this value in an UNQUOTED CSS url(...).
|
||||
|
||||
const METRICS: EVStat[] = [
|
||||
{ value: 8.2, decimals: 1, suffix: "t", label: "ATR72 Payload" },
|
||||
{ value: 1600, suffix: "km", label: "ATR72 Range" },
|
||||
{ value: 8.6, decimals: 1, suffix: "t", label: "Q400 Payload" },
|
||||
{ value: 667, suffix: "km/h", label: "Q400 Speed" },
|
||||
{ value: 8.2, decimals: 1, suffix: "t", label: "Payload Capacity" },
|
||||
{ value: 1600, suffix: "km", label: "Regional Range" },
|
||||
{ value: 8.6, decimals: 1, suffix: "t", label: "Max Payload" },
|
||||
{ value: 667, suffix: "km/h", label: "Regional Speed" },
|
||||
];
|
||||
|
||||
const FEATURES: EVFeature[] = [
|
||||
|
||||
@@ -465,7 +465,7 @@ export default function WingsHero() {
|
||||
<p>
|
||||
Linking India's major regional capitals with
|
||||
AI-powered air movement and zero-emission EV
|
||||
last-mile delivery.
|
||||
last-mile transport.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@ const STEPS = [
|
||||
},
|
||||
{
|
||||
label: "EV Last-Mile",
|
||||
desc: "Zero-emission electric vehicles complete the final delivery.",
|
||||
desc: "Zero-emission electric vehicles complete the final mile.",
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 13h13l3 4h2v3H3z" />
|
||||
@@ -72,7 +72,7 @@ export default function WingsHowItWorks() {
|
||||
<h2 className={styles.title}>
|
||||
Pickup to
|
||||
<br />
|
||||
delivery, one
|
||||
arrival, one
|
||||
<br />
|
||||
connected flow
|
||||
</h2>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ScrollReveal } from "@/animations/Reveal";
|
||||
const INDUSTRIES = [
|
||||
{
|
||||
title: "Quick Commerce",
|
||||
challenge: "Sub-3-hour delivery windows for dark stores and micro-warehouses.",
|
||||
challenge: "Sub-3-hour arrival windows for dark stores and micro-warehouses.",
|
||||
solution: "Dedicated same-day air capacity linking metros to fulfillment hubs.",
|
||||
image: "/images/card 1.png",
|
||||
},
|
||||
@@ -34,7 +34,7 @@ const INDUSTRIES = [
|
||||
{
|
||||
title: "E-Commerce Logistics",
|
||||
challenge: "Next-day and same-day fulfillment expectations across tier-1 and tier-2 cities.",
|
||||
solution: "Direct sorting-hub-to-airport clearance network for faster regional delivery.",
|
||||
solution: "Direct sorting-hub-to-airport clearance network for faster regional logistics.",
|
||||
image: "/images/card6.png",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,68 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollReveal, StaggerChildren } from "@/animations/Reveal";
|
||||
import { ScrollReveal } from "@/animations/Reveal";
|
||||
import WingsNeuralEngineAnimation from "./WingsNeuralEngineAnimation";
|
||||
|
||||
const FLOW_STAGES = [
|
||||
{
|
||||
id: "data",
|
||||
label: "Logistics Data",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3" />
|
||||
</svg>
|
||||
),
|
||||
description: "Orders, fleet telemetry, routes, and traffic updates compiled in real-time."
|
||||
},
|
||||
{
|
||||
id: "ai",
|
||||
label: "AI Core",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" ry="2" />
|
||||
<rect x="9" y="9" width="6" height="6" />
|
||||
<line x1="9" y1="1" x2="9" y2="4" />
|
||||
<line x1="15" y1="1" x2="15" y2="4" />
|
||||
<line x1="9" y1="20" x2="9" y2="23" />
|
||||
<line x1="15" y1="20" x2="15" y2="23" />
|
||||
<line x1="20" y1="9" x2="23" y2="9" />
|
||||
<line x1="20" y1="15" x2="23" y2="15" />
|
||||
<line x1="1" y1="9" x2="4" y2="9" />
|
||||
<line x1="1" y1="15" x2="4" y2="15" />
|
||||
</svg>
|
||||
),
|
||||
description: "Neural engine simulating network patterns and predicting constraints."
|
||||
},
|
||||
{
|
||||
id: "recommendations",
|
||||
label: "Recommendations",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 22h4" />
|
||||
</svg>
|
||||
),
|
||||
description: "Optimized route configurations, SLA status warnings, and capacity balancing."
|
||||
},
|
||||
{
|
||||
id: "improvement",
|
||||
label: "Continuous Learning",
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 4v6h-6" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
),
|
||||
description: "Post-flight feedback loops refining algorithms with every shipment."
|
||||
}
|
||||
];
|
||||
|
||||
const CAPABILITIES = [
|
||||
{
|
||||
title: "SLA Monitoring",
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Demand Forecasting",
|
||||
desc: "AI-driven forward projection of cargo demand, preemptively positioning aircraft and ground capacity.",
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 3v18h18" />
|
||||
<path d="M7 14l4-4 3 3 6-7" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Dynamic Pricing",
|
||||
desc: "Real-time rate adjustments that track market capacity fluctuations and optimize yield.",
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.5 9.5c0-1.4 1.1-2.3 2.5-2.3s2.5.9 2.5 2c0 1.6-2.2 1.8-2.5 3.3M12 17h.01" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Route Optimization",
|
||||
desc: "Dynamic scheduling for ground EV pickups and flights that bypasses congestion for the fastest path.",
|
||||
title: "Route & Fleet Optimization",
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="13 6 19 12 13 18" />
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12h18" />
|
||||
<path d="M12 3v18" />
|
||||
<path d="m18 17 4-5-4-5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Load Balancing",
|
||||
desc: "Automated weight-and-balance modeling for optimal, safe aircraft load layout within minutes.",
|
||||
title: "Anomaly Detection",
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3v18M5 8l7-5 7 5M5 8v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8" />
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Predictive Maintenance",
|
||||
desc: "Continuous telemetry monitoring triggers preemptive alerts, drastically reducing aircraft downtime.",
|
||||
title: "Performance Insights",
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 12h-6l-4 8-4-16-4 8H2" />
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20V10" />
|
||||
<path d="M18 20V4" />
|
||||
<path d="M6 20v-4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Real-Time Tracking",
|
||||
desc: "End-to-end sensor monitoring keeps shippers, airline, and EV operators updated on exact shipment state.",
|
||||
icon: (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
function TiltCard({ children, className }: { children: React.ReactNode, className: string }) {
|
||||
@@ -73,9 +121,7 @@ function TiltCard({ children, className }: { children: React.ReactNode, classNam
|
||||
const card = cardRef.current;
|
||||
const glare = glareRef.current;
|
||||
if (!card || !glare) return;
|
||||
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced) return;
|
||||
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
@@ -84,39 +130,24 @@ function TiltCard({ children, className }: { children: React.ReactNode, classNam
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
|
||||
// Softer, more premium 3D tilt
|
||||
const rotateX = ((y - centerY) / centerY) * -3;
|
||||
const rotateY = ((x - centerX) / centerX) * 3;
|
||||
const rotateX = ((y - centerY) / centerY) * -4;
|
||||
const rotateY = ((x - centerX) / centerX) * 4;
|
||||
|
||||
gsap.to(card, {
|
||||
duration: 0.4,
|
||||
duration: 0.5,
|
||||
rotateX,
|
||||
rotateY,
|
||||
y: -4,
|
||||
transformPerspective: 1000,
|
||||
transformPerspective: 1200,
|
||||
ease: "power2.out"
|
||||
});
|
||||
|
||||
// Dynamic glare tracking
|
||||
gsap.set(glare, {
|
||||
"--mx": `${x}px`,
|
||||
"--my": `${y}px`,
|
||||
});
|
||||
gsap.to(glare, {
|
||||
duration: 0.3,
|
||||
opacity: 1,
|
||||
ease: "power2.out"
|
||||
});
|
||||
|
||||
// Z-axis pop
|
||||
gsap.to(card.querySelector('.wmt-card-icon'), {
|
||||
duration: 0.4,
|
||||
z: 25,
|
||||
ease: "power2.out"
|
||||
});
|
||||
gsap.to(card.querySelector('.wmt-card-title'), {
|
||||
duration: 0.4,
|
||||
z: 15,
|
||||
opacity: 0.15,
|
||||
ease: "power2.out"
|
||||
});
|
||||
};
|
||||
@@ -126,7 +157,6 @@ function TiltCard({ children, className }: { children: React.ReactNode, classNam
|
||||
duration: 0.8,
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
y: 0,
|
||||
ease: "power3.out"
|
||||
});
|
||||
gsap.to(glare, {
|
||||
@@ -134,11 +164,6 @@ function TiltCard({ children, className }: { children: React.ReactNode, classNam
|
||||
opacity: 0,
|
||||
ease: "power3.out"
|
||||
});
|
||||
gsap.to(card.querySelectorAll('.wmt-card-icon, .wmt-card-title'), {
|
||||
duration: 0.8,
|
||||
z: 0,
|
||||
ease: "power3.out"
|
||||
});
|
||||
};
|
||||
|
||||
card.addEventListener("mousemove", handleMouseMove);
|
||||
@@ -151,29 +176,29 @@ function TiltCard({ children, className }: { children: React.ReactNode, classNam
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={cardRef} className={className}>
|
||||
<div ref={cardRef} className={className} style={{ perspective: "1000px" }}>
|
||||
<div
|
||||
ref={glareRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
borderRadius: "inherit",
|
||||
background: "radial-gradient(circle 220px at var(--mx, 50%) var(--my, 50%), rgba(255, 255, 255, 0.08) 0%, rgba(200, 16, 46, 0.03) 40%, transparent 100%)",
|
||||
background: "radial-gradient(circle 300px at var(--mx, 50%) var(--my, 50%), rgba(255, 59, 59, 0.1) 0%, transparent 100%)",
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
zIndex: 0,
|
||||
zIndex: 2,
|
||||
transform: "translateZ(1px)",
|
||||
mixBlendMode: "screen"
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: "relative", zIndex: 1, transformStyle: "preserve-3d" }}>
|
||||
<div style={{ position: "relative", zIndex: 1, transformStyle: "preserve-3d", height: "100%", width: "100%" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WingsMileTruth() {
|
||||
const ParticleCanvas = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -182,85 +207,90 @@ export default function WingsMileTruth() {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
let animationFrameId: number;
|
||||
let width = (canvas.width = canvas.offsetWidth);
|
||||
let height = (canvas.height = canvas.offsetHeight);
|
||||
|
||||
const handleResize = () => {
|
||||
if (!canvas) return;
|
||||
width = canvas.width = canvas.offsetWidth;
|
||||
height = canvas.height = canvas.offsetHeight;
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
const nodes = [
|
||||
{ x: 0.12, y: 0.5 },
|
||||
{ x: 0.34, y: 0.25 },
|
||||
{ x: 0.56, y: 0.65 },
|
||||
{ x: 0.78, y: 0.3 },
|
||||
{ x: 0.92, y: 0.6 },
|
||||
];
|
||||
const particles = [
|
||||
{ from: 0, to: 1, t: 0, speed: 0.006 },
|
||||
{ from: 1, to: 2, t: 0.4, speed: 0.005 },
|
||||
{ from: 2, to: 3, t: 0.15, speed: 0.007 },
|
||||
{ from: 3, to: 4, t: 0.6, speed: 0.005 },
|
||||
];
|
||||
const particles: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
speedY: number;
|
||||
speedX: number;
|
||||
opacity: number;
|
||||
fadeSpeed: number;
|
||||
}> = [];
|
||||
|
||||
let raf: number;
|
||||
const draw = () => {
|
||||
const createParticle = (isInitial = false) => {
|
||||
return {
|
||||
x: Math.random() * width,
|
||||
y: isInitial ? Math.random() * height : height + 10,
|
||||
size: Math.random() * 2 + 0.5,
|
||||
speedY: -(Math.random() * 0.4 + 0.1),
|
||||
speedX: (Math.random() - 0.5) * 0.2,
|
||||
opacity: Math.random() * 0.5 + 0.1,
|
||||
fadeSpeed: Math.random() * 0.005 + 0.002,
|
||||
};
|
||||
};
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
particles.push(createParticle(true));
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([6, 6]);
|
||||
ctx.strokeStyle = "rgba(200, 16, 46, 0.16)";
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < nodes.length - 1; i++) {
|
||||
ctx.moveTo(nodes[i].x * width, nodes[i].y * height);
|
||||
ctx.lineTo(nodes[i + 1].x * width, nodes[i + 1].y * height);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
if (!reduced) {
|
||||
particles.forEach((p) => {
|
||||
p.t += p.speed;
|
||||
if (p.t >= 1) p.t = 0;
|
||||
const a = nodes[p.from];
|
||||
const b = nodes[p.to];
|
||||
const x = a.x * width + (b.x * width - a.x * width) * p.t;
|
||||
const y = a.y * height + (b.y * height - a.y * height) * p.t;
|
||||
particles.forEach((p, idx) => {
|
||||
p.y += p.speedY;
|
||||
p.x += p.speedX;
|
||||
p.opacity -= p.fadeSpeed;
|
||||
|
||||
if (p.opacity <= 0 || p.y < -10 || p.x < -10 || p.x > width + 10) {
|
||||
particles[idx] = createParticle(false);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 3.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#c8102e";
|
||||
ctx.shadowColor = "#c8102e";
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(255, 59, 59, ${p.opacity})`;
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.shadowColor = "rgba(255, 59, 59, 0.6)";
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
});
|
||||
}
|
||||
|
||||
nodes.forEach((n) => {
|
||||
const x = n.x * width;
|
||||
const y = n.y * height;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 7, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(200, 16, 46, 0.1)";
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
|
||||
ctx.fill();
|
||||
}
|
||||
});
|
||||
|
||||
if (!reduced) raf = requestAnimationFrame(draw);
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
draw();
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <canvas ref={canvasRef} className="wmt-particle-canvas" aria-hidden="true" />;
|
||||
};
|
||||
|
||||
export default function WingsMileTruth() {
|
||||
const [activeStage, setActiveStage] = useState(0);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHovered) return;
|
||||
const interval = setInterval(() => {
|
||||
setActiveStage((prev) => (prev + 1) % FLOW_STAGES.length);
|
||||
}, 4000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isHovered]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style
|
||||
@@ -273,45 +303,97 @@ export default function WingsMileTruth() {
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
background: #09090b;
|
||||
background: #08080a;
|
||||
padding: clamp(64px, 8vw, 100px) clamp(24px, 5vw, 48px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.wmt-canvas {
|
||||
.wmt-ambient-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
.wmt-glow {
|
||||
position: absolute;
|
||||
width: 480px;
|
||||
height: 480px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
filter: blur(140px);
|
||||
z-index: 0;
|
||||
opacity: 0.06;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.wmt-glow.tl { left: -8%; top: -12%; background: radial-gradient(circle, #c8102e 0%, transparent 70%); }
|
||||
.wmt-glow.br { right: -8%; bottom: -12%; background: radial-gradient(circle, #ff3b3b 0%, transparent 70%); }
|
||||
.wmt-ambient-glow.red-left {
|
||||
left: -10%;
|
||||
top: 20%;
|
||||
width: 480px;
|
||||
height: 480px;
|
||||
background: radial-gradient(circle, #ff3b3b 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.wmt-ambient-glow.red-right {
|
||||
right: -5%;
|
||||
bottom: 10%;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
background: radial-gradient(circle, #ff3b3b 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.wmt-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1380px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
gap: 64px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wmt-header {
|
||||
margin: 0 auto 48px auto;
|
||||
max-width: 720px;
|
||||
text-align: center;
|
||||
@media (max-width: 1024px) {
|
||||
.wmt-inner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.wmt-visual-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wmt-visual-pane {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
max-width: 580px;
|
||||
border-radius: 24px;
|
||||
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.02) 0%, transparent 80%), rgba(255, 255, 255, 0.01);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wmt-visual-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wmt-particle-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wmt-content-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wmt-eyebrow {
|
||||
@@ -319,85 +401,262 @@ export default function WingsMileTruth() {
|
||||
font-weight: 800;
|
||||
letter-spacing: 2.5px;
|
||||
text-transform: uppercase;
|
||||
color: #c8102e;
|
||||
color: #ff3b3b;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.wmt-title {
|
||||
font-size: clamp(28px, 3.4vw, 48px) !important;
|
||||
font-size: clamp(32px, 3.8vw, 44px) !important;
|
||||
font-weight: 800 !important;
|
||||
letter-spacing: -0.03em !important;
|
||||
line-height: 1.15 !important;
|
||||
text-transform: none !important;
|
||||
color: #ffffff !important;
|
||||
margin: 0;
|
||||
margin: 0 0 16px 0;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.wmt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
margin-bottom: 20px;
|
||||
.wmt-title span {
|
||||
color: #ff3b3b;
|
||||
}
|
||||
|
||||
.wmt-card {
|
||||
min-width: 0;
|
||||
.wmt-desc {
|
||||
font-size: clamp(15px, 1.8vw, 16px);
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin: 0 0 40px 0;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.wmt-flow-container {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 18px;
|
||||
padding: 26px 22px;
|
||||
transition: border-color 0.5s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.5s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.wmt-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
background-color: rgba(255, 255, 255, 0.055);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||
.wmt-flow-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.wmt-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
.wmt-flow-pipeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
border-radius: 16px;
|
||||
padding: 16px 12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wmt-flow-pipeline {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.wmt-flow-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wmt-flow-node {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.wmt-flow-icon-wrapper {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: rgba(200, 16, 46, 0.12);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.wmt-flow-node:hover .wmt-flow-icon-wrapper,
|
||||
.wmt-flow-node.active .wmt-flow-icon-wrapper {
|
||||
background: rgba(255, 59, 59, 0.12);
|
||||
border-color: rgba(255, 59, 59, 0.5);
|
||||
color: #ff3b3b;
|
||||
box-shadow: 0 0 15px rgba(255, 59, 59, 0.25);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.wmt-flow-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.wmt-flow-node:hover .wmt-flow-label,
|
||||
.wmt-flow-node.active .wmt-flow-label {
|
||||
color: #ffffff;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.wmt-card-title {
|
||||
font-size: 15.5px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1.3 !important;
|
||||
text-transform: none !important;
|
||||
color: #ffffff !important;
|
||||
margin: 0 0 8px 0;
|
||||
.wmt-flow-connector {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.wmt-card-desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
@media (max-width: 640px) {
|
||||
.wmt-flow-connector {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wmt-flow-connector.active {
|
||||
background: rgba(255, 59, 59, 0.2);
|
||||
}
|
||||
|
||||
.wmt-flow-connector::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, #ff3b3b, transparent);
|
||||
animation: laser-flow 3s infinite linear;
|
||||
}
|
||||
|
||||
.wmt-flow-connector.active::after {
|
||||
animation-duration: 1.8s;
|
||||
}
|
||||
|
||||
@keyframes laser-flow {
|
||||
0% { left: -100%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
.wmt-flow-details {
|
||||
margin-top: 14px;
|
||||
padding: 12px 18px;
|
||||
background: rgba(255, 59, 59, 0.015);
|
||||
border: 1px solid rgba(255, 59, 59, 0.08);
|
||||
border-radius: 12px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.wmt-flow-details-text {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wmt-chips-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.wmt-chips-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.wmt-chips-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.wmt-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.wmt-chip:hover {
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border-color: rgba(255, 59, 59, 0.3);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4), 0 0 10px rgba(255, 59, 59, 0.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wmt-chip-icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.wmt-chip:hover .wmt-chip-icon-container {
|
||||
background: rgba(255, 59, 59, 0.1);
|
||||
border-color: rgba(255, 59, 59, 0.35);
|
||||
color: #ff3b3b;
|
||||
}
|
||||
|
||||
.wmt-chip-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.wmt-chip:hover .wmt-chip-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.wmt-callout {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
padding: 34px 32px;
|
||||
padding: 30px 32px;
|
||||
background: linear-gradient(135deg, rgba(200, 16, 46, 0.95) 0%, rgba(110, 8, 26, 0.98) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
margin-top: 56px;
|
||||
}
|
||||
|
||||
.wmt-callout::after {
|
||||
@@ -405,7 +664,7 @@ export default function WingsMileTruth() {
|
||||
position: absolute;
|
||||
inset: -1.5px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(90deg, #c8102e, #ff3366, #ff0033, #c8102e);
|
||||
background: linear-gradient(90deg, #c8102e, #ff3b3b, #ff3b3b, #c8102e);
|
||||
background-size: 300% 100%;
|
||||
animation: wmt-sweep 6s linear infinite;
|
||||
z-index: -1;
|
||||
@@ -421,7 +680,7 @@ export default function WingsMileTruth() {
|
||||
.wmt-callout-body { max-width: 640px; }
|
||||
|
||||
.wmt-callout-title {
|
||||
font-size: 20px !important;
|
||||
font-size: 19px !important;
|
||||
font-weight: 800 !important;
|
||||
line-height: 1.3 !important;
|
||||
text-transform: none !important;
|
||||
@@ -430,7 +689,7 @@ export default function WingsMileTruth() {
|
||||
}
|
||||
|
||||
.wmt-callout-desc {
|
||||
font-size: 14px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: 0;
|
||||
@@ -438,7 +697,7 @@ export default function WingsMileTruth() {
|
||||
|
||||
.wmt-callout-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 11.5px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
@@ -449,18 +708,8 @@ export default function WingsMileTruth() {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.wmt-section { width: calc(100% - 24px); border-radius: 22px; }
|
||||
.wmt-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wmt-grid { grid-template-columns: 1fr; }
|
||||
.wmt-callout { flex-direction: column; align-items: flex-start; }
|
||||
/* The vertical clamp() floors at a fixed 64px on any phone width;
|
||||
trimmed to a real mobile value to match the section rhythm near
|
||||
the footer. Horizontal padding is left untouched (its own
|
||||
clamp already resolves small on phones). */
|
||||
.wmt-callout { flex-direction: column; align-items: flex-start; margin-top: 40px; }
|
||||
.wmt-section { padding-top: 48px; padding-bottom: 48px; }
|
||||
}
|
||||
`,
|
||||
@@ -468,39 +717,84 @@ export default function WingsMileTruth() {
|
||||
/>
|
||||
|
||||
<section className="wmt-section" id="wings-miletruth">
|
||||
<canvas ref={canvasRef} className="wmt-canvas" aria-hidden="true" />
|
||||
<div className="wmt-glow tl" aria-hidden="true" />
|
||||
<div className="wmt-glow br" aria-hidden="true" />
|
||||
<div className="wmt-ambient-glow red-left" aria-hidden="true" />
|
||||
<div className="wmt-ambient-glow red-right" aria-hidden="true" />
|
||||
|
||||
<div className="wmt-inner dm-section-container">
|
||||
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className="wmt-header">
|
||||
<div className="wmt-eyebrow">/ MileTruth™ AI /</div>
|
||||
<h2 className="wmt-title">The intelligence layer behind every delivery</h2>
|
||||
<ScrollReveal delay={0.05} duration={0.8} yOffset={20} className="wmt-visual-container">
|
||||
<TiltCard className="wmt-visual-pane">
|
||||
<WingsNeuralEngineAnimation />
|
||||
</TiltCard>
|
||||
</ScrollReveal>
|
||||
|
||||
<StaggerChildren stagger={0.08} duration={0.65} yOffset={25} className="wmt-grid">
|
||||
{CAPABILITIES.map((c) => (
|
||||
<TiltCard key={c.title} className="wmt-card">
|
||||
<div className="wmt-card-icon" aria-hidden="true">{c.icon}</div>
|
||||
<h3 className="wmt-card-title">{c.title}</h3>
|
||||
<p className="wmt-card-desc">{c.desc}</p>
|
||||
</TiltCard>
|
||||
))}
|
||||
</StaggerChildren>
|
||||
<div className="wmt-content-pane">
|
||||
<ScrollReveal delay={0.1} duration={0.8} yOffset={20}>
|
||||
<div className="wmt-eyebrow">/ MileTruth™ AI /</div>
|
||||
<h2 className="wmt-title">MILETRUTH™ <span>AI</span></h2>
|
||||
<p className="wmt-desc">
|
||||
MileTruth continuously analyzes every completed shipment, turning raw operational data into recommendations that make next mile faster, cheaper and more reliable.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.15} duration={0.8} yOffset={25}>
|
||||
<div className="wmt-callout">
|
||||
<div className="wmt-callout-body">
|
||||
<h3 className="wmt-callout-title">Digital Twin of the Logistics Network</h3>
|
||||
<p className="wmt-callout-desc">
|
||||
A live virtual model of every ground vehicle, terminal, and flight — simulating
|
||||
what-if scenarios before bottlenecks ever form.
|
||||
<ScrollReveal delay={0.15} duration={0.8} yOffset={20} className="wmt-flow-container">
|
||||
<div className="wmt-flow-title">Data Pipeline Flow</div>
|
||||
<div
|
||||
className="wmt-flow-pipeline"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{FLOW_STAGES.map((stage, idx) => (
|
||||
<div key={stage.id} style={{ display: 'contents' }}>
|
||||
<div
|
||||
className={`wmt-flow-node ${activeStage === idx ? 'active' : ''}`}
|
||||
onMouseEnter={() => setActiveStage(idx)}
|
||||
>
|
||||
<div className="wmt-flow-icon-wrapper">
|
||||
{stage.icon}
|
||||
</div>
|
||||
<div className="wmt-flow-label">{stage.label}</div>
|
||||
</div>
|
||||
{idx < FLOW_STAGES.length - 1 && (
|
||||
<div className={`wmt-flow-connector ${activeStage >= idx ? 'active' : ''}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="wmt-flow-details">
|
||||
<p className="wmt-flow-details-text">
|
||||
{FLOW_STAGES[activeStage].description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="wmt-callout-badge">SaaS Ready</span>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.2} duration={0.8} yOffset={20}>
|
||||
<div className="wmt-chips-title">Engine Capabilities</div>
|
||||
<div className="wmt-chips-grid">
|
||||
{CAPABILITIES.map((c) => (
|
||||
<div key={c.title} className="wmt-chip">
|
||||
<div className="wmt-chip-icon-container">
|
||||
{c.icon}
|
||||
</div>
|
||||
<span className="wmt-chip-text">{c.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollReveal delay={0.25} duration={0.8} yOffset={20}>
|
||||
<div className="wmt-callout">
|
||||
<div className="wmt-callout-body">
|
||||
<h3 className="wmt-callout-title">Digital Twin of the Logistics Network</h3>
|
||||
<p className="wmt-callout-desc">
|
||||
A live virtual model of every ground vehicle, terminal, and flight — simulating
|
||||
what-if scenarios before bottlenecks ever form.
|
||||
</p>
|
||||
</div>
|
||||
<span className="wmt-callout-badge">SaaS Ready</span>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
937
src/components/sections/WingsNeuralEngineAnimation.tsx
Normal file
@@ -0,0 +1,937 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
/* ============================================================
|
||||
WingsNeuralEngineAnimation — the live visual beside the MileTruth™ AI copy
|
||||
on /doormile-wings. Wings-page only; the /solutions and home variants
|
||||
(SolutionsMileTruthAnimation, AiCoreAnimation) are separate files.
|
||||
|
||||
This is a *simulation*, not a timeline: every packet, cadence and readout
|
||||
is randomised per event, so the panel never repeats a frame and reads as
|
||||
something actually running. Six named telemetry sources stream packets
|
||||
into a core, the core breathes with its own throughput, and every few
|
||||
seconds it fires a recommendation out to the insight stack.
|
||||
|
||||
Canvas paints the field / ring / core / bursts; the HUD is HTML so the
|
||||
numbers stay crisp. One rAF loop drives both — HUD text is written through
|
||||
refs, never through React state, so there is no re-render per frame.
|
||||
|
||||
Lifecycle follows the site canvas pattern (see NeuralField, WorkflowScene):
|
||||
DPR-aware, ResizeObserver-sized, rAF paused off-screen and on hidden tabs,
|
||||
prefers-reduced-motion paints one static frame and stops.
|
||||
============================================================ */
|
||||
|
||||
/** Telemetry feeds on the ingest ring, in ring order from 12 o'clock running
|
||||
* clockwise. Every transport value here is a fixed constant: `bend` is that
|
||||
* feed's path curvature, `speed` its transit rate, `dwell` the pause after
|
||||
* absorption before it sends again. The six packets differ because these
|
||||
* numbers differ — there is no spawner and no randomness in the transport.
|
||||
* Labels are ordered so the long ones land on the sides, where there is room
|
||||
* to set them outside the ring. */
|
||||
const SOURCES = [
|
||||
{ label: "Orders", bend: 0.26, speed: 0.4, dwell: 900 },
|
||||
{ label: "Fleet Telemetry", bend: -0.19, speed: 0.52, dwell: 520 },
|
||||
{ label: "Routes", bend: 0.34, speed: 0.35, dwell: 1150 },
|
||||
{ label: "Traffic", bend: -0.3, speed: 0.47, dwell: 700 },
|
||||
{ label: "Weather", bend: 0.14, speed: 0.3, dwell: 1500 },
|
||||
{ label: "POD Scans", bend: -0.38, speed: 0.43, dwell: 1050 },
|
||||
];
|
||||
|
||||
/** Ring radius as a fraction of the short edge. Shared by the canvas and the
|
||||
* HTML labels so the two layers stay locked together. */
|
||||
const RING = 0.28;
|
||||
|
||||
/** Recommendation pool. `fn` builds the value half fresh each time so the
|
||||
* same insight never reads identically twice. */
|
||||
const INSIGHTS: Array<{ head: string; fn: () => string; good?: boolean }> = [
|
||||
{ head: "Reroute DEL → JAI", fn: () => `−${11 + Math.floor(Math.random() * 14)} min`, good: true },
|
||||
{ head: "SLA risk cleared", fn: () => `BLR-${10 + Math.floor(Math.random() * 80)}`, good: true },
|
||||
{ head: "Load rebalanced", fn: () => `Hub ${1 + Math.floor(Math.random() * 4)} → Hub ${5 + Math.floor(Math.random() * 3)}` },
|
||||
{ head: "Demand spike", fn: () => `Chennai +${9 + Math.floor(Math.random() * 22)}%` },
|
||||
{ head: "Idle dwell trimmed", fn: () => `−${4 + Math.floor(Math.random() * 9)} min/stop`, good: true },
|
||||
{ head: "Capacity shifted", fn: () => `${2 + Math.floor(Math.random() * 6)} vehicles` },
|
||||
{ head: "Anomaly flagged", fn: () => `Lane HYD-${100 + Math.floor(Math.random() * 90)}` },
|
||||
{ head: "Cold-chain hold", fn: () => `${2 + Math.floor(Math.random() * 5)} shipments` },
|
||||
];
|
||||
|
||||
type Insight = { id: number; head: string; value: string; good: boolean };
|
||||
|
||||
const rand = (a: number, b: number) => a + Math.random() * (b - a);
|
||||
|
||||
export default function WingsNeuralEngineAnimation({ className = "" }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const countRef = useRef<HTMLElement>(null);
|
||||
const rateRef = useRef<HTMLSpanElement>(null);
|
||||
const confRef = useRef<HTMLSpanElement>(null);
|
||||
const slaRef = useRef<HTMLSpanElement>(null);
|
||||
const sparkRef = useRef<HTMLCanvasElement>(null);
|
||||
/** Set by the sim so the effect can push insight cards into React state. */
|
||||
const pushInsightRef = useRef<() => void>(() => {});
|
||||
|
||||
const [insights, setInsights] = useState<Insight[]>([]);
|
||||
|
||||
// Insight cards are the one piece of React state here — they change a few
|
||||
// times a minute, not 60 times a second.
|
||||
useEffect(() => {
|
||||
let nextId = 1;
|
||||
pushInsightRef.current = () => {
|
||||
const pick = INSIGHTS[Math.floor(Math.random() * INSIGHTS.length)];
|
||||
const card: Insight = {
|
||||
id: nextId++,
|
||||
head: pick.head,
|
||||
value: pick.fn(),
|
||||
good: !!pick.good,
|
||||
};
|
||||
setInsights((prev) => [card, ...prev].slice(0, 3));
|
||||
window.setTimeout(() => {
|
||||
setInsights((prev) => prev.filter((c) => c.id !== card.id));
|
||||
}, 4600);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const stage = stageRef.current;
|
||||
if (!canvas || !stage) return;
|
||||
const ctx = canvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
/** Normalised unit — every radius below is a fraction of the short edge,
|
||||
* so the scene composes identically at 580px and at 300px. */
|
||||
let u = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
let raf = 0;
|
||||
let running = false;
|
||||
let last = 0;
|
||||
|
||||
/* ---- transport -------------------------------------------------------
|
||||
One slot per feed. `pt[i]` is that feed's packet progress along its
|
||||
path (0..1), or -1 while the feed is waiting. A slot cannot hold two
|
||||
packets, so "one active packet per source, no duplicates" is a property
|
||||
of the data structure rather than a rule a spawner has to respect. */
|
||||
const pt: number[] = SOURCES.map(() => -1);
|
||||
/** Countdown to the next send. Staggered by a fixed offset, not random. */
|
||||
const wait: number[] = SOURCES.map((_, i) => 200 + i * 260);
|
||||
|
||||
/** Core pulse: 1 on absorption, back to 0 in ~300ms. */
|
||||
let pulse = 0;
|
||||
/** The single absorption ripple: -1 idle, else 0..1. One at a time. */
|
||||
let ripple = -1;
|
||||
|
||||
/* Three segmented rings. Each drifts very slowly at its own rate, and on
|
||||
absorption exactly one ring — taken in turn — is stepped a further 15°,
|
||||
eased in rather than snapped. */
|
||||
const RINGS = [
|
||||
{ r: 1.2, seg: 3, gap: 0.66, a: 0.3, w: 1.1, sp: 0.000055 },
|
||||
{ r: 1.4, seg: 4, gap: 0.82, a: 0.18, w: 1, sp: -0.000034 },
|
||||
{ r: 1.6, seg: 2, gap: 1.0, a: 0.12, w: 1, sp: 0.000021 },
|
||||
];
|
||||
const ringPhase = [0.4, 2.1, 4.3];
|
||||
const ringStep = [0, 0, 0];
|
||||
const ringStepTo = [0, 0, 0];
|
||||
let nextRing = 0;
|
||||
|
||||
/* ---- HUD readouts (data, not motion) ---- */
|
||||
let analysed = 1284392;
|
||||
let eventsPerSec = 1180;
|
||||
let confidence = 97.4;
|
||||
let sla = 99.2;
|
||||
let hudDue = 0;
|
||||
let nextInsight = 2600;
|
||||
|
||||
// The DoorMile symbol — icon only, the same asset the solutions engine
|
||||
// core uses. Drawn on canvas so packets can vanish behind it.
|
||||
const mark = new window.Image();
|
||||
let markReady = false;
|
||||
mark.onload = () => {
|
||||
markReady = true;
|
||||
if (reduce) draw();
|
||||
};
|
||||
mark.src = "/images/doormile-mark.png";
|
||||
// Seeded with noise so the throughput trace never opens on a flat plateau.
|
||||
const spark: number[] = Array.from({ length: 28 }, () => rand(0.3, 0.7));
|
||||
|
||||
/* ---- geometry ---- */
|
||||
const ringR = () => u * RING;
|
||||
const coreR = () => u * 0.052;
|
||||
|
||||
function srcPos(i: number) {
|
||||
// Ring starts at the top and runs clockwise; the six labels in the HUD
|
||||
// use the same formula so canvas and DOM stay locked together.
|
||||
const a = -Math.PI / 2 + (i / SOURCES.length) * Math.PI * 2;
|
||||
return { x: cx + Math.cos(a) * ringR(), y: cy + Math.sin(a) * ringR(), a };
|
||||
}
|
||||
|
||||
/** Fixed quadratic bezier from feed `i` to the core. The control point is
|
||||
* derived from that feed's constant `bend`, so the route never varies. */
|
||||
function packetPos(i: number, t: number) {
|
||||
const s = srcPos(i);
|
||||
const mx = (s.x + cx) / 2;
|
||||
const my = (s.y + cy) / 2;
|
||||
const nx = -(cy - s.y);
|
||||
const ny = cx - s.x;
|
||||
const nl = Math.hypot(nx, ny) || 1;
|
||||
const k = SOURCES[i].bend * u * 0.34;
|
||||
const bx = mx + (nx / nl) * k;
|
||||
const by = my + (ny / nl) * k;
|
||||
const it = 1 - t;
|
||||
return {
|
||||
x: it * it * s.x + 2 * it * t * bx + t * t * cx,
|
||||
y: it * it * s.y + 2 * it * t * by + t * t * cy,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- painting ------------------------------------------------------ */
|
||||
|
||||
/** Background: the dark grid and one fixed pool of light. Both static. */
|
||||
function drawField() {
|
||||
const gap = Math.max(18, u * 0.052);
|
||||
ctx!.fillStyle = "rgba(255,255,255,0.045)";
|
||||
for (let x = gap / 2; x < w; x += gap) {
|
||||
for (let y = gap / 2; y < h; y += gap) {
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(x, y, 1, 0, Math.PI * 2);
|
||||
ctx!.fill();
|
||||
}
|
||||
}
|
||||
|
||||
const glow = ctx!.createRadialGradient(cx, cy, 0, cx, cy, u * 0.5);
|
||||
glow.addColorStop(0, "rgba(226,53,74,0.09)");
|
||||
glow.addColorStop(0.5, "rgba(192,18,39,0.035)");
|
||||
glow.addColorStop(1, "rgba(226,53,74,0)");
|
||||
ctx!.fillStyle = glow;
|
||||
ctx!.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
/** Input nodes and their route guide. Entirely static: a feed's activity
|
||||
* is told by its packet, so the node itself never needs to flash. */
|
||||
function drawRing() {
|
||||
ctx!.strokeStyle = "rgba(255,255,255,0.07)";
|
||||
ctx!.lineWidth = 1;
|
||||
ctx!.setLineDash([3, 7]);
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(cx, cy, ringR(), 0, Math.PI * 2);
|
||||
ctx!.stroke();
|
||||
ctx!.setLineDash([]);
|
||||
|
||||
for (let i = 0; i < SOURCES.length; i++) {
|
||||
const s = srcPos(i);
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(s.x, s.y, u * 0.019, 0, Math.PI * 2);
|
||||
ctx!.strokeStyle = "rgba(255,59,59,0.26)";
|
||||
ctx!.lineWidth = 1;
|
||||
ctx!.stroke();
|
||||
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(s.x, s.y, Math.max(2, u * 0.0062), 0, Math.PI * 2);
|
||||
ctx!.fillStyle = "rgba(255,130,130,0.7)";
|
||||
ctx!.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/** The six data packets. Nothing else on this canvas travels. */
|
||||
function drawPackets() {
|
||||
for (let i = 0; i < SOURCES.length; i++) {
|
||||
const t = pt[i];
|
||||
if (t < 0) continue;
|
||||
const head = packetPos(i, t);
|
||||
// A short trail behind the head: a bare dot reads as a floating
|
||||
// particle, a trail reads as something traveling a route.
|
||||
const tail = packetPos(i, Math.max(0, t - 0.075));
|
||||
const a = Math.min(1, t * 5) * (0.5 + t * 0.5) * Math.min(1, (1 - t) / 0.07);
|
||||
const tint = `255,${Math.round(130 + t * 100)},${Math.round(135 + t * 100)}`;
|
||||
|
||||
const tg = ctx!.createLinearGradient(tail.x, tail.y, head.x, head.y);
|
||||
tg.addColorStop(0, `rgba(${tint},0)`);
|
||||
tg.addColorStop(1, `rgba(${tint},${(a * 0.5).toFixed(3)})`);
|
||||
ctx!.beginPath();
|
||||
ctx!.moveTo(tail.x, tail.y);
|
||||
ctx!.lineTo(head.x, head.y);
|
||||
ctx!.strokeStyle = tg;
|
||||
ctx!.lineWidth = 1.4;
|
||||
ctx!.lineCap = "round";
|
||||
ctx!.stroke();
|
||||
|
||||
ctx!.save();
|
||||
ctx!.shadowColor = "rgba(255,59,59,0.8)";
|
||||
ctx!.shadowBlur = 5;
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(head.x, head.y, 2.3, 0, Math.PI * 2);
|
||||
ctx!.fillStyle = `rgba(${tint},${a.toFixed(3)})`;
|
||||
ctx!.fill();
|
||||
ctx!.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- the core: a glass processor with the brand symbol at its heart.
|
||||
Painted in two passes so packets can fly over the glass and disappear
|
||||
*into* the symbol rather than stopping at its rim. ---- */
|
||||
|
||||
/** Radius of the glass housing. */
|
||||
const glassR = () => coreR() * 2.15;
|
||||
|
||||
function drawCoreBack() {
|
||||
const R = glassR();
|
||||
ctx!.save();
|
||||
ctx!.translate(cx, cy);
|
||||
|
||||
// Seat the assembly into the field — gradient only, no drawn rim here.
|
||||
const pg = ctx!.createRadialGradient(0, 0, R * 0.7, 0, 0, R * 2.4);
|
||||
pg.addColorStop(0, "rgba(7,7,16,0)");
|
||||
pg.addColorStop(0.45, "rgba(7,7,16,0.5)");
|
||||
pg.addColorStop(1, "rgba(7,7,16,0)");
|
||||
ctx!.fillStyle = pg;
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(0, 0, R * 2.4, 0, Math.PI * 2);
|
||||
ctx!.fill();
|
||||
|
||||
// The absorption ripple: one, small, inside the ring stack.
|
||||
if (ripple >= 0) {
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(0, 0, R * (1.02 + ripple * 0.55), 0, Math.PI * 2);
|
||||
ctx!.strokeStyle = `rgba(255,140,140,${((1 - ripple) * 0.3).toFixed(3)})`;
|
||||
ctx!.lineWidth = 1;
|
||||
ctx!.stroke();
|
||||
}
|
||||
|
||||
// Three segmented rings. Nothing rides them and nothing highlights
|
||||
// them — the rotation alone is the computation.
|
||||
for (let i = 0; i < RINGS.length; i++) {
|
||||
const g = RINGS[i];
|
||||
const span = (Math.PI * 2) / g.seg;
|
||||
const base = ringPhase[i] + ringStep[i];
|
||||
for (let j = 0; j < g.seg; j++) {
|
||||
const s0 = base + j * span;
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(0, 0, R * g.r, s0, s0 + span - g.gap);
|
||||
ctx!.strokeStyle = `rgba(255,90,90,${g.a})`;
|
||||
ctx!.lineWidth = g.w;
|
||||
ctx!.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/* --- glass housing --- */
|
||||
const body = ctx!.createLinearGradient(0, -R, 0, R);
|
||||
body.addColorStop(0, "rgba(255,255,255,0.075)");
|
||||
body.addColorStop(0.5, "rgba(255,255,255,0.022)");
|
||||
body.addColorStop(1, "rgba(255,70,70,0.05)");
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(0, 0, R, 0, Math.PI * 2);
|
||||
ctx!.fillStyle = body;
|
||||
ctx!.fill();
|
||||
|
||||
// Light held inside the glass, lifting only with the core pulse.
|
||||
const inner = ctx!.createRadialGradient(0, 0, 0, 0, 0, R);
|
||||
inner.addColorStop(0, `rgba(255,70,70,${(0.12 + pulse * 0.3).toFixed(3)})`);
|
||||
inner.addColorStop(0.6, "rgba(226,53,74,0.05)");
|
||||
inner.addColorStop(1, "rgba(226,53,74,0)");
|
||||
ctx!.fillStyle = inner;
|
||||
ctx!.fill();
|
||||
|
||||
// Rim: bright where the light falls, warm on the far side.
|
||||
const rim = ctx!.createLinearGradient(-R * 0.75, -R * 0.75, R * 0.75, R * 0.75);
|
||||
rim.addColorStop(0, "rgba(255,255,255,0.55)");
|
||||
rim.addColorStop(0.4, "rgba(255,255,255,0.1)");
|
||||
rim.addColorStop(1, `rgba(255,90,90,${(0.28 + pulse * 0.3).toFixed(3)})`);
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(0, 0, R, 0, Math.PI * 2);
|
||||
ctx!.strokeStyle = rim;
|
||||
ctx!.lineWidth = 1.2;
|
||||
ctx!.stroke();
|
||||
|
||||
// Specular sweep on the upper-left shoulder — the tell that it is glass.
|
||||
// Faded at both ends so it reads as light on a curve, not a drawn arc.
|
||||
const spec = ctx!.createLinearGradient(-R, -R * 0.4, 0, -R);
|
||||
spec.addColorStop(0, "rgba(255,255,255,0)");
|
||||
spec.addColorStop(0.5, "rgba(255,255,255,0.42)");
|
||||
spec.addColorStop(1, "rgba(255,255,255,0)");
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(0, 0, R - 2.2, Math.PI * 1.05, Math.PI * 1.55);
|
||||
ctx!.strokeStyle = spec;
|
||||
ctx!.lineWidth = 1.8;
|
||||
ctx!.lineCap = "round";
|
||||
ctx!.stroke();
|
||||
|
||||
ctx!.restore();
|
||||
}
|
||||
|
||||
function drawCoreFront() {
|
||||
if (!markReady) return;
|
||||
const R = glassR();
|
||||
ctx!.save();
|
||||
ctx!.translate(cx, cy);
|
||||
|
||||
// The symbol itself never moves: only its glow answers an absorption.
|
||||
const mw = R * 1.02;
|
||||
const mh = mw * (mark.height / mark.width);
|
||||
|
||||
ctx!.shadowColor = `rgba(255,90,90,${(0.45 + pulse * 0.45).toFixed(3)})`;
|
||||
ctx!.shadowBlur = R * (0.15 + pulse * 0.38);
|
||||
ctx!.globalAlpha = 0.97;
|
||||
ctx!.drawImage(mark, -mw / 2, -mh / 2, mw, mh);
|
||||
|
||||
if (pulse > 0.02) {
|
||||
ctx!.globalAlpha = Math.min(0.5, pulse * 0.5);
|
||||
ctx!.shadowColor = "rgba(255,255,255,0.95)";
|
||||
ctx!.shadowBlur = R * (0.2 + pulse * 0.45);
|
||||
ctx!.drawImage(mark, -mw / 2, -mh / 2, mw, mh);
|
||||
}
|
||||
|
||||
ctx!.restore();
|
||||
}
|
||||
|
||||
function drawSpark() {
|
||||
const sc = sparkRef.current;
|
||||
if (!sc) return;
|
||||
const sctx = sc.getContext("2d");
|
||||
if (!sctx) return;
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
const rect = sc.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
if (sc.width !== Math.round(rect.width * dpr)) {
|
||||
sc.width = Math.round(rect.width * dpr);
|
||||
sc.height = Math.round(rect.height * dpr);
|
||||
}
|
||||
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
sctx.clearRect(0, 0, rect.width, rect.height);
|
||||
|
||||
// Normalise against the buffer's own range so the trace always uses the
|
||||
// full height — a fixed scale reads as a dead flat line.
|
||||
let lo = Infinity;
|
||||
let hi = -Infinity;
|
||||
for (const v of spark) {
|
||||
if (v < lo) lo = v;
|
||||
if (v > hi) hi = v;
|
||||
}
|
||||
const span = Math.max(0.06, hi - lo);
|
||||
const px = (i: number) => (i / (spark.length - 1)) * rect.width;
|
||||
const py = (v: number) => rect.height - 2 - ((v - lo) / span) * (rect.height - 4);
|
||||
|
||||
sctx.beginPath();
|
||||
sctx.moveTo(px(0), py(spark[0]));
|
||||
for (let i = 1; i < spark.length; i++) sctx.lineTo(px(i), py(spark[i]));
|
||||
|
||||
// Fill under the trace, then re-stroke the line on top.
|
||||
sctx.save();
|
||||
sctx.lineTo(rect.width, rect.height);
|
||||
sctx.lineTo(0, rect.height);
|
||||
sctx.closePath();
|
||||
const fg = sctx.createLinearGradient(0, 0, 0, rect.height);
|
||||
fg.addColorStop(0, "rgba(255,59,59,0.28)");
|
||||
fg.addColorStop(1, "rgba(255,59,59,0)");
|
||||
sctx.fillStyle = fg;
|
||||
sctx.fill();
|
||||
sctx.restore();
|
||||
|
||||
sctx.beginPath();
|
||||
sctx.moveTo(px(0), py(spark[0]));
|
||||
for (let i = 1; i < spark.length; i++) sctx.lineTo(px(i), py(spark[i]));
|
||||
sctx.strokeStyle = "rgba(255,90,90,0.95)";
|
||||
sctx.lineWidth = 1.2;
|
||||
sctx.lineJoin = "round";
|
||||
sctx.stroke();
|
||||
|
||||
// Live head dot.
|
||||
sctx.beginPath();
|
||||
sctx.arc(px(spark.length - 1), py(spark[spark.length - 1]), 1.8, 0, Math.PI * 2);
|
||||
sctx.fillStyle = "#ff6b6b";
|
||||
sctx.fill();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx!.clearRect(0, 0, w, h);
|
||||
drawField();
|
||||
drawRing();
|
||||
drawCoreBack();
|
||||
drawPackets();
|
||||
drawCoreFront();
|
||||
drawSpark();
|
||||
}
|
||||
|
||||
/* ---- integration ---------------------------------------------------- */
|
||||
|
||||
/** A packet reached the centre: one ripple, one ring stepped 15°, the
|
||||
* logo glow lifts, and the feed starts its fixed wait. Nothing else. */
|
||||
function absorb(i: number) {
|
||||
pulse = 1;
|
||||
ripple = 0;
|
||||
ringStepTo[nextRing % RINGS.length] += Math.PI / 12;
|
||||
nextRing++;
|
||||
pt[i] = -1;
|
||||
wait[i] = SOURCES[i].dwell;
|
||||
// The counter measures exactly this: packets the core has taken in.
|
||||
analysed += 3;
|
||||
}
|
||||
|
||||
function step(dt: number) {
|
||||
const k = Math.min(3, dt / 16.67);
|
||||
|
||||
// Transport: each feed is either carrying or waiting. Never both.
|
||||
for (let i = 0; i < SOURCES.length; i++) {
|
||||
if (pt[i] < 0) {
|
||||
wait[i] -= dt;
|
||||
if (wait[i] <= 0) pt[i] = 0;
|
||||
} else {
|
||||
// Accelerates as it falls into the core.
|
||||
pt[i] += ((SOURCES[i].speed * (0.55 + pt[i] * 0.9)) / 60) * k;
|
||||
if (pt[i] >= 1) absorb(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Core pulse settles in ~300ms.
|
||||
pulse = Math.max(0, pulse - 0.055 * k);
|
||||
|
||||
if (ripple >= 0) {
|
||||
ripple += 0.04 * k;
|
||||
if (ripple >= 1) ripple = -1;
|
||||
}
|
||||
|
||||
// Rings: constant slow drift, plus the eased 15° steps.
|
||||
for (let i = 0; i < RINGS.length; i++) {
|
||||
ringPhase[i] += dt * RINGS[i].sp;
|
||||
ringStep[i] += (ringStepTo[i] - ringStep[i]) * Math.min(1, dt / 380);
|
||||
}
|
||||
|
||||
/* ---- HUD: readouts and recommendation cards. No canvas motion. ---- */
|
||||
nextInsight -= dt;
|
||||
if (nextInsight <= 0) {
|
||||
nextInsight = rand(2600, 4200);
|
||||
pushInsightRef.current();
|
||||
confidence = Math.min(99.1, Math.max(95.6, confidence + rand(-0.35, 0.4)));
|
||||
sla = Math.min(99.8, Math.max(98.4, sla + rand(-0.12, 0.14)));
|
||||
}
|
||||
|
||||
hudDue -= dt;
|
||||
if (hudDue <= 0) {
|
||||
hudDue = 140;
|
||||
eventsPerSec = Math.min(2400, Math.max(620, eventsPerSec + rand(-70, 72)));
|
||||
spark.push(Math.min(1, Math.max(0, (eventsPerSec - 600) / 1800)));
|
||||
spark.shift();
|
||||
if (countRef.current) countRef.current.textContent = analysed.toLocaleString("en-US");
|
||||
if (rateRef.current) rateRef.current.textContent = (eventsPerSec / 1000).toFixed(2) + "k";
|
||||
if (confRef.current) confRef.current.textContent = confidence.toFixed(1) + "%";
|
||||
if (slaRef.current) slaRef.current.textContent = sla.toFixed(1) + "%";
|
||||
}
|
||||
}
|
||||
|
||||
function frame(time: number) {
|
||||
const dt = last ? Math.min(64, time - last) : 16.67;
|
||||
last = time;
|
||||
step(dt);
|
||||
draw();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (running || reduce) return;
|
||||
running = true;
|
||||
last = 0;
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
function stop() {
|
||||
running = false;
|
||||
cancelAnimationFrame(raf);
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const rect = stage!.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
w = rect.width;
|
||||
h = rect.height;
|
||||
u = Math.min(w, h);
|
||||
cx = w / 2;
|
||||
cy = h / 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 (reduce) draw();
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(stage);
|
||||
resize();
|
||||
|
||||
if (reduce) {
|
||||
// One composed static frame: every feed frozen mid-transit, steady
|
||||
// numbers. Each packet sits at a different point on its own route.
|
||||
for (let i = 0; i < SOURCES.length; i++) {
|
||||
pt[i] = 0.28 + i * 0.1;
|
||||
}
|
||||
draw();
|
||||
if (countRef.current) countRef.current.textContent = analysed.toLocaleString("en-US");
|
||||
if (rateRef.current) rateRef.current.textContent = "1.18k";
|
||||
if (confRef.current) confRef.current.textContent = confidence.toFixed(1) + "%";
|
||||
if (slaRef.current) slaRef.current.textContent = sla.toFixed(1) + "%";
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
let visible = true;
|
||||
const sync = () => {
|
||||
if (visible && !document.hidden) start();
|
||||
else stop();
|
||||
};
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
visible = entries.some((e) => e.isIntersecting);
|
||||
sync();
|
||||
},
|
||||
{ rootMargin: "120px 0px" },
|
||||
);
|
||||
io.observe(canvas);
|
||||
document.addEventListener("visibilitychange", sync);
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
ro.disconnect();
|
||||
io.disconnect();
|
||||
document.removeEventListener("visibilitychange", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`wne-wrap ${className}`}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "100%",
|
||||
background: "#070710",
|
||||
borderRadius: "24px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
.wne-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.wne-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wne-hud {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ---- source labels, positioned to match the canvas ring ---- */
|
||||
.wne-src {
|
||||
position: absolute;
|
||||
font-size: clamp(7.5px, 1.5cqw, 9.5px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.7px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- live pill + counter ---- */
|
||||
.wne-live {
|
||||
position: absolute;
|
||||
top: 5.5%;
|
||||
left: 5.5%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(7, 7, 16, 0.72);
|
||||
border: 1px solid rgba(255, 59, 59, 0.28);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.wne-live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #ff3b3b;
|
||||
box-shadow: 0 0 10px #ff3b3b;
|
||||
animation: wne-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wne-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.wne-live-text {
|
||||
font-size: clamp(8px, 1.5cqw, 10px);
|
||||
font-weight: 800;
|
||||
letter-spacing: 1.4px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.wne-count {
|
||||
position: absolute;
|
||||
top: calc(5.5% + 40px);
|
||||
left: 5.5%;
|
||||
font-size: clamp(8px, 1.5cqw, 10.5px);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.wne-count b {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- insight stack ---- */
|
||||
.wne-insights {
|
||||
position: absolute;
|
||||
top: 5.5%;
|
||||
right: 5.5%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-end;
|
||||
max-width: 62%;
|
||||
}
|
||||
|
||||
.wne-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 11px;
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 8, 12, 0.82);
|
||||
border: 1px solid rgba(255, 59, 59, 0.22);
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: wne-card-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes wne-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(14px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wne-card-head {
|
||||
font-size: clamp(8px, 1.5cqw, 10.5px);
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.wne-card-value {
|
||||
font-size: clamp(8px, 1.5cqw, 10.5px);
|
||||
font-weight: 800;
|
||||
color: #ff3b3b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.wne-card-value.good {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* ---- bottom metric strip ---- */
|
||||
.wne-metrics {
|
||||
position: absolute;
|
||||
left: 5.5%;
|
||||
right: 5.5%;
|
||||
bottom: 5.5%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(7, 7, 16, 0.68);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.wne-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wne-metric-label {
|
||||
font-size: clamp(7px, 1.35cqw, 9px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.9px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wne-metric-value {
|
||||
font-size: clamp(11px, 2.3cqw, 15px);
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wne-metric-value small {
|
||||
font-size: 0.62em;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.wne-spark {
|
||||
width: clamp(46px, 11cqw, 76px);
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wne-sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.wne-src {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Narrow panel: the ring labels and the sparkline are the first things
|
||||
to go, so the three readouts keep room to breathe. */
|
||||
@container (max-width: 430px) {
|
||||
.wne-src {
|
||||
display: none;
|
||||
}
|
||||
.wne-spark {
|
||||
display: none;
|
||||
}
|
||||
.wne-metrics {
|
||||
padding: 9px 11px;
|
||||
gap: 6px;
|
||||
}
|
||||
.wne-metric-label {
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.wne-insights {
|
||||
max-width: 74%;
|
||||
}
|
||||
.wne-live {
|
||||
padding: 5px 9px;
|
||||
gap: 6px;
|
||||
}
|
||||
.wne-live-text {
|
||||
letter-spacing: 0.9px;
|
||||
}
|
||||
.wne-count {
|
||||
top: calc(5.5% + 32px);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div ref={stageRef} className="wne-stage" style={{ containerType: "size" }}>
|
||||
<canvas ref={canvasRef} className="wne-canvas" aria-hidden="true" />
|
||||
|
||||
<div className="wne-hud" aria-hidden="true">
|
||||
{SOURCES.map((s, i) => {
|
||||
// Same polar formula the canvas uses. Side nodes set their label
|
||||
// outward from an inner edge (so long labels grow away from the
|
||||
// card wall); top/bottom nodes hang theirs above, which also keeps
|
||||
// the 6 o'clock label clear of the metric strip.
|
||||
const a = -Math.PI / 2 + (i / SOURCES.length) * Math.PI * 2;
|
||||
const x = 50 + Math.cos(a) * RING * 100;
|
||||
const y = 50 + Math.sin(a) * RING * 100;
|
||||
const side = Math.abs(Math.cos(a)) > 0.3;
|
||||
const style: React.CSSProperties = side
|
||||
? Math.cos(a) > 0
|
||||
? { left: `${x + 4.4}%`, top: `${y}%`, transform: "translateY(-50%)" }
|
||||
: { right: `${100 - x + 4.4}%`, top: `${y}%`, transform: "translateY(-50%)" }
|
||||
: { left: `${x}%`, top: `${y - 5.6}%`, transform: "translate(-50%, -50%)" };
|
||||
return (
|
||||
<span key={s.label} className="wne-src" style={style}>
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="wne-live">
|
||||
<span className="wne-live-dot" />
|
||||
<span className="wne-live-text">MILETRUTH ENGINE LIVE</span>
|
||||
</div>
|
||||
|
||||
<div className="wne-count">
|
||||
<b ref={countRef}>1,284,392</b> shipments analysed
|
||||
</div>
|
||||
|
||||
<div className="wne-insights">
|
||||
{insights.map((c) => (
|
||||
<div key={c.id} className="wne-card">
|
||||
<span className="wne-card-head">{c.head}</span>
|
||||
<span className={`wne-card-value${c.good ? " good" : ""}`}>{c.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="wne-metrics">
|
||||
<div className="wne-metric">
|
||||
<span className="wne-metric-label">Throughput</span>
|
||||
<span className="wne-metric-value">
|
||||
<span ref={rateRef}>1.18k</span>
|
||||
<small>ev/s</small>
|
||||
</span>
|
||||
</div>
|
||||
<canvas ref={sparkRef} className="wne-spark" />
|
||||
<div className="wne-metric">
|
||||
<span className="wne-metric-label">Confidence</span>
|
||||
<span className="wne-metric-value" ref={confRef}>
|
||||
97.4%
|
||||
</span>
|
||||
</div>
|
||||
<div className="wne-metric">
|
||||
<span className="wne-metric-label">SLA Protected</span>
|
||||
<span className="wne-metric-value" ref={slaRef}>
|
||||
99.2%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="wne-sr">
|
||||
MileTruth AI ingesting live logistics telemetry from orders, fleet, routes, traffic,
|
||||
weather and proof-of-delivery feeds, and emitting routing recommendations in real time.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -204,7 +204,7 @@ export default function WingsSpeedAdvantage() {
|
||||
Max <CountUp end={3} duration={1.4} triggerOnce />
|
||||
</div>
|
||||
<div className="wsa-unit">Hours, Door-to-Door</div>
|
||||
<p className="wsa-panel-sub">Same-day delivery between major regional capitals.</p>
|
||||
<p className="wsa-panel-sub">Same-day logistics between major regional capitals.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ const MAX_WAVE_AMP_FACTOR = 1 + 0.28 + 0.15;
|
||||
const FEATURES = [
|
||||
{
|
||||
title: "Same-Day Air Logistics",
|
||||
desc: "Max 3-hour door-to-door delivery between India's major regional capitals.",
|
||||
desc: "Max 3-hour door-to-door transit between India's major regional capitals.",
|
||||
icon: (
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
@@ -70,7 +70,7 @@ const FEATURES = [
|
||||
},
|
||||
{
|
||||
title: "EV Last Mile",
|
||||
desc: "Zero-emission electric vehicles complete every first- and final-mile delivery.",
|
||||
desc: "Zero-emission electric vehicles complete every first- and final-mile leg.",
|
||||
icon: (
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 13h13l3 4h2v3H3z" />
|
||||
@@ -472,6 +472,7 @@ export default function WingsWhyGrid() {
|
||||
},
|
||||
});
|
||||
playedTimeline = tl;
|
||||
st = tl.scrollTrigger ?? null;
|
||||
|
||||
// The energy pulse's own journey — calm and deliberate, ~4.8-5.5s so
|
||||
// it glides rather than rushes from the first card to the last.
|
||||
|
||||
@@ -6,7 +6,7 @@ import IndustryWorldMap from "./IndustryWorldMap";
|
||||
const WS_STATS = [
|
||||
{ value: "500", plus: "+", label: "Women Partners" },
|
||||
{ value: "35", plus: "+", label: "Cities" },
|
||||
{ value: "10K", plus: "+", label: "Deliveries" },
|
||||
{ value: "10K", plus: "+", label: "Shipments" },
|
||||
];
|
||||
|
||||
const WS_CARDS = [
|
||||
|
||||
@@ -54,7 +54,7 @@ const FEATURES: EVFeature[] = [
|
||||
</>,
|
||||
),
|
||||
title: "Distance Reduction",
|
||||
desc: "Same volume delivered with a leaner, better-used fleet.",
|
||||
desc: "Same volume moved with a leaner, better-used fleet.",
|
||||
},
|
||||
{
|
||||
icon: ico(
|
||||
@@ -74,7 +74,7 @@ const FEATURES: EVFeature[] = [
|
||||
</>,
|
||||
),
|
||||
title: "SLA Performance",
|
||||
desc: "Real-time correction keeps deliveries on time.",
|
||||
desc: "Real-time correction keeps shipments on time.",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -90,13 +90,13 @@ export default function Workflow2() {
|
||||
ariaLabel="Workflow 2 — Innovation"
|
||||
gapBottom
|
||||
bannerImage="/images/mid-mile-approach.webp"
|
||||
cardTitle="CHOOSE THE BEST PLAN"
|
||||
cardSubtitle="Analyze thousands of route possibilities and automatically select the most efficient delivery strategy."
|
||||
cardTitle="BEST PLAN"
|
||||
cardSubtitle="Analyze thousands of route possibilities and automatically select the most efficient routing strategy."
|
||||
eyebrow="/ Innovation /"
|
||||
titleLead="MANY STRATEGIES. "
|
||||
titleAccent="ONE BEST PLAN."
|
||||
image="/videos/workflow-2-routing.mp4"
|
||||
imageAlt="AI route-planning engine selecting the best delivery plan"
|
||||
imageAlt="AI route-planning engine selecting the best route plan"
|
||||
metrics={METRICS}
|
||||
features={FEATURES}
|
||||
cardsHeading="AI Decision Engine"
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function Workflow3() {
|
||||
return (
|
||||
<section
|
||||
className="dm-wf3"
|
||||
aria-label="Workflow 3 — Happier Riders. Higher Fulfillment."
|
||||
aria-label="Workflow 3 — Happier Fleet Drivers. Higher Fulfillment."
|
||||
>
|
||||
<StrategySection />
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const METRICS: EVStat[] = [
|
||||
/* Distinct copy for the bottom capability bar (not a repeat of the KPI row —
|
||||
* Workflow 4's brief specifies separate content for each). */
|
||||
const BAR_STATS: EVStat[] = [
|
||||
{ text: "Multi-Rider", value: 0, suffix: "", label: "Dynamic Routing" },
|
||||
{ text: "Multi-Driver", value: 0, suffix: "", label: "Dynamic Routing" },
|
||||
{ text: "Optimized", value: 0, suffix: "", label: "Time Windows" },
|
||||
{ text: "Real-Time", value: 0, suffix: "", label: "Fleet Balancing" },
|
||||
{ text: "Cost + Distance + SLA", value: 0, suffix: "", label: "Objectives" },
|
||||
@@ -62,7 +62,7 @@ const FEATURES: EVFeature[] = [
|
||||
</>,
|
||||
),
|
||||
title: "Encode the Problem",
|
||||
desc: "Rider assignments, delivery sequences, capacity, traffic, service time, and SLA constraints become part of one mathematical optimization model.",
|
||||
desc: "Fleet driver assignments, stop sequences, capacity, traffic, service time, and SLA constraints become part of one mathematical optimization model.",
|
||||
},
|
||||
{
|
||||
icon: ico(<path d="m8 3 4 8 5-5 5 15H2L8 3z" />),
|
||||
@@ -103,8 +103,9 @@ export default function Workflow4() {
|
||||
<EVSection
|
||||
ariaLabel="Workflow 4 — Quantum Intelligence"
|
||||
gapBottom
|
||||
bannerImage="/images/workflow4.png"
|
||||
image="/videos/Doormile-quantum-video3.mp4"
|
||||
cardTitle="BUILDING QUANTUM INTELLIGENCE"
|
||||
cardTitle="QUANTUM INTELLIGENCE"
|
||||
cardSubtitle="Exploring a hybrid optimization layer that treats routing as an energy landscape, not a search problem."
|
||||
eyebrow="/ Quantum Intelligence /"
|
||||
titleLead="FROM ROUTE SEARCH. "
|
||||
|
||||
@@ -341,7 +341,7 @@ function RiderAvatar({ rider, register, awake, focused }: { rider: typeof RIDERS
|
||||
<Html center distanceFactor={9} position={[0, 1.5, 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>
|
||||
<span className="dm-st3d-chip__txt"><b>Driver {rider.id}</b>{rider.veh}</span>
|
||||
</div>
|
||||
</Html>
|
||||
)}
|
||||
@@ -497,7 +497,7 @@ const VALIDATIONS = [
|
||||
];
|
||||
|
||||
// Node labels along the delivery route (Dispatch Hub → Route Nodes → Delivery).
|
||||
const DEST_LABELS: (string | null)[] = ["Route Node A", null, "Route Node B", null, "Route Node C", "📦 Delivery"];
|
||||
const DEST_LABELS: (string | null)[] = ["Route Node A", null, "Route Node B", null, "Route Node C", "📦 Destination"];
|
||||
|
||||
/** Expanding ring at the delivery node — fires only once the truck arrives (local t > 0.85). */
|
||||
function DeliveryPulse({ pos, i, progress }: { pos: [number, number, number]; i: number; progress: React.RefObject<number> }) {
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function StrategySection({ connected = false }: { connected?: boo
|
||||
<section
|
||||
ref={containerRef}
|
||||
className={`dm-st is-${pinState}${connected ? " is-connected" : ""}`}
|
||||
aria-label="Strategy — Happier Riders. Higher Fulfillment."
|
||||
aria-label="Strategy — Happier Fleet Drivers. Higher Fulfillment."
|
||||
>
|
||||
<div className="dm-st-sticky">
|
||||
<div className="dm-st-card">
|
||||
@@ -235,11 +235,11 @@ export default function StrategySection({ connected = false }: { connected?: boo
|
||||
|
||||
{/* STAGE 01 — INPUT (green) */}
|
||||
<StageCard i={0} scroll={scroll} side="left" active={active}>
|
||||
<h3 className="dm-st-pillar__title">Orders & riders enter the system</h3>
|
||||
<h3 className="dm-st-pillar__title">Orders & fleet drivers enter the system</h3>
|
||||
<p className="dm-st-anchor__lead">Orders are uploaded and matched against the available fleet, ready for assignment.</p>
|
||||
<div className="dm-st-anchor__chips">
|
||||
<span className="dm-st-anchor__chip">59 Orders</span>
|
||||
<span className="dm-st-anchor__chip">4 Riders</span>
|
||||
<span className="dm-st-anchor__chip">4 Drivers</span>
|
||||
<span className="dm-st-anchor__chip">Fleet ready</span>
|
||||
</div>
|
||||
</StageCard>
|
||||
@@ -258,7 +258,7 @@ export default function StrategySection({ connected = false }: { connected?: boo
|
||||
{/* STAGE 03 — SMART OPTIMIZATION (blue) */}
|
||||
<StageCard i={2} scroll={scroll} side="left" active={active}>
|
||||
<h3 className="dm-st-pillar__title">Routes optimized & validated</h3>
|
||||
<p className="dm-st-anchor__lead">Every route is solved for distance, then checked against battery range and delivery SLAs.</p>
|
||||
<p className="dm-st-anchor__lead">Every route is solved for distance, then checked against battery range and service SLAs.</p>
|
||||
<div className="dm-st-anchor__chips">
|
||||
<span className="dm-st-anchor__chip">Optimize</span>
|
||||
<span className="dm-st-anchor__chip">Battery</span>
|
||||
@@ -279,8 +279,8 @@ export default function StrategySection({ connected = false }: { connected?: boo
|
||||
|
||||
{/* STAGE 05 — STRATEGY COMPARISON (red, hero) */}
|
||||
<StageCard i={4} scroll={scroll} side="right" active={active}>
|
||||
<h3 className="dm-st-pillar__title">Happier riders. Higher fulfillment.</h3>
|
||||
<p className="dm-st-anchor__lead">EV Aware wins — the best fulfillment with feasible, battery-safe routes for every rider.</p>
|
||||
<h3 className="dm-st-pillar__title">Happier fleet drivers. Higher fulfillment.</h3>
|
||||
<p className="dm-st-anchor__lead">EV Aware wins — the best fulfillment with feasible, battery-safe routes for every fleet driver.</p>
|
||||
<div className="dm-st-anchor__chips">
|
||||
<span className="dm-st-anchor__chip dm-st-anchor__chip--win">🏆 EV Aware</span>
|
||||
<span className="dm-st-anchor__chip">88% Score</span>
|
||||
|
||||
@@ -197,7 +197,7 @@ export default function IndexHero() {
|
||||
</h1>
|
||||
<div className="content-slider-item-text logico-content-wrapper-2" style={{ display: "flex", justifyContent: "center", width: "100%", marginTop: "23px" }}>
|
||||
<div className="text-content" style={{ textAlign: "center", maxWidth: "680px", margin: "0 auto" }}>
|
||||
<p>Stop managing three separate logistics services. Doormile unifies first, mid and last mile into a single intelligent delivery system powered by MileTruth™ AI.</p>
|
||||
<p>Stop managing three separate logistics services. Doormile unifies first, mid and last mile into a single intelligent logistics system powered by MileTruth™ AI.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,7 +153,7 @@ export default function IndustrySolutions() {
|
||||
<div className="industry-solutions-grid">
|
||||
|
||||
{/* Card 1: FMCG */}
|
||||
<Link href="/solutions" className="industry-card-link">
|
||||
<Link href="/industries" className="industry-card-link">
|
||||
<div className="industry-card-bg">
|
||||
<Image
|
||||
src="/images/tab-pic-1.webp"
|
||||
@@ -189,7 +189,7 @@ export default function IndustrySolutions() {
|
||||
<h5 className="industry-card-section-title">Doormile Solutions</h5>
|
||||
<ul className="industry-card-list">
|
||||
<li className="industry-card-list-item bullet-4">AI-driven demand-responsive routing</li>
|
||||
<li className="industry-card-list-item bullet-5">Freshness-aware delivery prioritization</li>
|
||||
<li className="industry-card-list-item bullet-5">Freshness-aware shipment prioritization</li>
|
||||
<li className="industry-card-list-item bullet-6">Dynamic batch optimization</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -217,7 +217,7 @@ export default function IndustrySolutions() {
|
||||
</Link>
|
||||
|
||||
{/* Card 2: Pharmaceutical */}
|
||||
<Link href="/solutions" className="industry-card-link">
|
||||
<Link href="/industries" className="industry-card-link">
|
||||
<div className="industry-card-bg">
|
||||
<Image
|
||||
src="/images/tab-pic-2.webp"
|
||||
@@ -246,7 +246,7 @@ export default function IndustrySolutions() {
|
||||
<ul className="industry-card-list">
|
||||
<li className="industry-card-list-item bullet-1">Cold chain integrity requirements</li>
|
||||
<li className="industry-card-list-item bullet-2">Regulatory compliance tracking</li>
|
||||
<li className="industry-card-list-item bullet-3">Critical delivery time windows</li>
|
||||
<li className="industry-card-list-item bullet-3">Critical shipment time windows</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -281,7 +281,7 @@ export default function IndustrySolutions() {
|
||||
</Link>
|
||||
|
||||
{/* Card 3: Enterprise & B2B */}
|
||||
<Link href="/solutions" className="industry-card-link">
|
||||
<Link href="/industries" className="industry-card-link">
|
||||
<div className="industry-card-bg">
|
||||
<Image
|
||||
src="/images/tab-pic-3.webp"
|
||||
@@ -303,13 +303,13 @@ export default function IndustrySolutions() {
|
||||
<div className="industry-card-hover-content">
|
||||
<div className="industry-card-hover-top">
|
||||
<p className="industry-card-description">
|
||||
High-value shipments with complex delivery requirements.
|
||||
High-value shipments with complex logistics requirements.
|
||||
</p>
|
||||
<div>
|
||||
<h5 className="industry-card-section-title">Challenges</h5>
|
||||
<ul className="industry-card-list">
|
||||
<li className="industry-card-list-item bullet-1">Appointment scheduling coordination</li>
|
||||
<li className="industry-card-list-item bullet-2">White-glove delivery standards</li>
|
||||
<li className="industry-card-list-item bullet-2">White-glove logistics standards</li>
|
||||
<li className="industry-card-list-item bullet-3">Multi-location routing complexity</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@ export default function StatsBar() {
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">
|
||||
<CountUp end={99.2} decimals={1} suffix="%" duration={2.2} /> <br />
|
||||
<span style={{ fontSize: "20px" }}>On-Time Delivery</span>
|
||||
<span style={{ fontSize: "20px" }}>On-Time Performance</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import IndustryWorldMap from "./IndustryWorldMap";
|
||||
import IndustryWorldMap from "@/components/sections/IndustryWorldMap";
|
||||
|
||||
type Tab = "ch" | "so";
|
||||
|
||||
@@ -22,15 +22,15 @@ const SECTIONS: Section[] = [
|
||||
image: "/images/tab-pic-1-solution.webp",
|
||||
alt: "FMCG logistics",
|
||||
desc:
|
||||
"FMCG logistics demands speed, precision, and continuous fulfillment across high-volume delivery networks. Businesses must balance tight delivery timelines, inventory movement, and operational efficiency without compromising product availability.",
|
||||
"FMCG logistics demands speed, precision, and continuous fulfillment across high-volume logistics networks. Businesses must balance tight shipment timelines, inventory movement, and operational efficiency without compromising product availability.",
|
||||
ch: [
|
||||
"Unpredictable demand spikes create delivery pressure and reduce operational efficiency during peak periods.",
|
||||
"Fresh product expiry constraints require faster, precisely timed deliveries to maintain product quality.",
|
||||
"Multi-stop route complexity increases travel time, operational costs, and delivery coordination challenges.",
|
||||
"Inventory stockout risks increase when delivery delays disrupt fast-moving product distribution.",
|
||||
"Unpredictable demand spikes create logistics pressure and reduce operational efficiency during peak periods.",
|
||||
"Fresh product expiry constraints require faster, precisely timed shipments to maintain product quality.",
|
||||
"Multi-stop route complexity increases travel time, operational costs, and logistics coordination challenges.",
|
||||
"Inventory stockout risks increase when shipment delays disrupt fast-moving product distribution.",
|
||||
],
|
||||
so: [
|
||||
"AI demand forecasting adapts delivery plans instantly to real-time order demand.",
|
||||
"AI demand forecasting adapts logistics 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.",
|
||||
@@ -42,17 +42,17 @@ const SECTIONS: Section[] = [
|
||||
image: "/images/tab-pic-2-solution.webp",
|
||||
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.",
|
||||
"Pharma logistics requires precision, compliance, and real-time monitoring so every shipment arrives safely and on time — from temperature-sensitive medicines to urgent emergency shipments.",
|
||||
ch: [
|
||||
"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.",
|
||||
"Regulatory compliance must be tracked and documented on every shipment.",
|
||||
"Critical arrival windows require highly accurate scheduling.",
|
||||
"Emergency shipments need instant dispatch and zero-delay execution.",
|
||||
],
|
||||
so: [
|
||||
"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.",
|
||||
"Precision scheduling locks in critical arrival windows reliably.",
|
||||
"Priority dispatch queue fast-tracks urgent, life-critical shipments.",
|
||||
],
|
||||
},
|
||||
@@ -65,17 +65,57 @@ const SECTIONS: Section[] = [
|
||||
"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.",
|
||||
ch: [
|
||||
"Appointment scheduling requires precise timing across many locations.",
|
||||
"White-glove delivery standards demand premium handling and accuracy.",
|
||||
"White-glove logistics 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.",
|
||||
],
|
||||
so: [
|
||||
"Intelligent appointment engine streamlines and automates delivery slots.",
|
||||
"Intelligent appointment engine streamlines and automates arrival slots.",
|
||||
"White-glove workflow module enforces premium handling end to end.",
|
||||
"Enterprise route planner coordinates efficient multi-location delivery.",
|
||||
"Enterprise route planner coordinates efficient multi-location logistics.",
|
||||
"SLA monitoring dashboard tracks commitments and flags risk in real time.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Retail",
|
||||
image: "/images/last-mile-approach.webp",
|
||||
alt: "Retail doorstep delivery",
|
||||
desc:
|
||||
"Retail logistics is judged at the doorstep. Shoppers expect to watch the delivery rather than call about it, and every failed attempt costs more than the trip itself — so visibility, first-attempt success, and clean returns decide the experience.",
|
||||
ch: [
|
||||
"Customers expect to track the delivery live instead of phoning for updates.",
|
||||
"Failed delivery attempts cost more than the delivery itself and erode trust.",
|
||||
"Returns must flow back through the same trip to stay economical.",
|
||||
"Same-day promises leave no room for slow dispatch or manual coordination.",
|
||||
],
|
||||
so: [
|
||||
"Live customer tracking links replace status calls with a shareable map.",
|
||||
"OTP-verified handover confirms delivery and lifts first-attempt success.",
|
||||
"Reverse pickup on the same route brings returns back without an extra trip.",
|
||||
"Fast doorstep dispatch keeps same-day commitments consistently on time.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Quick Commerce",
|
||||
image: "/images/home2-pic-3.webp",
|
||||
alt: "Quick commerce dark store delivery",
|
||||
desc:
|
||||
"Quick commerce compresses the entire logistics day into minutes. Promise windows are measured in tens of minutes, dark store stock moves constantly, and rider capacity has to match demand the moment it spikes.",
|
||||
ch: [
|
||||
"Sub-hour promise windows collapse if dispatch waits on manual decisions.",
|
||||
"Dense micro-zone orders arrive faster than they can be grouped by hand.",
|
||||
"Peak-hour demand outpaces available rider capacity within minutes.",
|
||||
"Dark store stock counts drift out of date and trigger cancelled orders.",
|
||||
],
|
||||
so: [
|
||||
"Instant dispatch assigns every order the moment it lands, with no queue.",
|
||||
"Micro-zone batching groups nearby orders into one short, efficient run.",
|
||||
"Live capacity balancing shifts riders to the zones that are surging.",
|
||||
"Real-time dark store stock sync prevents cancellations and substitutions.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function Card({ sec }: { sec: Section }) {
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useSceneStore } from '../../store/useSceneStore'
|
||||
|
||||
export default function Hero() {
|
||||
const lenis = useSceneStore((state) => state.lenis)
|
||||
const handleScrollToStart = () => {
|
||||
// Scroll down to the first active transition point
|
||||
lenis?.scrollTo(window.innerHeight * 0.5, { duration: 1.5 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hero-overlay" id="home-hero">
|
||||
{/* Dynamic mouse scrolling indicator */}
|
||||
<div className="scroll-indicator" onClick={handleScrollToStart}>
|
||||
<div className="mouse-frame">
|
||||
<div className="mouse-dot" />
|
||||
</div>
|
||||
<div className="scroll-text">Scroll to start</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export const colors = {
|
||||
primary: '#0071e3', // Apple Blue
|
||||
secondary: '#86868b', // Apple Gray
|
||||
background: '#ffffff', // White
|
||||
backgroundDark: '#f5f5f7', // Off-white/Light gray
|
||||
text: '#1d1d1f', // Premium dark gray (text)
|
||||
textMuted: '#86868b', // Subtitle text
|
||||
border: '#d2d2d7', // Thin borders
|
||||
success: '#34c759', // Apple Green
|
||||
accentBlue: '#2997ff', // Bright active blue
|
||||
cardBg: 'rgba(255, 255, 255, 0.8)',
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export const easeInOutCubic = (t) => {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
|
||||
}
|
||||
|
||||
export const easeOutQuad = (t) => {
|
||||
return t * (2 - t)
|
||||
}
|
||||
|
||||
export const easeInQuad = (t) => {
|
||||
return t * t
|
||||
}
|
||||
|
||||
export const easeOutCubic = (t) => {
|
||||
return 1 - Math.pow(1 - t, 3)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ function templateContent(post: {
|
||||
return [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: `In last-mile logistics, a missed SLA is rarely caused by one big failure. It usually comes from small delays adding up across a route. ${post.title} looks at how delivery teams can spot those delays early and plan around them before vehicles leave the hub.`,
|
||||
text: `In last-mile logistics, a missed SLA is rarely caused by one big failure. It usually comes from small delays adding up across a route. ${post.title} looks at how logistics teams can spot those delays early and plan around them before vehicles leave the hub.`,
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
@@ -52,14 +52,14 @@ function templateContent(post: {
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Every extra kilometre costs money. It uses fuel or charge, adds rider time, wears down vehicles, and increases the chance of a late delivery. When routes are built from fixed zones or old habits, those costs repeat every day.",
|
||||
text: "Every extra kilometre costs money. It uses fuel or charge, adds driver time, wears down vehicles, and increases the chance of a late shipment. When routes are built from fixed zones or old habits, those costs repeat every day.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
items: [
|
||||
"Fewer vehicles needed for the same number of drops",
|
||||
"Lower cost per drop through better stop sequencing",
|
||||
"ETAs that match traffic, distance, and delivery windows",
|
||||
"ETAs that match traffic, distance, and arrival windows",
|
||||
"Less fuel or charge used per completed order",
|
||||
],
|
||||
},
|
||||
@@ -70,7 +70,7 @@ function templateContent(post: {
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: `A good dispatch plan starts with the basics: order locations, promised delivery windows, rider capacity, vehicle range, and known traffic trouble spots. MileTruth™ checks those inputs before dispatch so the team is not fixing avoidable mistakes on the road.`,
|
||||
text: `A good dispatch plan starts with the basics: order locations, promised arrival windows, driver capacity, vehicle range, and known traffic trouble spots. MileTruth™ checks those inputs before dispatch so the team is not fixing avoidable mistakes on the road.`,
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
@@ -80,7 +80,7 @@ function templateContent(post: {
|
||||
},
|
||||
{
|
||||
type: "quote",
|
||||
text: "A route should be checked before the rider leaves, not explained after the customer calls.",
|
||||
text: "A route should be checked before the driver leaves, not explained after the customer calls.",
|
||||
cite: "Doormile Operations",
|
||||
},
|
||||
{
|
||||
@@ -90,21 +90,21 @@ function templateContent(post: {
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The best improvements usually start with simple measurements. Track distance per route, failed delivery windows, rider idle time, and orders moved between vehicles after dispatch. Those numbers show where the operation is leaking time.",
|
||||
text: "The best improvements usually start with simple measurements. Track distance per route, failed arrival windows, driver idle time, and orders moved between vehicles after dispatch. Those numbers show where the operation is leaking time.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
ordered: true,
|
||||
items: [
|
||||
"Benchmark today's distance, fleet size, and on-time rate.",
|
||||
"Include vehicle capacity, delivery windows, rider shifts, and battery charge.",
|
||||
"Include vehicle capacity, arrival windows, driver shifts, and battery charge.",
|
||||
"Check routes against traffic and customer commitments before dispatch.",
|
||||
"Compare the next dispatch cycle against the old plan.",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Better routing is not about pushing riders harder. It is about giving them a plan that already accounts for the real day ahead.",
|
||||
text: "Better routing is not about pushing drivers harder. It is about giving them a plan that already accounts for the real day ahead.",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -124,14 +124,14 @@ const seeds: SeedPost[] = [
|
||||
// ── Flagship 1 ───────────────────────────────────────────────────────────
|
||||
{
|
||||
slug: "how-ai-is-transforming-last-mile-ev-delivery",
|
||||
title: "How Better Planning Improves Last-Mile EV Delivery",
|
||||
title: "How Better Planning Improves Last-Mile EV Logistics",
|
||||
excerpt:
|
||||
"A practical look at how EV fleets can plan routes, manage charging, and keep delivery promises without adding unnecessary vehicles.",
|
||||
"A practical look at how EV fleets can plan routes, manage charging, and keep service promises without adding unnecessary vehicles.",
|
||||
category: "Technology",
|
||||
image: "/images/blog-post-pic-17.webp",
|
||||
date: "2025-10-02",
|
||||
intro:
|
||||
"The last mile is already difficult to plan. With electric vehicles, the team also has to think about battery range, charging time, rider load, traffic, and delivery windows. Good planning turns those moving parts into a workable dispatch plan.",
|
||||
"The last mile is already difficult to plan. With electric vehicles, the team also has to think about battery range, charging time, driver load, traffic, and arrival windows. Good planning turns those moving parts into a workable dispatch plan.",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
@@ -144,15 +144,15 @@ const seeds: SeedPost[] = [
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A fixed-zone plan may look clean in the morning, but the road rarely follows the plan. A rider may get delayed near Hitec City, a gated community may hold a vehicle for ten extra minutes, or a battery may drain faster because the load is heavier than usual.",
|
||||
text: "A fixed-zone plan may look clean in the morning, but the road rarely follows the plan. A driver may get delayed near Hitec City, a gated community may hold a vehicle for ten extra minutes, or a battery may drain faster because the load is heavier than usual.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
items: [
|
||||
"Order volumes by area, time slot, and customer type",
|
||||
"Travel times based on the city's actual traffic patterns",
|
||||
"Battery use by vehicle type, rider load, and route length",
|
||||
"Feedback from completed deliveries, failed attempts, and late arrivals",
|
||||
"Battery use by vehicle type, driver load, and route length",
|
||||
"Feedback from completed shipments, failed attempts, and late arrivals",
|
||||
],
|
||||
},
|
||||
{ type: "heading", level: 3, text: "Adjusting during the day" },
|
||||
@@ -162,14 +162,14 @@ const seeds: SeedPost[] = [
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "During peak traffic hours in Hyderabad, some vehicles were arriving 20 to 30 minutes later than planned. By adjusting routes based on live traffic and battery levels, the hub reduced missed delivery windows and improved on-time performance.",
|
||||
text: "During peak traffic hours in Hyderabad, some vehicles were arriving 20 to 30 minutes later than planned. By adjusting routes based on live traffic and battery levels, the hub reduced missed arrival windows and improved on-time performance.",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
src: "/images/ev-paradox.webp",
|
||||
alt: "Electric delivery vehicle routing visualisation",
|
||||
alt: "Electric fleet vehicle routing visualisation",
|
||||
caption:
|
||||
"EV route plans need to account for range, load, traffic, and charging time before riders leave the hub.",
|
||||
"EV route plans need to account for range, load, traffic, and charging time before drivers leave the hub.",
|
||||
},
|
||||
{
|
||||
type: "quote",
|
||||
@@ -183,21 +183,21 @@ const seeds: SeedPost[] = [
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "For a fleet manager, this is not about fancy software. It is about fewer emergency calls, fewer mid-route swaps, and fewer customers asking why their delivery missed the promised window.",
|
||||
text: "For a fleet manager, this is not about fancy software. It is about fewer emergency calls, fewer mid-route swaps, and fewer customers asking why their shipment missed the promised window.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
ordered: true,
|
||||
items: [
|
||||
"Record delivery times, failed attempts, traffic delays, and charging cycles.",
|
||||
"Build travel-time estimates from the areas your riders actually serve.",
|
||||
"Record arrival times, failed attempts, traffic delays, and charging cycles.",
|
||||
"Build travel-time estimates from the areas your drivers actually serve.",
|
||||
"Check every route against battery capacity before dispatch.",
|
||||
"Replan when traffic, orders, or vehicle availability changes.",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The fleets that improve fastest are usually not the ones adding vehicles first. They are the ones removing wasted distance, planning charging properly, and giving riders routes they can complete on time.",
|
||||
text: "The fleets that improve fastest are usually not the ones adding vehicles first. They are the ones removing wasted distance, planning charging properly, and giving drivers routes they can complete on time.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -207,7 +207,7 @@ const seeds: SeedPost[] = [
|
||||
slug: "42-less-distance-insights-from-our-hyderabad-hub",
|
||||
title: "42% Less Distance: Insights from Our Hyderabad Hub",
|
||||
excerpt:
|
||||
"A practical look at how one Hyderabad hub reduced distance, used fewer vehicles, and protected delivery windows with better route planning.",
|
||||
"A practical look at how one Hyderabad hub reduced distance, used fewer vehicles, and protected arrival windows with better route planning.",
|
||||
category: "Case Study",
|
||||
image: "/images/blog-post-pic-15.webp",
|
||||
date: "2025-09-18",
|
||||
@@ -216,7 +216,7 @@ const seeds: SeedPost[] = [
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Hyderabad is not an easy city for delivery planning. Dense commercial areas, fast-growing suburbs, flyover work, narrow service lanes, apartment security checks, and sudden traffic build-up can all change the day.",
|
||||
text: "Hyderabad is not an easy city for logistics planning. Dense commercial areas, fast-growing suburbs, flyover work, narrow service lanes, apartment security checks, and sudden traffic build-up can all change the day.",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
@@ -225,11 +225,11 @@ const seeds: SeedPost[] = [
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Before MileTruth, the hub planned routes in the usual way. Zones were drawn from experience, dispatchers sequenced stops manually, and riders adjusted on the road when something changed.",
|
||||
text: "Before MileTruth, the hub planned routes in the usual way. Zones were drawn from experience, dispatchers sequenced stops manually, and drivers adjusted on the road when something changed.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "That process worked, but it carried hidden costs. Two riders might cross the same area in the same hour. A vehicle might take a longer loop to avoid one late stop. A dispatcher might hold back an order because the best vehicle was not obvious in the moment.",
|
||||
text: "That process worked, but it carried hidden costs. Two drivers might cross the same area in the same hour. A vehicle might take a longer loop to avoid one late stop. A dispatcher might hold back an order because the best vehicle was not obvious in the moment.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
@@ -237,7 +237,7 @@ const seeds: SeedPost[] = [
|
||||
"Zone-based allocation that missed nearby cross-zone drops",
|
||||
"Manual stop sequencing during busy dispatch windows",
|
||||
"ETAs checked after routing instead of before dispatch",
|
||||
"Traffic and delay handling that depended on phone calls from riders",
|
||||
"Traffic and delay handling that depended on phone calls from drivers",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -247,18 +247,18 @@ const seeds: SeedPost[] = [
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The main change was treating the day's orders as one connected plan instead of separate zone lists. The route plan considered distance, rider capacity, delivery windows, traffic, and vehicle availability together.",
|
||||
text: "The main change was treating the day's orders as one connected plan instead of separate zone lists. The route plan considered distance, driver capacity, arrival windows, traffic, and vehicle availability together.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "During peak traffic hours in Hyderabad, some vehicles were arriving 20 to 30 minutes later than planned. By adjusting routes based on live traffic and battery levels, we reduced missed delivery windows and improved on-time performance.",
|
||||
text: "During peak traffic hours in Hyderabad, some vehicles were arriving 20 to 30 minutes later than planned. By adjusting routes based on live traffic and battery levels, we reduced missed arrival windows and improved on-time performance.",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
src: "/images/last-mile-approach.webp",
|
||||
alt: "Hyderabad delivery hub routing analysis",
|
||||
alt: "Hyderabad logistics hub routing analysis",
|
||||
caption:
|
||||
"Planning the day's deliveries together removed repeated cross-town travel.",
|
||||
"Planning the day's shipments together removed repeated cross-town travel.",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
@@ -269,14 +269,14 @@ const seeds: SeedPost[] = [
|
||||
type: "list",
|
||||
items: [
|
||||
"42% reduction in total distance travelled across the hub",
|
||||
"37% fewer vehicles required for the same delivery volume",
|
||||
"37% fewer vehicles required for the same shipment volume",
|
||||
"No SLA misses during the measured deployment window",
|
||||
"Lower fuel use and fewer unnecessary kilometres per parcel",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "quote",
|
||||
text: "The improvement did not come from asking riders to work harder. It came from giving the team a better route plan before the vehicles left.",
|
||||
text: "The improvement did not come from asking drivers to work harder. It came from giving the team a better route plan before the vehicles left.",
|
||||
cite: "Hyderabad Hub Operations",
|
||||
},
|
||||
{
|
||||
@@ -300,16 +300,16 @@ const seeds: SeedPost[] = [
|
||||
slug: "miletruth-ai-10-stages-to-smarter-dispatch",
|
||||
title: "MileTruth™: 10 Stages to Smarter Dispatch",
|
||||
excerpt:
|
||||
"From order intake to final route output, here is how a dispatch plan is checked before riders leave the hub.",
|
||||
"From order intake to final route output, here is how a dispatch plan is checked before drivers leave the hub.",
|
||||
category: "MileTruth",
|
||||
image: "/images/blog-post-pic-31.webp",
|
||||
date: "2025-09-05",
|
||||
intro:
|
||||
"Behind every Doormile dispatch is a step-by-step route planning process. Each stage removes a common source of error before the plan reaches the rider.",
|
||||
"Behind every Doormile dispatch is a step-by-step route planning process. Each stage removes a common source of error before the plan reaches the driver.",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A dispatch plan has to be fast, but it also has to be usable. A quick route that ignores a bad address, low battery, or tight delivery window creates problems later in the day.",
|
||||
text: "A dispatch plan has to be fast, but it also has to be usable. A quick route that ignores a bad address, low battery, or tight arrival window creates problems later in the day.",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
@@ -320,15 +320,15 @@ const seeds: SeedPost[] = [
|
||||
type: "list",
|
||||
ordered: true,
|
||||
items: [
|
||||
"Order intake: new orders, rider availability, and vehicle status are collected.",
|
||||
"Address check: delivery points, time windows, and service notes are verified.",
|
||||
"Stop planning: each stop gets an expected service time and delivery priority.",
|
||||
"Order intake: new orders, driver availability, and vehicle status are collected.",
|
||||
"Address check: drop points, time windows, and service notes are verified.",
|
||||
"Stop planning: each stop gets an expected service time and stop priority.",
|
||||
"Travel-time check: routes are compared against time-of-day traffic.",
|
||||
"Constraint check: capacity, shift time, customer windows, and range are applied.",
|
||||
"Route options: several possible plans are built for the same order set.",
|
||||
"Plan selection: the lowest-cost workable route plan is selected.",
|
||||
"Battery check: EV routes are checked against real charge capacity.",
|
||||
"ETA check: promised delivery times are verified before dispatch.",
|
||||
"ETA check: promised arrival times are verified before dispatch.",
|
||||
"Dispatch output: the final route is sent to the operations team.",
|
||||
],
|
||||
},
|
||||
@@ -339,14 +339,14 @@ const seeds: SeedPost[] = [
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "When everything is checked in one step, small errors slip through. A wrong pin, a missing apartment note, or a low battery warning can reach the rider and become a customer issue.",
|
||||
text: "When everything is checked in one step, small errors slip through. A wrong pin, a missing apartment note, or a low battery warning can reach the driver and become a customer issue.",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
src: "/images/blog-post-pic-31.webp",
|
||||
alt: "MileTruth routing pipeline diagram",
|
||||
caption:
|
||||
"A staged dispatch process catches address, range, and ETA issues before riders leave.",
|
||||
"A staged dispatch process catches address, range, and ETA issues before drivers leave.",
|
||||
},
|
||||
{
|
||||
type: "quote",
|
||||
@@ -360,14 +360,14 @@ const seeds: SeedPost[] = [
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The useful part is not only building one route. It is comparing a few workable route options before choosing the plan. One option may save distance, another may protect a tight delivery window, and another may keep an EV closer to a charger.",
|
||||
text: "The useful part is not only building one route. It is comparing a few workable route options before choosing the plan. One option may save distance, another may protect a tight arrival window, and another may keep an EV closer to a charger.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
items: [
|
||||
"Several route plans checked before dispatch",
|
||||
"Distance, time windows, capacity, and range considered together",
|
||||
"Range and ETA checked before the route reaches the rider",
|
||||
"Range and ETA checked before the route reaches the driver",
|
||||
"Fast output that still leaves room for dispatcher review",
|
||||
],
|
||||
},
|
||||
@@ -403,7 +403,7 @@ const seeds: SeedPost[] = [
|
||||
},
|
||||
{
|
||||
slug: "fleet-reduction-without-compromising-delivery-volume",
|
||||
title: "Fleet Reduction Without Compromising Delivery Volume",
|
||||
title: "Fleet Reduction Without Compromising Shipment Volume",
|
||||
excerpt:
|
||||
"Handling the same order volume with fewer vehicles starts by removing avoidable kilometres and overlapping routes.",
|
||||
category: "Fleet Management",
|
||||
@@ -416,12 +416,12 @@ const seeds: SeedPost[] = [
|
||||
slug: "building-a-greener-city-the-future-of-urban-logistics",
|
||||
title: "Building a Greener City: The Future of Urban Logistics",
|
||||
excerpt:
|
||||
"Cities are asking for cleaner delivery. The practical challenge is planning EV routes that can meet customer windows without wasting charge.",
|
||||
"Cities are asking for cleaner logistics. The practical challenge is planning EV routes that can meet customer windows without wasting charge.",
|
||||
category: "Sustainability",
|
||||
image: "/images/blog-post-pic-6.webp",
|
||||
date: "2025-07-10",
|
||||
intro:
|
||||
"Cleaner delivery is becoming an operating requirement, not just a brand message. It depends on EV adoption and route plans that make those vehicles practical every day.",
|
||||
"Cleaner logistics is becoming an operating requirement, not just a brand message. It depends on EV adoption and route plans that make those vehicles practical every day.",
|
||||
},
|
||||
{
|
||||
slug: "how-doormile-maintains-99-9-sla-compliance-at-scale",
|
||||
@@ -432,18 +432,18 @@ const seeds: SeedPost[] = [
|
||||
image: "/images/last-mile-approach.webp",
|
||||
date: "2025-06-26",
|
||||
intro:
|
||||
"Strong SLA performance is not luck. It comes from planning the day so late deliveries are the exception, not the expected risk.",
|
||||
"Strong SLA performance is not luck. It comes from planning the day so late shipments are the exception, not the expected risk.",
|
||||
},
|
||||
{
|
||||
slug: "battery-simulation-the-secret-to-ev-route-pre-validation",
|
||||
title: "Battery Simulation: The Secret to EV Route Pre-Validation",
|
||||
excerpt:
|
||||
"Before a rider leaves the hub, every EV route should be checked against real charge capacity and the expected return plan.",
|
||||
"Before a driver leaves the hub, every EV route should be checked against real charge capacity and the expected return plan.",
|
||||
category: "EV Fleet",
|
||||
image: "/images/blog-post-pic-3.webp",
|
||||
date: "2025-06-12",
|
||||
intro:
|
||||
"A stranded EV is not just a late delivery. It means a vehicle is out of service, a customer is waiting, and the hub has to arrange recovery. Range checks need to happen before dispatch.",
|
||||
"A stranded EV is not just a late shipment. It means a vehicle is out of service, a customer is waiting, and the hub has to arrange recovery. Range checks need to happen before dispatch.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getBodyClasses } from "@/lib/bodyClasses";
|
||||
import { getBodyClasses } from "@/shared/lib/bodyClasses";
|
||||
|
||||
/**
|
||||
* Applies production WP/Elementor body classes per route.
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { sendEmailAction } from "@/actions/sendEmail";
|
||||
import { sendEmailAction } from "@/shared/actions/sendEmail";
|
||||
import { ScrollReveal } from "@/animations/Reveal";
|
||||
|
||||
export default function Footer() {
|
||||
@@ -126,7 +126,7 @@ export default function Footer() {
|
||||
</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>
|
||||
<p>Connecting businesses with fast, secure, intelligent logistics.</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">
|
||||
@@ -360,7 +360,7 @@ export default function Footer() {
|
||||
<div className="elementor-widget-container">
|
||||
<ScrollReveal delay={0.1} duration={0.8} yOffset={25}>
|
||||
<h2 className="logico-title">
|
||||
Delivered <span style={{ color: "#c01227" }}>on time</span> with no hassle.
|
||||
Shipped <span style={{ color: "#c01227" }}>on time</span> with no hassle.
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
@@ -425,11 +425,6 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-5b73dd3 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="5b73dd3" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-10ead4d elementor-widget elementor-widget-logico_heading" data-id="10ead4d" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">Solutions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-b2507df elementor-widget elementor-widget-logico_custom_navigation_menu" data-id="b2507df" data-element_type="widget" data-e-type="widget" data-widget_type="logico_custom_navigation_menu.default">
|
||||
<div className="elementor-widget-container">
|
||||
<ul className="logico-custom-menu-widget">
|
||||
@@ -440,7 +435,7 @@ export default function Footer() {
|
||||
<Link href="/how-it-works">How It Works</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/solutions">Use Cases</Link>
|
||||
<Link href="/industries">Use Cases</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contact">Request Demo</Link>
|
||||
@@ -450,11 +445,6 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-451f15c e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="451f15c" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-0ec06ff elementor-widget elementor-widget-logico_heading" data-id="0ec06ff" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">Services</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-a219b7d elementor-widget elementor-widget-logico_custom_navigation_menu" data-id="a219b7d" data-element_type="widget" data-e-type="widget" data-widget_type="logico_custom_navigation_menu.default">
|
||||
<div className="elementor-widget-container">
|
||||
<ul className="logico-custom-menu-widget">
|
||||
@@ -478,11 +468,6 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-44a1f5d e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="44a1f5d" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-0edc8fd elementor-widget elementor-widget-logico_heading" data-id="0edc8fd" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">Legal</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-189484a elementor-widget elementor-widget-logico_custom_navigation_menu" data-id="189484a" data-element_type="widget" data-e-type="widget" data-widget_type="logico_custom_navigation_menu.default">
|
||||
<div className="elementor-widget-container">
|
||||
<ul className="logico-custom-menu-widget">
|
||||
@@ -19,6 +19,7 @@ const CURRENT_PAGE_MAP: Record<string, string> = {
|
||||
"/how-it-works": "how-it-works",
|
||||
"/miletruth": "miletruth",
|
||||
"/solutions": "solutions",
|
||||
"/industries": "industries",
|
||||
"/about": "about",
|
||||
"/doormile-wings": "doormile-wings",
|
||||
"/empowerment": "entarpreneurship",
|
||||
@@ -38,6 +39,8 @@ const CURRENT_PAGE_ALIASES: Record<string, string[]> = {
|
||||
miletruth: ["miletruth"],
|
||||
// Solutions is now a plain top-level link — no child pages own its active state.
|
||||
solutions: ["solutions"],
|
||||
// Industries is the sibling top-level link — "who Doormile serves".
|
||||
industries: ["industries"],
|
||||
"doormile-wings": ["doormile-wings"],
|
||||
entarpreneurship: ["entarpreneurship"],
|
||||
blogs: ["blogs"],
|
||||
@@ -45,8 +48,8 @@ const CURRENT_PAGE_ALIASES: Record<string, string[]> = {
|
||||
"privacy-policy": ["privacy-policy"],
|
||||
"terms-of-service": ["terms-of-service"],
|
||||
"cookie-policy": ["cookie-policy"],
|
||||
// Company is the dropdown parent for About + Blogs + How It Works — active if any is.
|
||||
company: ["blogs", "how-it-works", "about"],
|
||||
// Company is the dropdown parent for About + Industries + How It Works + Blogs — active if any is.
|
||||
company: ["about", "industries", "how-it-works", "blogs"],
|
||||
};
|
||||
|
||||
export default function Header() {
|
||||
@@ -524,15 +527,13 @@ export default function Header() {
|
||||
About
|
||||
</Link>
|
||||
</li>
|
||||
<li className={currentPage === "blogs" ? "dm-child-active" : ""}>
|
||||
<li className={currentPage === "industries" ? "dm-child-active" : ""}>
|
||||
<Link
|
||||
href="/blog#blogs"
|
||||
onClick={(event) => {
|
||||
setDesktopCompanyOpen(false);
|
||||
handleNavClick(event, "/blog", "blogs");
|
||||
}}
|
||||
href="/industries"
|
||||
prefetch
|
||||
onClick={() => setDesktopCompanyOpen(false)}
|
||||
>
|
||||
Blogs
|
||||
Industries
|
||||
</Link>
|
||||
</li>
|
||||
<li className={currentPage === "how-it-works" ? "dm-child-active" : ""}>
|
||||
@@ -546,6 +547,17 @@ export default function Header() {
|
||||
How It Works
|
||||
</Link>
|
||||
</li>
|
||||
<li className={currentPage === "blogs" ? "dm-child-active" : ""}>
|
||||
<Link
|
||||
href="/blog#blogs"
|
||||
onClick={(event) => {
|
||||
setDesktopCompanyOpen(false);
|
||||
handleNavClick(event, "/blog", "blogs");
|
||||
}}
|
||||
>
|
||||
Blogs
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -621,12 +633,15 @@ export default function Header() {
|
||||
<li className={currentPage === "about" ? "dm-child-active" : ""}>
|
||||
<Link href="/about-us#about" onClick={(event) => handleNavClick(event, "/about-us", "about", true)}>About</Link>
|
||||
</li>
|
||||
<li className={currentPage === "blogs" ? "dm-child-active" : ""}>
|
||||
<Link href="/blog#blogs" onClick={(event) => handleNavClick(event, "/blog", "blogs", true)}>Blogs</Link>
|
||||
<li className={currentPage === "industries" ? "dm-child-active" : ""}>
|
||||
<Link href="/industries" prefetch onClick={closeMobileMenu}>Industries</Link>
|
||||
</li>
|
||||
<li className={currentPage === "how-it-works" ? "dm-child-active" : ""}>
|
||||
<Link href="/how-it-works#how-it-works" onClick={(event) => handleNavClick(event, "/how-it-works", "how-it-works", true)}>How It Works</Link>
|
||||
</li>
|
||||
<li className={currentPage === "blogs" ? "dm-child-active" : ""}>
|
||||
<Link href="/blog#blogs" onClick={(event) => handleNavClick(event, "/blog", "blogs", true)}>Blogs</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -803,6 +818,23 @@ export default function Header() {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* ── Elementor's "laptop" tier (1021–1200px) ──
|
||||
Elementor's own "laptop" tier is 1021–1200px: the Contact button
|
||||
container (f961133) carries .elementor-hidden-laptop, which
|
||||
site.css turns into display:none inside
|
||||
@media (min-width:1021px) and (max-width:1200px). The nav widget
|
||||
likewise carries .logico-breakpoint-laptop, whose theme rules
|
||||
(body[data-elementor-device-mode="laptop"]) hide the desktop
|
||||
.header-menu-container and reveal the hamburger — but those are
|
||||
driven by Elementor's frontend JS, which this port does not ship,
|
||||
so they never matched and the desktop nav stayed on down to 1025px
|
||||
while the Contact button had already been hidden by the pure-CSS
|
||||
half of the same tier. That left 1025–1200px rendering a
|
||||
three-column desktop grid with an empty third column, so the
|
||||
centred nav slid left and overlapped the logo (measured: nav starts
|
||||
6px left of the logo's right edge at 1164px, 40px at 1093px).
|
||||
The grid is repaired for that band further down (search
|
||||
"laptop tier"); everything >= 1201px is untouched. */
|
||||
@media (min-width: 1025px) {
|
||||
#masthead {
|
||||
position: relative;
|
||||
@@ -888,6 +920,34 @@ export default function Header() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── laptop tier: drop the phantom third column ──
|
||||
In Elementor's laptop tier (1021–1200px) the Contact button is
|
||||
display:none, so the grid's third track holds nothing — but
|
||||
"1fr auto 1fr" still reserves it. At these widths the auto (nav)
|
||||
track is wider than the space left over, so both 1fr tracks
|
||||
collapse to 0 and the centred nav bleeds out of its track in BOTH
|
||||
directions, landing on top of the logo: measured overlap was 39px
|
||||
at 1200px, 56px at 1164px and 121px at 1026px ("Home" printed over
|
||||
"DoorMile"). Un-hiding the Contact button does not help — measured
|
||||
at every width in the band the three sections still do not fit
|
||||
(the nav would then overlap the button by 36–116px instead).
|
||||
|
||||
Dropping to two tracks and pinning the nav to the end removes the
|
||||
phantom column: the logo keeps its intrinsic width, the nav takes
|
||||
what is left, and they no longer intersect. This block only ever
|
||||
matches while .elementor-hidden-laptop is hiding the button, so
|
||||
>= 1201px — every Mac (1440/1470/1512/1728 CSS px) and an
|
||||
EliteBook at 100% or 125% Windows scaling — is byte-identical. */
|
||||
@media (min-width: 1025px) and (max-width: 1200px) {
|
||||
#masthead .elementor-element.elementor-element-466de1b {
|
||||
grid-template-columns: auto minmax(0, 1fr) !important;
|
||||
}
|
||||
|
||||
#masthead .elementor-element.elementor-element-e44ee7e {
|
||||
justify-self: end !important;
|
||||
}
|
||||
}
|
||||
|
||||
#masthead .header-menu-container .main-menu > li.active > a:before {
|
||||
background-color: #ffffff !important;
|
||||
opacity: 1 !important;
|
||||
@@ -22,6 +22,9 @@ const ROUTE_CLASSES: Record<string, string> = {
|
||||
"wp-singular page-template-default page page-id-59 wp-theme-logico wp-child-theme-logico-child theme-logico ehf-header ehf-footer ehf-template-logico ehf-stylesheet-logico-child logico-front-end logico-theme-style-rounded elementor-default elementor-page elementor-page-59",
|
||||
"/miletruth": `${SHARED} page-id-59 elementor-page-59`,
|
||||
"/solutions": `${SHARED} page-id-59 elementor-page-59`,
|
||||
// Industries carries the original Solutions page's Elementor context — it is
|
||||
// the same migrated markup (solutions hero + industry cards), just re-routed.
|
||||
"/industries": `${SHARED} page-id-59 elementor-page-59`,
|
||||
// PHP source quirk: about-us.php has `home` class (upstream bug, preserved)
|
||||
"/about-us": `home ${SHARED} page-id-59 elementor-page-59`,
|
||||
// Reuse the About page Elementor/runtime context for the Wings Empowerment
|
||||