Compare commits
19 Commits
cursor/air
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e664f6e307 | |||
| ab7540c757 | |||
| 65ea0640f8 | |||
| 6e3f49bb94 | |||
| 040cc2ae46 | |||
| fee43cec3e | |||
| 136cc966c7 | |||
| ca83f34b11 | |||
| c29dc64c38 | |||
| 6aa7a47f14 | |||
| 9e45d51ed2 | |||
| ce602a454f | |||
| 0ec150d703 | |||
| a8f4b23977 | |||
| 4adf5b7b9f | |||
| bb7de1dcb6 | |||
| 8854c3d8ac | |||
| 05159a3208 | |||
| 71875ea20f |
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 532 KiB |
|
Before Width: | Height: | Size: 492 KiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 5.7 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 7.8 MiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 8.6 MiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 376 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 |
|
Before Width: | Height: | Size: 999 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 489 KiB |
|
Before Width: | Height: | Size: 11 MiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 6.7 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 395 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 546 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 8.0 MiB |
BIN
public/images/miletruth-ai-core.png
Normal file
|
After Width: | Height: | Size: 489 KiB |
|
Before Width: | Height: | Size: 10 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 5.9 MiB |
|
Before Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 2.4 MiB |
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 |
49
scripts/fix_preload.mjs
Normal 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
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,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",
|
||||
const postRoutes = blogPosts.map((post) => ({
|
||||
url: `${SITE_URL}/blog/${post.slug}`,
|
||||
lastModified: post.date,
|
||||
changeFrequency: "monthly" as const,
|
||||
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,
|
||||
},
|
||||
];
|
||||
}));
|
||||
|
||||
return staticRoutes;
|
||||
return [...staticRoutes, ...postRoutes];
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
<IndustryStack />
|
||||
<SolutionsHero slides={SOLUTIONS_SLIDES} />
|
||||
|
||||
{/* 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
|
||||
@@ -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">
|
||||
|
||||
@@ -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, { 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 & 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: "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||