19 Commits

Author SHA1 Message Date
e664f6e307 refactor: migrate shared infrastructure, home and industries features 2026-08-03 17:19:08 +05:30
ab7540c757 fix image and screen issue 2026-08-03 14:56:30 +05:30
65ea0640f8 fix mobile screen view issue 2026-08-03 11:27:21 +05:30
6e3f49bb94 update ev fleet image 2026-07-30 20:30:26 +05:30
040cc2ae46 fix solutions page 2026-07-29 18:45:01 +05:30
fee43cec3e update solution page 2026-07-29 17:47:05 +05:30
136cc966c7 update miletruth page 2026-07-27 19:59:25 +05:30
ca83f34b11 update solution page 2026-07-20 13:33:30 +05:30
c29dc64c38 update wings and workflow4 image 2026-07-15 15:14:41 +05:30
6aa7a47f14 comment wings page 2026-07-14 11:54:00 +05:30
9e45d51ed2 Mark sitemap route as force-static
Its content (fixed routes + blog post list) never needs per-request
dynamic execution, so pin it explicitly rather than relying on Next's
default inference.
2026-07-13 20:23:53 +05:30
ce602a454f Revert to standalone output: static export cannot run the SMTP Server Action
output: "export" produces pure static HTML with no server at runtime, so
the contact form's Server Action (src/actions/sendEmail.ts, nodemailer/SMTP)
can never execute under it - Next.js itself refuses to build with a Server
Action present under output: "export". The deploy architecture is being
switched to a Node standalone runtime (see doormile-build) instead, which
supports the existing Server Action unchanged.
2026-07-13 20:18:59 +05:30
0ec150d703 Revert to static export output for nginx deploy pipeline
output: "standalone" (a8f4b23) produces a Node server bundle with no
server.js at the path the deploy Dockerfile expects. The deploy repo's
Dockerfile now runs nginx serving static files (d808317), which requires
a static export (out/), not a standalone server bundle.
2026-07-13 20:09:35 +05:30
a8f4b23977 Configure Next.js standalone output for deployment 2026-07-13 17:54:13 +05:30
4adf5b7b9f fix rebuild issue 2026-07-13 17:35:12 +05:30
bb7de1dcb6 update footer 2026-07-13 15:27:34 +05:30
8854c3d8ac fix build issue 2026-07-13 15:20:36 +05:30
05159a3208 Merge branch 'cursor/air-freight-page' 2026-07-13 15:15:46 +05:30
71875ea20f cleanup the repo unused images 2026-07-03 16:35:42 +05:30
167 changed files with 14055 additions and 3145 deletions

3
.gitignore vendored
View File

@@ -42,3 +42,6 @@ next-env.d.ts
# Local backups of pre-optimized GLB source models (not served)
.glb-originals/
# Scraped demo assets
demo.artureanec.com/

View File

View File

@@ -14,7 +14,7 @@ const nextConfig: NextConfig = {
turbopack: {
root: path.resolve(__dirname),
},
output: "export",
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.
@@ -24,7 +24,6 @@ const nextConfig: NextConfig = {
// any other page's runtime behavior.
reactStrictMode: false,
images: {
unoptimized: true,
formats: ["image/avif", "image/webp"],
},
};

View File

@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
public/images/ev fleet1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

BIN
public/images/workflow4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

1
public/live-track.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 32 KiB

49
scripts/fix_preload.mjs Normal file
View File

@@ -0,0 +1,49 @@
import fs from 'fs';
import path from 'path';
const files = [
"src/components/sections/BlogsHero.tsx",
"src/components/sections/AboutHero.tsx",
"src/components/sections/SingleBlog.tsx",
"src/components/sections/ContactsHero.tsx",
"src/components/sections/IndexHero.tsx",
"src/components/sections/SolutionsHero.tsx",
"src/components/sections/MileTruthHero.tsx",
"src/components/sections/HowItWorksHero.tsx"
];
for (const f of files) {
let content = fs.readFileSync(f, 'utf8');
const match = content.match(/<link\s+rel="preload"\s+as="image"\s+href="([^"]+)"\s*\/>/);
if (match) {
const href = match[1];
if (!content.includes('import { preload } from "react-dom"')) {
const lastImportIndex = content.lastIndexOf('import ');
const endOfLastImport = content.indexOf('\n', lastImportIndex);
content = content.substring(0, endOfLastImport + 1) + 'import { preload } from "react-dom";\n' + content.substring(endOfLastImport + 1);
}
content = content.replace(match[0], '');
// Find the default export function
const fnMatch = content.match(/export default function\s+\w+\s*\([^)]*\)\s*\{/);
if (fnMatch) {
const insertIdx = fnMatch.index + fnMatch[0].length;
content = content.substring(0, insertIdx) + `\n preload("${href}", { as: "image" });` + content.substring(insertIdx);
} else {
// try non-default export
const fnMatch2 = content.match(/export function\s+\w+\s*\([^)]*\)\s*\{/);
if (fnMatch2) {
const insertIdx = fnMatch2.index + fnMatch2[0].length;
content = content.substring(0, insertIdx) + `\n preload("${href}", { as: "image" });` + content.substring(insertIdx);
} else {
console.log("Could not find function for " + f);
}
}
fs.writeFileSync(f, content, 'utf8');
console.log("Fixed " + f);
}
}

24
scripts/probe.mjs Normal file
View 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();

View 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();
}

View File

@@ -1,53 +0,0 @@
"use server";
import nodemailer from "nodemailer";
export async function sendContactEmail(data: {
fullName: string;
email: string;
subject: string;
message: string;
}) {
const { fullName, email, subject, message } = data;
if (!fullName || !email || !subject || !message) {
return { success: false, error: "All fields are required." };
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 465,
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
});
try {
await transporter.sendMail({
from: `"${fullName}" <${process.env.SMTP_USER}>`,
to: process.env.CONTACT_RECEIVER,
replyTo: email,
subject: `New Contact Form Submission: ${subject}`,
html: `
<div style="font-family: sans-serif; color: #333; max-width: 600px; margin: 0 auto; border: 1px solid #eee; padding: 20px; border-radius: 8px;">
<h2 style="color: #c01227; border-bottom: 2px solid #c01227; padding-bottom: 10px; margin-top: 0;">New Contact Form Submission</h2>
<p><strong>Name:</strong> ${fullName}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Subject:</strong> ${subject}</p>
<p><strong>Message:</strong></p>
<blockquote style="background: #f9f9f9; padding: 15px; border-left: 4px solid #c01227; margin: 0; font-style: italic;">
${message.replace(/\n/g, "<br />")}
</blockquote>
</div>
`,
});
return { success: true };
} catch (error) {
console.error("Email send error:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to send the email. Please try again later.";
return { success: false, error: errorMessage };
}
}

View File

@@ -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 };

View File

@@ -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() {

View File

@@ -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"],
},
};

View File

@@ -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"

View File

@@ -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;
}
/* ============================================================

View File

@@ -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() {

View 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>
);
}

View File

@@ -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<{

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,80 +1,31 @@
import type { MetadataRoute } from "next";
import { MetadataRoute } from "next";
import { blogPosts, SITE_URL } from "@/shared/data/blog";
export const dynamic = "force-static";
const BASE_URL = "https://doormile.com";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
const staticRoutes = [
"",
"/about",
"/contact",
"/blog",
"/air-freight",
"/solutions",
"/industries",
"/how-it-works",
].map((route) => ({
url: `${SITE_URL}${route}`,
lastModified: new Date().toISOString().split("T")[0],
changeFrequency: "weekly" as const,
priority: route === "" ? 1 : 0.8,
}));
const staticRoutes: MetadataRoute.Sitemap = [
{
url: `${BASE_URL}/`,
lastModified: now,
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${BASE_URL}/how-it-works`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/miletruth`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/solutions`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/doormile-wings`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/about-us`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.7,
},
{
url: `${BASE_URL}/blog`,
lastModified: now,
changeFrequency: "weekly",
priority: 0.7,
},
{
url: `${BASE_URL}/contact`,
lastModified: now,
changeFrequency: "yearly",
priority: 0.6,
},
{
url: `${BASE_URL}/privacy-policy`,
lastModified: now,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${BASE_URL}/terms-of-service`,
lastModified: now,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${BASE_URL}/cookie-policy`,
lastModified: now,
changeFrequency: "yearly",
priority: 0.3,
},
];
const postRoutes = blogPosts.map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`,
lastModified: post.date,
changeFrequency: "monthly" as const,
priority: 0.6,
}));
return staticRoutes;
return [...staticRoutes, ...postRoutes];
}

View File

@@ -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>
);
}

View File

@@ -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
@@ -65,7 +65,6 @@ export default function BlogSearch() {
setOpen(true);
}}
onFocus={() => setOpen(true)}
aria-expanded={showPanel}
aria-controls="dm-blog-search-results"
/>
<span className="dm-blog-search-icon" aria-hidden="true">

View File

@@ -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.

View File

@@ -1,528 +0,0 @@
"use client";
import React, { useEffect, useRef, useState, useSyncExternalStore } 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 &amp; 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 &amp; 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; }
}
`;

View File

@@ -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: "",

View File

@@ -228,7 +228,7 @@ export default function OfficeMap() {
{OFFICE_LOCATIONS.map((office) => {
const isFocused = focus?.id === office.id;
const isHovered = hoveredOfficeId === office.id;
return (
<Marker

View File

@@ -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);

View File

@@ -54,6 +54,7 @@ function CameraRig({ progress, framing }: { progress: React.RefObject<number>; f
// construction-time prop on <Canvas>, so update it imperatively here.
useEffect(() => {
if ("fov" in camera) {
// eslint-disable-next-line react-hooks/immutability
(camera as THREE_PerspectiveCamera).fov = framing.fov;
(camera as THREE_PerspectiveCamera).updateProjectionMatrix();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
import React from "react";
import { preload } from "react-dom";
export default function AboutHero() {
preload("/images/about-bg.webp", { as: "image" });
return (
<>
<link rel="preload" as="image" href="/images/about-bg.webp" />
<style dangerouslySetInnerHTML={{ __html: `
.about-us-hero-content {
width: 100% !important;

View File

@@ -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;

View File

@@ -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);

Some files were not shown because too many files have changed in this diff Show More