+ );
+}
diff --git a/src/components/sections/DeliveredCounter.tsx b/src/components/sections/DeliveredCounter.tsx
new file mode 100644
index 0000000..46690d0
--- /dev/null
+++ b/src/components/sections/DeliveredCounter.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+
+/* ============================================================
+ DELIVERED COUNTER — Solutions page
+
+ The big outlined stat that, in the Logico reference, sits between the
+ mile cards and the "what we do" carousel: a large count-up number over a
+ dotted world map, with a rotated caption alongside it.
+
+ Rebuilt with Doormile branding — the site's red accent instead of teal,
+ the project's own dotted-map asset (public/images/bg-map.png), and a
+ logistics stat. It renders INSIDE the Miles3 dark card (as a sibling of
+ the carousel), so it reads as part of the same section. Transparent
+ surface; everything scoped to #dcount-root.
+ ============================================================ */
+
+const TARGET = 24_836_517;
+const DURATION = 2200;
+
+/** de-DE groups thousands with ".", matching the reference's 358.895.941. */
+function format(n: number): string {
+ return Math.round(n).toLocaleString("de-DE");
+}
+
+export default function DeliveredCounter() {
+ const rootRef = useRef(null);
+ const [val, setVal] = useState(0);
+
+ useEffect(() => {
+ const el = rootRef.current;
+ if (!el) return;
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ let raf = 0;
+ let fired = false;
+
+ const io = new IntersectionObserver(
+ (entries) => {
+ const e = entries[0];
+ if (!e.isIntersecting || fired) return;
+ fired = true;
+ io.disconnect();
+ if (reduce) { setVal(TARGET); return; }
+ const start = performance.now();
+ const tick = (now: number) => {
+ const p = Math.min(1, (now - start) / DURATION);
+ const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic
+ setVal(TARGET * eased);
+ if (p < 1) raf = requestAnimationFrame(tick);
+ };
+ raf = requestAnimationFrame(tick);
+ },
+ { threshold: 0.35 }
+ );
+
+ io.observe(el);
+ return () => { io.disconnect(); cancelAnimationFrame(raf); };
+ }, []);
+
+ return (
+ <>
+
+
+
+
+ {format(val)}
+
+ Parcels delivered
+ end to end
+
+
+ {format(TARGET)} parcels delivered end to end
+
+
+
+ >
+ );
+}
+
+const CSS = `
+#dcount-root {
+ --pc-red-bright: #E2354A;
+ position: relative;
+ display: flex; align-items: center; justify-content: center;
+ padding: 0;
+ margin: 0;
+ overflow: visible;
+}
+#dcount-root, #dcount-root * { font-family: var(--font-manrope), "Manrope", sans-serif; box-sizing: border-box; }
+
+/* Dotted world map backdrop — naturally overlays the statistic counter */
+#dcount-root .dc-map {
+ position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
+ width: min(1300px, 98%); height: 100%; min-height: 380px; z-index: 0; pointer-events: none;
+ background: url(/images/bg-map.png) center / contain no-repeat;
+ filter: invert(1) brightness(2.2);
+ opacity: 0.18;
+ -webkit-mask-image: radial-gradient(85% 85% at 50% 50%, #000 50%, transparent 100%);
+ mask-image: radial-gradient(85% 85% at 50% 50%, #000 50%, transparent 100%);
+}
+
+#dcount-root .dc-fig {
+ position: relative; z-index: 1;
+ display: inline-flex; align-items: center; justify-content: center; gap: clamp(14px, 1.8vw, 30px);
+}
+
+/* Big outlined numeral, red stroke, transparent fill */
+#dcount-root .dc-num {
+ color: transparent;
+ -webkit-text-stroke: 2px var(--pc-red-bright);
+ font-size: clamp(58px, 12.5vw, 200px);
+ font-weight: 500; line-height: 0.92; letter-spacing: -0.01em;
+ font-variant-numeric: tabular-nums; white-space: nowrap;
+ text-shadow: 0 0 40px rgba(226,53,74,0.18);
+}
+/* Firefox/older engines without text-stroke: fall back to a faint red fill so
+ the number never disappears. */
+@supports not ((-webkit-text-stroke: 2px red)) {
+ #dcount-root .dc-num { color: rgba(226,53,74,0.85); }
+}
+
+/* Rotated caption, reading bottom-to-top like the reference */
+#dcount-root .dc-label {
+ writing-mode: vertical-rl; transform: rotate(180deg);
+ display: inline-flex; align-items: center; gap: 10px;
+ color: #fff; line-height: 1.15; padding-bottom: 4px;
+}
+#dcount-root .dc-label b { font-size: clamp(12px, 1.1vw, 15px); font-weight: 700; letter-spacing: 0.02em; }
+#dcount-root .dc-label i { font-style: normal; color: rgba(255,255,255,0.5); font-size: clamp(11px, 1vw, 14px); font-weight: 600; }
+
+/* Screen-reader-only final figure (the visible number animates via aria-hidden fig) */
+#dcount-root .dc-sr {
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
+}
+
+@media (max-width: 639px) {
+ #dcount-root { padding: 24px 0 20px; }
+ #dcount-root .dc-num { -webkit-text-stroke-width: 1.5px; }
+ #dcount-root .dc-fig { gap: 12px; }
+}
+`;
diff --git a/src/components/sections/EVSection.tsx b/src/components/sections/EVSection.tsx
index 233410a..1412f8e 100644
--- a/src/components/sections/EVSection.tsx
+++ b/src/components/sections/EVSection.tsx
@@ -343,7 +343,7 @@ function DashboardPanel({
}
export default function EVSection({
- bannerImage = "/images/bg-header-5.webp",
+ bannerImage = "/images/ev.webp",
cardNumber = "",
cardTitle = "EV Logistics",
cardSubtitle = "Cleaner miles, lower costs",
@@ -351,7 +351,7 @@ export default function EVSection({
titleLead = "BUILT FOR ELECTRIC. ",
titleAccent = "NOT ADAPTED.",
features = DEFAULT_FEATURES,
- image = "/images/premium-ev-van.webp",
+ image = "/images/ev.webp",
imageAlt = "DoorMile electric fleet van",
badges = DEFAULT_BADGES,
stats = DEFAULT_STATS,
@@ -768,20 +768,30 @@ export default function EVSection({
.evnd-feature__icon-container {
width: 48px; height: 48px;
display: flex; align-items: center; justify-content: center;
- background: rgba(255,255,255,0.03);
- border: 1px solid rgba(255,255,255,0.08);
+ background: rgba(239, 68, 68, 0.14);
+ border: 1px solid rgba(239, 68, 68, 0.35);
border-radius: 12px;
- transition: background-color 0.3s ease, border-color 0.3s ease;
+ color: #ef4444 !important;
+ transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
.evnd-feature:hover .evnd-feature__icon-container {
- background: rgba(239,68,68,0.08);
- border-color: rgba(239,68,68,0.25);
+ background: rgba(239, 68, 68, 0.25);
+ border-color: rgba(239, 68, 68, 0.6);
+ color: #ff5555 !important;
}
.evnd-icon {
width: 22px;
height: 22px;
display: block;
+ color: #ef4444 !important;
+ stroke: #ef4444 !important;
+ }
+ .evnd-icon path,
+ .evnd-icon circle,
+ .evnd-icon polygon,
+ .evnd-icon rect {
+ stroke: #ef4444 !important;
}
.evnd-feature__title {
@@ -1297,7 +1307,7 @@ export default function EVSection({
data-settings='{"background_background":"classic"}'
style={{
backgroundPosition: "center 0px",
- backgroundImage: `url(${bannerImage})`,
+ backgroundImage: `url("${encodeURI(bannerImage)}")`,
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
position: "relative",
diff --git a/src/components/sections/Miles3.tsx b/src/components/sections/Miles3.tsx
index 031de43..8c35f04 100644
--- a/src/components/sections/Miles3.tsx
+++ b/src/components/sections/Miles3.tsx
@@ -8,11 +8,20 @@ interface Miles3Props {
eyebrow?: string;
/** Section heading. Defaults to the original How It Works copy. */
heading?: string;
+ /**
+ * Extra content rendered INSIDE the same dark card, directly below the
+ * First/Mid/Last Mile grid — so it reads as one continuous section rather
+ * than a new one. Used on the Solutions page for the Platform Capabilities
+ * carousel. When present, the card also gains a bottom pad so the appended
+ * block never touches the rounded edge.
+ */
+ children?: React.ReactNode;
}
export default function Miles3({
eyebrow = "/ How It Works /",
heading = "Doormile connects first, mid, and last mile into a seamless end-to-end logistics experience",
+ children,
}: Miles3Props = {}) {
return (
<>
@@ -31,6 +40,7 @@ export default function Miles3({
.elementor-element-c36a604 {
display: flex;
flex-direction: column;
+ justify-content: flex-start;
width: auto;
margin: 20px 20px 0 20px;
background-color: #1F1F1F;
@@ -49,6 +59,7 @@ export default function Miles3({
.elementor-element-77d1265 {
display: flex;
flex-direction: column;
+ justify-content: flex-start;
width: 100%;
max-width: 1630px;
margin: 0 auto;
@@ -114,6 +125,13 @@ export default function Miles3({
not grid-template-columns directly, or the grid never collapses on mobile. */
--e-con-grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, 1fr);
+ /* Same trap for the ROWS: ".e-con.e-grid" also drives grid-template-rows
+ from a var that defaults to repeat(2, 1fr), reserving a second, EMPTY
+ row (plus the 70px row-gap) under the 3 mile cards — ~380px of dead
+ space before whatever follows in the card. "auto" sizes the one real
+ row to its content and still lets the 2-col/1-col breakpoints create
+ implicit rows. */
+ --e-con-grid-template-rows: auto;
grid-auto-flow: row;
gap: 70px 60px;
width: 100%;
@@ -171,6 +189,22 @@ export default function Miles3({
margin: 0;
}
+ /* Continuation block (Platform Capabilities carousel) — lives inside the
+ same dark card, below the mile grid. Its own top border + generous
+ top/bottom rhythm keep it reading as "more of this section". The
+ card itself has no bottom padding, so this block owns the bottom
+ breathing room too. */
+ .miles3-continue {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ width: 100%;
+ min-width: 0;
+ align-self: stretch;
+ margin-top: 28px;
+ padding-bottom: clamp(60px, 6vw, 90px);
+ }
+
/* ---- Responsive (grid breakpoints mirror the original section-miles3
cascade: 3-col@120gap >1200, 3-col@40gap ≤1200, 2-col ≤1020, 1-col
≤480). The ≤1200 step is what keeps columns wide enough at laptop
@@ -282,6 +316,8 @@ export default function Miles3({
+ {children ?
{children}
: null}
+
diff --git a/src/components/sections/NeuralField.tsx b/src/components/sections/NeuralField.tsx
new file mode 100644
index 0000000..bffc236
--- /dev/null
+++ b/src/components/sections/NeuralField.tsx
@@ -0,0 +1,332 @@
+"use client";
+
+import React, { useEffect, useRef } from "react";
+
+/* ============================================================
+ NeuralField — the animated neural-network + particle backdrop behind the
+ MileTruth™ AI band on /solutions.
+
+ Deliberately a plain 2D canvas rather than the R3F MileTruthCanvas scene:
+ this one sits *behind* body copy at low opacity, so it has to stay cheap
+ enough to run under the fold of a long marketing page. It follows the same
+ rules as the other canvases on the site (WorkflowScene, MileTruthNetwork):
+
+ · DPR-aware, resized from the parent box (ResizeObserver)
+ · rAF paused while off-screen and while the tab is hidden
+ · prefers-reduced-motion paints ONE static frame and stops
+
+ Three layers, back to front:
+ 1. particle field — tiny drifting motes, no links
+ 2. neural mesh — drifting nodes + proximity links, accent-tinted as
+ the two ends get closer
+ 3. signals — a few bright packets travelling node-to-node, which
+ is what reads as "a decision engine thinking"
+ ============================================================ */
+
+type Node = {
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ r: number;
+ hub: boolean;
+ /** phase offset so hub glow does not pulse in lockstep */
+ ph: number;
+};
+
+type Mote = { x: number; y: number; vy: number; sway: number; ph: number; a: number };
+
+type Signal = { a: number; b: number; t: number; sp: number };
+
+/** Distance at which two nodes stop being linked (CSS px). */
+const LINK = 132;
+/** Pointer radius that brightens the mesh. */
+const CURSOR = 170;
+
+function hexToRgb(hex: string): [number, number, number] {
+ const h = hex.replace("#", "");
+ const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
+ const n = parseInt(full, 16);
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+}
+
+export default function NeuralField({
+ accent = "#E2354A",
+ tone = "dark",
+ className,
+}: {
+ accent?: string;
+ /** Surface the field is painted on. "light" inks the mesh near-black instead
+ * of white and drops the additive glow, which is invisible on paper. */
+ tone?: "dark" | "light";
+ className?: string;
+}) {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const parent = canvas?.parentElement;
+ if (!canvas || !parent) return;
+ const ctx = canvas.getContext("2d", { alpha: true });
+ if (!ctx) return;
+
+ const [ar, ag, ab] = hexToRgb(accent);
+ const light = tone === "light";
+ // The ink the mesh fades towards when a link is long / far from the pointer.
+ const [ir, ig, ib] = light ? [17, 17, 17] : [255, 255, 255];
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+ let w = 0;
+ let h = 0;
+ let nodes: Node[] = [];
+ let motes: Mote[] = [];
+ let signals: Signal[] = [];
+ let raf = 0;
+ let visible = true;
+ let running = false;
+ let last = 0;
+ let nextSignal = 600;
+ const pointer = { x: -9999, y: -9999 };
+
+ const rand = (a: number, b: number) => a + Math.random() * (b - a);
+
+ function build() {
+ const area = w * h;
+ // One node per ~24k css px², clamped — enough to read as a mesh at
+ // 1440px wide without turning the O(n²) link pass into real work.
+ const count = Math.max(16, Math.min(72, Math.round(area / 24000)));
+ nodes = Array.from({ length: count }, (_, i) => ({
+ x: Math.random() * w,
+ y: Math.random() * h,
+ vx: rand(-0.14, 0.14),
+ vy: rand(-0.11, 0.11),
+ r: rand(1.1, 2.3),
+ hub: i % 9 === 0,
+ ph: Math.random() * Math.PI * 2,
+ }));
+ motes = Array.from({ length: Math.min(140, Math.round(area / 9000)) }, () => ({
+ x: Math.random() * w,
+ y: Math.random() * h,
+ vy: rand(-0.22, -0.06),
+ sway: rand(6, 20),
+ ph: Math.random() * Math.PI * 2,
+ a: rand(0.06, 0.28),
+ }));
+ signals = [];
+ }
+
+ function resize() {
+ const rect = parent!.getBoundingClientRect();
+ if (!rect.width || !rect.height) return;
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
+ w = rect.width;
+ h = rect.height;
+ canvas!.width = Math.round(w * dpr);
+ canvas!.height = Math.round(h * dpr);
+ canvas!.style.width = w + "px";
+ canvas!.style.height = h + "px";
+ ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
+ build();
+ if (reduce) draw(0, 0);
+ }
+
+ function spawnSignal() {
+ if (nodes.length < 2 || signals.length >= 3) return;
+ const a = Math.floor(Math.random() * nodes.length);
+ // Prefer a neighbour that is actually linked, so the packet rides a
+ // visible edge instead of crossing empty space.
+ let b = -1;
+ let best = Infinity;
+ for (let i = 0; i < nodes.length; i++) {
+ if (i === a) continue;
+ const d = Math.hypot(nodes[i].x - nodes[a].x, nodes[i].y - nodes[a].y);
+ if (d < LINK && d < best && Math.random() > 0.45) {
+ best = d;
+ b = i;
+ }
+ }
+ if (b < 0) return;
+ signals.push({ a, b, t: 0, sp: rand(0.5, 0.95) });
+ }
+
+ function draw(dt: number, time: number) {
+ ctx!.clearRect(0, 0, w, h);
+
+ /* ---- 1. particle field ---- */
+ for (const m of motes) {
+ ctx!.beginPath();
+ ctx!.arc(m.x + Math.sin(time * 0.0004 + m.ph) * m.sway, m.y, 0.9, 0, Math.PI * 2);
+ ctx!.fillStyle = "rgba(" + ir + "," + ig + "," + ib + "," + m.a * (light ? 0.4 : 0.55) + ")";
+ ctx!.fill();
+ }
+
+ /* ---- 2. neural mesh ---- */
+ ctx!.lineWidth = 1;
+ for (let i = 0; i < nodes.length; i++) {
+ const a = nodes[i];
+ for (let j = i + 1; j < nodes.length; j++) {
+ const b = nodes[j];
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ const d2 = dx * dx + dy * dy;
+ if (d2 > LINK * LINK) continue;
+ const d = Math.sqrt(d2);
+ const prox = 1 - d / LINK;
+ // Near the pointer the mesh warms towards the accent and brightens.
+ const mx = (a.x + b.x) * 0.5;
+ const my = (a.y + b.y) * 0.5;
+ const cur = Math.max(0, 1 - Math.hypot(mx - pointer.x, my - pointer.y) / CURSOR);
+ const tint = Math.min(1, prox * 0.85 + cur * 0.6);
+ const alpha = (prox * 0.3 + cur * 0.35) * (light ? 0.6 : 1);
+ const r = Math.round(ir + (ar - ir) * tint);
+ const g = Math.round(ig + (ag - ig) * tint);
+ const bl = Math.round(ib + (ab - ib) * tint);
+ ctx!.strokeStyle = "rgba(" + r + "," + g + "," + bl + "," + alpha.toFixed(3) + ")";
+ ctx!.beginPath();
+ ctx!.moveTo(a.x, a.y);
+ ctx!.lineTo(b.x, b.y);
+ ctx!.stroke();
+ }
+ }
+
+ for (const n of nodes) {
+ const cur = Math.max(0, 1 - Math.hypot(n.x - pointer.x, n.y - pointer.y) / CURSOR);
+ if (n.hub) {
+ const pulse = 0.5 + 0.5 * Math.sin(time * 0.0016 + n.ph);
+ ctx!.save();
+ // An additive bloom only reads on a dark surface; on paper it muddies.
+ if (!light) {
+ ctx!.shadowColor = "rgba(" + ar + "," + ag + "," + ab + ",0.9)";
+ ctx!.shadowBlur = 10 + pulse * 12;
+ }
+ ctx!.beginPath();
+ ctx!.arc(n.x, n.y, n.r + 0.7 + pulse * 0.6, 0, Math.PI * 2);
+ ctx!.fillStyle = "rgba(" + ar + "," + ag + "," + ab + "," + (0.5 + pulse * 0.35) * (light ? 0.7 : 1) + ")";
+ ctx!.fill();
+ ctx!.restore();
+ } else {
+ ctx!.beginPath();
+ ctx!.arc(n.x, n.y, n.r, 0, Math.PI * 2);
+ ctx!.fillStyle = "rgba(" + ir + "," + ig + "," + ib + "," + (0.16 + cur * 0.4) * (light ? 0.55 : 1) + ")";
+ ctx!.fill();
+ }
+ }
+
+ /* ---- 3. travelling signals ---- */
+ for (const s of signals) {
+ const a = nodes[s.a];
+ const b = nodes[s.b];
+ if (!a || !b) continue;
+ const x = a.x + (b.x - a.x) * s.t;
+ const y = a.y + (b.y - a.y) * s.t;
+ // Fade in and out so packets never pop at the endpoints.
+ const fade = Math.sin(s.t * Math.PI);
+ ctx!.save();
+ if (!light) {
+ ctx!.shadowColor = "rgba(" + ar + "," + ag + "," + ab + ",1)";
+ ctx!.shadowBlur = 14;
+ }
+ ctx!.beginPath();
+ ctx!.arc(x, y, 1.9, 0, Math.PI * 2);
+ // On paper the packet is the accent itself; on dark it burns towards white.
+ ctx!.fillStyle = light
+ ? "rgba(" + ar + "," + ag + "," + ab + "," + fade + ")"
+ : "rgba(255," + Math.round(ag * 0.7 + 90) + "," + Math.round(ab * 0.7 + 90) + "," + fade + ")";
+ ctx!.fill();
+ ctx!.restore();
+ }
+
+ if (reduce) return;
+
+ /* ---- integrate ---- */
+ const step = Math.min(3, dt / 16.67);
+ for (const n of nodes) {
+ n.x += n.vx * step;
+ n.y += n.vy * step;
+ if (n.x < -20) n.x = w + 20;
+ if (n.x > w + 20) n.x = -20;
+ if (n.y < -20) n.y = h + 20;
+ if (n.y > h + 20) n.y = -20;
+ }
+ for (const m of motes) {
+ m.y += m.vy * step;
+ if (m.y < -10) {
+ m.y = h + 10;
+ m.x = Math.random() * w;
+ }
+ }
+ for (let i = signals.length - 1; i >= 0; i--) {
+ signals[i].t += (signals[i].sp * step) / 60;
+ if (signals[i].t >= 1) signals.splice(i, 1);
+ }
+ nextSignal -= dt;
+ if (nextSignal <= 0) {
+ spawnSignal();
+ nextSignal = rand(700, 1800);
+ }
+ }
+
+ function frame(time: number) {
+ const dt = last ? time - last : 16.67;
+ last = time;
+ draw(dt, time);
+ raf = requestAnimationFrame(frame);
+ }
+
+ function start() {
+ if (running || reduce) return;
+ running = true;
+ last = 0;
+ raf = requestAnimationFrame(frame);
+ }
+ function stop() {
+ running = false;
+ cancelAnimationFrame(raf);
+ }
+
+ const ro = new ResizeObserver(resize);
+ ro.observe(parent);
+ resize();
+
+ const io = new IntersectionObserver(
+ (entries) => {
+ visible = entries.some((e) => e.isIntersecting);
+ if (visible && !document.hidden) start();
+ else stop();
+ },
+ { rootMargin: "120px 0px" },
+ );
+ io.observe(canvas);
+
+ const onVisibility = () => {
+ if (document.hidden) stop();
+ else if (visible) start();
+ };
+ const onMove = (e: PointerEvent) => {
+ const rect = canvas.getBoundingClientRect();
+ pointer.x = e.clientX - rect.left;
+ pointer.y = e.clientY - rect.top;
+ };
+ const onLeave = () => {
+ pointer.x = -9999;
+ pointer.y = -9999;
+ };
+
+ document.addEventListener("visibilitychange", onVisibility);
+ // Listener on the section (not the canvas — it is pointer-events: none).
+ const host = parent.closest("section") ?? parent;
+ host.addEventListener("pointermove", onMove as EventListener);
+ host.addEventListener("pointerleave", onLeave);
+
+ return () => {
+ stop();
+ ro.disconnect();
+ io.disconnect();
+ document.removeEventListener("visibilitychange", onVisibility);
+ host.removeEventListener("pointermove", onMove as EventListener);
+ host.removeEventListener("pointerleave", onLeave);
+ };
+ }, [accent, tone]);
+
+ return ;
+}
diff --git a/src/components/sections/PlatformCapabilities.tsx b/src/components/sections/PlatformCapabilities.tsx
new file mode 100644
index 0000000..8a7f6c0
--- /dev/null
+++ b/src/components/sections/PlatformCapabilities.tsx
@@ -0,0 +1,1124 @@
+"use client";
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import Image from "next/image";
+import Link from "next/link";
+
+/* ============================================================
+ PLATFORM CAPABILITIES — Solutions page
+
+ A Logico-style horizontal CARD carousel: multiple rounded image cards on a
+ sliding track, three visible at a time on desktop. Each card is a full-bleed
+ operational photograph under a graded scrim, with glassmorphic UI widgets
+ composited on top, plus a title, one-line description and a CTA — the point
+ being that a visitor reads "real logistics platform", not "concept art".
+
+ Placement: this renders INSIDE the Miles3 dark card (as its `children`),
+ directly below the First/Mid/Last Mile grid — so the whole thing reads as
+ ONE continuous "What We Offer" section, exactly like the Logico reference
+ where the feature cards sit underneath the logistics overview. It is
+ therefore transparent (no inset card of its own) and inherits the dark
+ #1F1F1F surface from the card above.
+
+ Everything is scoped to `#pcap-root`; nothing here touches the surrounding
+ `.sol-*` / `.elementor-*` styles.
+ ============================================================ */
+
+/* ---------------------------------------------------------------
+ Icons
+ --------------------------------------------------------------- */
+function Arrow({ dir }: { dir: "prev" | "next" }) {
+ return (
+
+ );
+}
+
+/* ---------------------------------------------------------------
+ Slide data
+ --------------------------------------------------------------- */
+type Capability = {
+ id: string;
+ title: string;
+ /** One-line description shown on the card face. */
+ blurb: string;
+ desc: string;
+ benefits: string[];
+ cta: { label: string; href: string };
+ /**
+ * Full-bleed operational photograph behind the glass overlays. Every shot is
+ * a real Doormile scene from the project's own library — deliberately none of
+ * the images already used elsewhere on /solutions, so no photo repeats within
+ * the page. `pos` is object-position, tuned per image for the 63:90 crop.
+ */
+ photo: {
+ src: string;
+ alt: string;
+ pos?: string;
+ /**
+ * How many card-widths of source pixels `object-fit: cover` actually needs.
+ * The cards are portrait (63:90) but most of these photographs are
+ * landscape, so cover scales them to match the card's HEIGHT and the
+ * intrinsic width required is `cardHeight × sourceAspect` — up to 3.6× the
+ * card's own width. Feeding next/image a plain "31vw" would hand back a
+ * 468px file that then gets upscaled 3.5× and looks soft, so `sizes` is
+ * derived from this instead. Value = (90/63) × (sourceW / sourceH).
+ */
+ cover: number;
+ };
+ /** Glass UI widgets composited over the photograph. */
+ ui: {
+ live: string;
+ metric: string;
+ metricLabel: string;
+ rows?: { k: string; v: string }[];
+ bars?: number[];
+ /** Thin progress meter under the figures. */
+ progress?: { label: string; value: number };
+ /** Small confirmation line closing the panel. */
+ stamp?: string;
+ note?: string;
+ };
+};
+
+const CAPS: Capability[] = [
+ {
+ id: "route",
+ title: "AI Route Optimization",
+ blurb:
+ "The best route, planned before the driver ever pulls out of the depot.",
+ desc:
+ "MileTruth™ plans every run before the day starts — sequencing drops, respecting capacity and reading traffic, so the route the driver gets is already the best one available.",
+ benefits: [
+ "Multi-stop sequencing solved in milliseconds, not on a whiteboard",
+ "Live traffic and road conditions folded back into the plan mid-trip",
+ "Capacity and SLA constraints respected on every candidate route",
+ "42% average distance saved against manually planned runs",
+ ],
+ cta: { label: "See how MileTruth™ plans", href: "/miletruth" },
+ photo: {
+ src: "/images/miletruth-bg.webp",
+ alt: "Doormile trucks running an expressway corridor, seen from above",
+ pos: "50% 50%",
+ cover: 3.6,
+ },
+ ui: {
+ live: "ROUTE SOLVER",
+ metric: "45ms",
+ metricLabel: "Plan latency",
+ rows: [
+ { k: "Candidates evaluated", v: "47" },
+ { k: "Chosen route", v: "4B" },
+ { k: "Distance saved", v: "42%" },
+ ],
+ stamp: "Route 4B locked",
+ },
+ },
+ {
+ id: "pickup",
+ title: "Smart Pickup Management",
+ blurb:
+ "Every pickup booked, batched and sent to the nearest available unit.",
+ desc:
+ "Pickups are booked, batched and assigned to the nearest available unit automatically — no phone tree, no guessing who is closest to your dock.",
+ benefits: [
+ "Nearest-unit assignment with skill and vehicle matching",
+ "Pickup windows confirmed to the shipper before the driver moves",
+ "Geofenced arrival and departure, timestamped on the record",
+ "Failed-pickup exceptions escalated the moment they happen",
+ ],
+ cta: { label: "Book a pickup demo", href: "/contact" },
+ photo: {
+ src: "/images/card%203.png",
+ alt: "Doormile crew loading a truck at a dispatch dock",
+ pos: "50% 50%",
+ cover: 1.8,
+ },
+ ui: {
+ live: "DISPATCH QUEUE",
+ metric: "4 min",
+ metricLabel: "ETA to origin",
+ rows: [
+ { k: "Driver", v: "#481 · EV Van" },
+ { k: "Distance", v: "1.2 km" },
+ { k: "Window", v: "09:00 – 10:00" },
+ ],
+ stamp: "Unit #481 assigned",
+ },
+ },
+ {
+ id: "fleet",
+ title: "Fleet Intelligence",
+ blurb:
+ "Position, load, charge and health for every vehicle on one live canvas.",
+ desc:
+ "Every vehicle reports position, load and health continuously, so utilisation, charge state and maintenance stop being a monthly spreadsheet exercise.",
+ benefits: [
+ "Live position, speed and load for every unit on one canvas",
+ "Charge and range planned into the day before the vehicle leaves",
+ "Utilisation and idle time surfaced per vehicle, not per depot",
+ "Maintenance flags raised from telemetry, ahead of a breakdown",
+ ],
+ cta: { label: "Explore fleet intelligence", href: "/miletruth" },
+ photo: {
+ src: "/images/mid-mile-approach.webp",
+ alt: "A line of Doormile vans staged at a mid-mile hub",
+ pos: "50% 55%",
+ cover: 1.45,
+ },
+ ui: {
+ live: "FLEET TELEMETRY",
+ metric: "94%",
+ metricLabel: "Capacity fill",
+ bars: [46, 72, 58, 88, 64, 94, 76],
+ progress: { label: "Charge across fleet", value: 78 },
+ stamp: "GPS · 128 units reporting",
+ note: "Illustrative console data",
+ },
+ },
+ {
+ id: "warehouse",
+ title: "Warehouse Coordination",
+ blurb:
+ "Dock time booked against the plan the road is already running on.",
+ desc:
+ "Inbound and outbound are sequenced against the same plan the road runs on, so dock time is booked rather than queued for and nothing waits on a phone call.",
+ benefits: [
+ "Dock slots allocated against the actual arrival plan",
+ "Inbound manifests matched and validated before the truck lands",
+ "Put-away and pick lists driven off the live order record",
+ "Cross-dock handovers tracked as one movement, not two",
+ ],
+ cta: { label: "Talk to our team", href: "/contact" },
+ photo: {
+ src: "/images/card6.png",
+ alt: "Sorting floor of a Doormile fulfilment centre",
+ pos: "50% 50%",
+ cover: 1.8,
+ },
+ ui: {
+ live: "DOCK SCHEDULE",
+ metric: "0",
+ metricLabel: "Unplanned waits",
+ rows: [
+ { k: "Bay 03 · Inbound", v: "11:40" },
+ { k: "Bay 07 · Cross-dock", v: "12:15" },
+ { k: "Bay 09 · Outbound", v: "13:05" },
+ ],
+ stamp: "Shift tasks 82% complete",
+ },
+ },
+ {
+ id: "control",
+ title: "Live Operations Control",
+ blurb:
+ "One control-room view — the trips at risk find you, not the other way round.",
+ desc:
+ "One control room view of every shipment in motion. Exceptions come to you — you are alerted to the three trips at risk, not the fourteen hundred that are fine.",
+ benefits: [
+ "Every order, driver and shipment on a single live map",
+ "Delay risk flagged around 30 minutes before it becomes a breach",
+ "3x faster response to exceptions than reactive dispatch",
+ "One source of status for ops, sales and the customer",
+ ],
+ cta: { label: "See it on your operation", href: "/contact" },
+ photo: {
+ src: "/images/first-mile-approach.webp",
+ alt: "Doormile dispatch area with a live operations wall",
+ pos: "34% 50%",
+ cover: 1.45,
+ },
+ ui: {
+ live: "CONTROL ROOM",
+ metric: "3",
+ metricLabel: "Trips at risk",
+ rows: [
+ { k: "Corridor E-3", v: "68% risk" },
+ { k: "Corridor S-2", v: "34% risk" },
+ { k: "Corridor N-1", v: "22% risk" },
+ ],
+ stamp: "2 alerts acknowledged",
+ note: "Illustrative console data",
+ },
+ },
+ {
+ id: "pod",
+ title: "Proof of Delivery",
+ blurb:
+ "OTP, signature and photo at the door, in billing the same second.",
+ desc:
+ "Delivery is confirmed at the door with OTP, signature and photo — and that proof reaches billing the same second, not three days later in a paper envelope.",
+ benefits: [
+ "OTP-verified handover with digital signature and photo capture",
+ "Proof attached to the shipment record, permanently auditable",
+ "Billing triggered on delivery, so invoicing runs same-day",
+ "Disputes answered with the timestamped record, not recollection",
+ ],
+ cta: { label: "See digital POD", href: "/contact" },
+ photo: {
+ src: "/images/last-mile-approach.webp",
+ alt: "Doormile driver handing a parcel to a customer at the door",
+ pos: "50% 50%",
+ cover: 1.45,
+ },
+ ui: {
+ live: "POD CAPTURE",
+ metric: "99.2%",
+ metricLabel: "On-time delivery",
+ rows: [
+ { k: "OTP", v: "Verified" },
+ { k: "Signature", v: "Captured" },
+ { k: "Pushed to billing", v: "Instant" },
+ ],
+ stamp: "Delivered 14:22 · signed",
+ },
+ },
+ {
+ id: "cx",
+ title: "Customer Experience",
+ blurb:
+ "Your customer watches the same live link your control room does.",
+ desc:
+ "Your customer watches the same live link your control room is watching. The status call stops happening because the answer is already on their screen.",
+ benefits: [
+ "One shared tracking link, from first mile to the door",
+ "ETAs recalculated live and pushed, not requested",
+ "Proactive delay notice before the customer notices",
+ "Branded tracking that stays yours end to end",
+ ],
+ cta: { label: "Improve your CX", href: "/contact" },
+ photo: {
+ src: "/images/workflow4.png",
+ alt: "Customer following a Doormile delivery on a live tracking link",
+ pos: "50% 50%",
+ cover: 2.8,
+ },
+ ui: {
+ live: "CUSTOMER TRACKING",
+ metric: "14:22",
+ metricLabel: "Live ETA",
+ rows: [
+ { k: "Link status", v: "Customer viewing" },
+ { k: "ETA updates", v: "Automatic" },
+ { k: "Status calls", v: "0" },
+ ],
+ stamp: "★ 4.9 satisfaction",
+ },
+ },
+ {
+ id: "analytics",
+ title: "Analytics & Reports",
+ blurb:
+ "Cost, SLA and utilisation straight from the records the operation ran on.",
+ desc:
+ "Cost per kilometre, SLA performance and utilisation come out of the same records the operation ran on — so the monthly review argues about decisions, not about numbers.",
+ benefits: [
+ "Cost per km, per route, per client — from live operational data",
+ "SLA and on-time performance tracked continuously, not sampled",
+ "Emissions and EV utilisation ready for sustainability reporting",
+ "Scheduled exports and API access into your own BI stack",
+ ],
+ cta: { label: "See the reporting layer", href: "/miletruth" },
+ photo: {
+ src: "/images/blog-post-pic-15.webp",
+ alt: "A line haul fleet staged overnight before dispatch",
+ pos: "50% 45%",
+ cover: 1,
+ },
+ ui: {
+ live: "PERFORMANCE",
+ metric: "40%",
+ metricLabel: "Manual tasks removed",
+ bars: [38, 52, 47, 66, 71, 84, 92],
+ progress: { label: "SLA attainment", value: 96 },
+ stamp: "Q3 · exported to your BI",
+ note: "Illustrative console data",
+ },
+ },
+];
+
+/* ---------------------------------------------------------------
+ Glass UI overlays
+
+ The cards are photographs now, not drawings. Each one carries a small set of
+ glassmorphic widgets — a live pill, a metric panel, a confirmation line —
+ composited over the image so the card reads as "software running on a real
+ operation" rather than as concept art. Every value comes from the slide's
+ own `ui` block; nothing here is decorative-only.
+ --------------------------------------------------------------- */
+function CapOverlay({ index }: { index: number }) {
+ const { ui } = CAPS[index];
+ const bars = ui.bars;
+
+ return (
+
+ {/* Routing is the one card that earns a drawn element: the chosen line
+ tracing the corridor in the photograph underneath it. */}
+ {index === 0 && (
+
+ )}
+
+
+ {index === 0 && AI}
+
+
+ {ui.live}
+
+
+
+
+
+ {ui.metric}
+ {ui.metricLabel}
+
+
+ {ui.rows && (
+
+ {ui.rows.map((r) => (
+
+ {r.k}
+ {r.v}
+
+ ))}
+
+ )}
+
+ {bars && (
+
+ {bars.map((b, i) => (
+
+ ))}
+
+ )}
+
+ {ui.progress && (
+
+
+ {ui.progress.label}
+ {ui.progress.value}%
+
+
+
+
+
+ )}
+
+ {ui.stamp && (
+
+
+ {ui.stamp}
+
+ )}
+
+
+ );
+}
+
+/* ---------------------------------------------------------------
+ Section — Logico-style multi-card carousel
+ --------------------------------------------------------------- */
+const AUTOPLAY_MS = 5200;
+const GAP = 24; // px between cards — keep in sync with --pcap-gap below
+
+/* A card occupies roughly 31vw / 46vw / 92vw at the three breakpoints below.
+ `cover` scales that up to the width the cropped photo actually needs (see
+ Capability["photo"]), so each image is fetched sharp without every card
+ paying for the widest one. */
+function coverSizes(cover: number): string {
+ const at = (vw: number) => Math.round(vw * cover);
+ return `(max-width: 639px) ${at(92)}vw, (max-width: 1023px) ${at(46)}vw, ${at(31)}vw`;
+}
+
+export default function PlatformCapabilities() {
+ const viewportRef = useRef(null);
+ const [perView, setPerView] = useState(3);
+ const [vpW, setVpW] = useState(0);
+ const [index, setIndex] = useState(0);
+ const [drag, setDrag] = useState(0);
+ const [dragging, setDragging] = useState(false);
+ const [paused, setPaused] = useState(false);
+ const [reduce, setReduce] = useState(false);
+ const startX = useRef(0);
+
+ const total = CAPS.length;
+ const maxIndex = Math.max(0, total - perView);
+
+ /* Measure the viewport so card width + slide distance are exact, and pick
+ how many cards are visible from the real width (3 / 2 / 1). */
+ useEffect(() => {
+ const el = viewportRef.current;
+ if (!el) return;
+ const measure = () => {
+ const w = el.clientWidth;
+ setVpW(w);
+ setPerView(w < 640 ? 1 : w < 1024 ? 2 : 3);
+ };
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const m = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const f = () => setReduce(m.matches);
+ f();
+ m.addEventListener?.("change", f);
+ return () => m.removeEventListener?.("change", f);
+ }, []);
+
+ // Keep the index valid when the visible count changes.
+ useEffect(() => {
+ setIndex((i) => Math.min(i, Math.max(0, total - perView)));
+ }, [perView, total]);
+
+ const cardW = vpW > 0 ? (vpW - (perView - 1) * GAP) / perView : 0;
+ const step = cardW + GAP;
+
+ const clampIdx = useCallback(
+ (n: number) => Math.max(0, Math.min(n, maxIndex)),
+ [maxIndex]
+ );
+ const go = useCallback((n: number) => setIndex(clampIdx(n)), [clampIdx]);
+ const prev = useCallback(() => setIndex((i) => (i <= 0 ? maxIndex : i - 1)), [maxIndex]);
+ const next = useCallback(() => setIndex((i) => (i >= maxIndex ? 0 : i + 1)), [maxIndex]);
+
+ /* Autoplay — advances one card, wraps at the end. Pauses on hover, focus and
+ while dragging; off entirely for reduced-motion. */
+ useEffect(() => {
+ if (reduce || paused || dragging || maxIndex === 0) return;
+ const id = window.setInterval(() => {
+ setIndex((i) => (i >= maxIndex ? 0 : i + 1));
+ }, AUTOPLAY_MS);
+ return () => window.clearInterval(id);
+ }, [reduce, paused, dragging, maxIndex]);
+
+ /* Pointer drag / touch swipe — one handler covers mouse and touch. */
+ const onPointerDown = useCallback((e: React.PointerEvent) => {
+ if (e.button != null && e.button !== 0) return;
+ setDragging(true);
+ setPaused(true);
+ startX.current = e.clientX;
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
+ }, []);
+
+ const onPointerMove = useCallback(
+ (e: React.PointerEvent) => {
+ if (!dragging) return;
+ setDrag(e.clientX - startX.current);
+ },
+ [dragging]
+ );
+
+ const endDrag = useCallback(
+ (e: React.PointerEvent) => {
+ if (!dragging) return;
+ const dx = e.clientX - startX.current;
+ setDragging(false);
+ setDrag(0);
+ if (step > 0 && Math.abs(dx) > 8) {
+ const shift = Math.round(-dx / step) || (dx < 0 ? 1 : -1);
+ if (Math.abs(dx) > 40 || Math.abs(shift) >= 1) go(index + shift);
+ }
+ window.setTimeout(() => setPaused(false), 350);
+ },
+ [dragging, step, index, go]
+ );
+
+ const onKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "ArrowRight") { e.preventDefault(); next(); }
+ if (e.key === "ArrowLeft") { e.preventDefault(); prev(); }
+ },
+ [next, prev]
+ );
+
+ // Track offset, with a gentle rubber-band past either end while dragging.
+ const tx = useMemo(() => {
+ const minTx = -maxIndex * step;
+ let t = -index * step + (dragging ? drag : 0);
+ if (dragging) {
+ if (t > 0) t = t * 0.35;
+ else if (t < minTx) t = minTx + (t - minTx) * 0.35;
+ }
+ return t;
+ }, [index, step, dragging, drag, maxIndex]);
+
+ const num = String(Math.min(index + 1, total)).padStart(2, "0");
+ const totalStr = String(total).padStart(2, "0");
+
+ return (
+ <>
+
+
+
+
+ / Platform Capabilities /
+
+ EVERYTHING THE PLATFORM
+ DOES FOR YOUR OPERATION.
+
+
+ The services above are what we move. This is what runs underneath them —
+ eight capabilities working on the same shipment record, from the route
+ being planned to the report being signed off.
+
+ {CAPS.map((cap, i) => {
+ const active = i >= index && i < index + perView;
+ const n = String(i + 1).padStart(2, "0");
+ return (
+
+ {/* Full-bleed photo card: a real operational photograph
+ fills the frame, a grade + scrim keep the glass UI and
+ the overlaid title readable — the Logico "what we do"
+ composition, Doormile assets. */}
+ { if (Math.abs(drag) > 6) e.preventDefault(); }}
+ >
+
+ {/* next/image, not a bare : the source library
+ runs to ~2.5MB per PNG and each one renders into a
+ ~470px-wide card, so the resize + avif/webp step is
+ doing real work here. */}
+
+
+
+
+
+
+ {n}
+
+
+ Capability {n}
+ {cap.title}
+ {cap.blurb}
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ {/* Floating twin-chevron nav, sitting over the card row like the
+ reference. Hidden from AT — the segments below are the labelled
+ control; these duplicate prev/next for pointer users. */}
+