refactor: migrate shared infrastructure, home and industries features
This commit is contained in:
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 105 KiB |
@@ -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 };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import SolutionsHero from "@/components/sections/SolutionsHero";
|
||||
import IndustryStack from "@/components/sections/IndustryStack";
|
||||
import IndustryStack from "@/features/industries/IndustryStack";
|
||||
|
||||
/**
|
||||
* Industries — "Who Doormile serves".
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { blogPosts, SITE_URL } from "@/data/blog";
|
||||
import { blogPosts, SITE_URL } from "@/shared/data/blog";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { blogPosts } from "@/data/blog";
|
||||
import { blogPosts } from "@/shared/data/blog";
|
||||
|
||||
/**
|
||||
* Client-side blog search. The site is a static export, so there is no search
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getCategories,
|
||||
formatDate,
|
||||
type BlogPost,
|
||||
} from "@/data/blog";
|
||||
} from "@/shared/data/blog";
|
||||
import BlogSearch from "./BlogSearch";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { motion, useMotionValue, useTransform, type MotionValue } from "framer-motion";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { P, STRATEGIES, WINNER_INDEX, ENGINE_STEPS, CONSTRAINT_LIST, STRATEGY_SCORES } from "./theme";
|
||||
|
||||
const LogisticsBrainCanvas = dynamic(() => import("./LogisticsBrainCanvas"), { ssr: false });
|
||||
|
||||
/** Rounds a MotionValue to an integer for the animated stat counters.
|
||||
* Writes to the DOM imperatively so it never triggers a setState during the
|
||||
* parent's render (which `useTransform` emits synchronously on re-render). */
|
||||
function Counter({ mv }: { mv: MotionValue<number> }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
useEffect(() => {
|
||||
const write = (x: number) => {
|
||||
if (ref.current) ref.current.textContent = String(Math.round(x));
|
||||
};
|
||||
write(mv.get());
|
||||
return mv.on("change", write);
|
||||
}, [mv]);
|
||||
return <span ref={ref}>{Math.round(mv.get())}</span>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Active step index from scroll progress (−1 before the engine starts). */
|
||||
function stepFromProgress(p: number): number {
|
||||
let s = -1;
|
||||
for (let i = 0; i < ENGINE_STEPS.length; i++) if (p >= ENGINE_STEPS[i].at) s = i;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Persistent top rail: the 6 engine steps, with the current one highlighted. */
|
||||
function StepRail({ active }: { active: number }) {
|
||||
return (
|
||||
<div className="dm-lb-rail" aria-hidden>
|
||||
{ENGINE_STEPS.map((s, i) => {
|
||||
const state = i < active ? "done" : i === active ? "current" : "todo";
|
||||
return (
|
||||
<React.Fragment key={s.n}>
|
||||
{i > 0 && <span className={`dm-lb-rail__line is-${i <= active ? "on" : "off"}`} />}
|
||||
<div className={`dm-lb-rail__step is-${state}`}>
|
||||
<span className="dm-lb-rail__num">{i < active ? "✓" : s.n}</span>
|
||||
<span className="dm-lb-rail__title">{s.title}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One workflow card that travels through the story. The outer anchor pins it to
|
||||
* its stage position (Left / Center / Right / Center-Hero); the inner motion card
|
||||
* slides + scales into that anchor in lockstep with scroll, so as the camera moves
|
||||
* through the stages the card visibly moves with the narrative instead of sitting
|
||||
* fixed in one corner.
|
||||
*/
|
||||
function StoryCard({
|
||||
step,
|
||||
index,
|
||||
pos,
|
||||
opacity,
|
||||
y,
|
||||
x,
|
||||
scale,
|
||||
num,
|
||||
kicker,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
step: number;
|
||||
index: number;
|
||||
pos: "left" | "center" | "right" | "hero";
|
||||
opacity: MotionValue<number>;
|
||||
y: MotionValue<number>;
|
||||
x: MotionValue<number>;
|
||||
scale: MotionValue<number>;
|
||||
num: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
// Don't mount this beat's card until its step is active.
|
||||
if (step !== index) return null;
|
||||
return (
|
||||
<div className={`dm-lb-card-anchor is-${pos}`}>
|
||||
<motion.div className="dm-lb-card-story" style={{ opacity, y, x, scale }}>
|
||||
<div className="dm-lb-card-story__head">
|
||||
<span className="dm-lb-pillar__num">{num}</span>
|
||||
<span className="dm-lb-pillar__kicker">{kicker}</span>
|
||||
</div>
|
||||
<h3 className="dm-lb-pillar__title">{title}</h3>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Logistics Brain" — one sticky, fullscreen, scroll-driven cinematic WebGL
|
||||
* section. A single GSAP ScrollTrigger maps scroll position to a normalized
|
||||
* progress value that drives the R3F scene, the camera spline and this overlay
|
||||
* in lockstep, so the whole thing reads as one continuous shot.
|
||||
*/
|
||||
export default function LogisticsBrainSection({ connected = false }: { connected?: boolean } = {}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const scroll = useMotionValue(0);
|
||||
|
||||
const [pinState, setPinState] = useState<"before" | "pinned" | "after">("before");
|
||||
const [step, setStep] = useState(-1);
|
||||
const [mountScene, setMountScene] = useState(false);
|
||||
const [sceneActive, setSceneActive] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mqMobile = window.matchMedia("(max-width: 767px)");
|
||||
const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const sync = () => { setIsMobile(mqMobile.matches); setReduced(mqReduce.matches); };
|
||||
sync();
|
||||
mqMobile.addEventListener("change", sync);
|
||||
mqReduce.addEventListener("change", sync);
|
||||
return () => { mqMobile.removeEventListener("change", sync); mqReduce.removeEventListener("change", sync); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const mountIo = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
setMountScene(true);
|
||||
setSceneActive(true);
|
||||
mountIo.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "70% 0px" },
|
||||
);
|
||||
const activeIo = new IntersectionObserver(
|
||||
(entries) => setSceneActive(entries.some((e) => e.isIntersecting)),
|
||||
{ rootMargin: "10% 0px" },
|
||||
);
|
||||
mountIo.observe(el);
|
||||
activeIo.observe(el);
|
||||
return () => { mountIo.disconnect(); activeIo.disconnect(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
let lastPin: "before" | "pinned" | "after" = "before";
|
||||
let lastStep = -1;
|
||||
const st = ScrollTrigger.create({
|
||||
trigger: el,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
// Match Workflow 1's responsiveness (0.4) so the camera + overlay track the
|
||||
// scroll with the same snappy feel — 0.5 made this section lag noticeably.
|
||||
scrub: 0.4,
|
||||
invalidateOnRefresh: true,
|
||||
onUpdate: (self) => {
|
||||
const p = self.progress;
|
||||
progressRef.current = p;
|
||||
scroll.set(p);
|
||||
const ns = p <= 0.0002 ? "before" : p >= 0.9998 ? "after" : "pinned";
|
||||
if (ns !== lastPin) { lastPin = ns; setPinState(ns); }
|
||||
const nstep = stepFromProgress(p);
|
||||
if (nstep !== lastStep) { lastStep = nstep; setStep(nstep); }
|
||||
},
|
||||
});
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 120);
|
||||
return () => { clearTimeout(refresh); st.kill(); };
|
||||
}, [scroll]);
|
||||
|
||||
// Overlay transforms.
|
||||
const introOpacity = useTransform(scroll, [0, 0.04, 0.1], [1, 1, 0]);
|
||||
// Persistent header (title + step rail) fades in after the intro and out at the finale.
|
||||
const headerOpacity = useTransform(scroll, [0.04, 0.1, P.finale - 0.04, P.finale + 0.02], [0, 1, 1, 0]);
|
||||
|
||||
// Per-beat kinetic-typography pillars (cross-fade across each story beat).
|
||||
const p1o = useTransform(scroll, [0.135, 0.165, 0.255, 0.275], [0, 1, 1, 0]);
|
||||
const p1y = useTransform(scroll, [0.135, 0.175], [26, 0]);
|
||||
const p2o = useTransform(scroll, [0.29, 0.32, 0.415, 0.435], [0, 1, 1, 0]);
|
||||
const p2y = useTransform(scroll, [0.29, 0.33], [26, 0]);
|
||||
const p3o = useTransform(scroll, [0.45, 0.48, 0.575, 0.595], [0, 1, 1, 0]);
|
||||
const p3y = useTransform(scroll, [0.45, 0.49], [26, 0]);
|
||||
const p4o = useTransform(scroll, [0.61, 0.64, 0.715, 0.735], [0, 1, 1, 0]);
|
||||
const p4y = useTransform(scroll, [0.61, 0.65], [26, 0]);
|
||||
const p5o = useTransform(scroll, [0.75, 0.78, 0.855, 0.875], [0, 1, 1, 0]);
|
||||
const p5y = useTransform(scroll, [0.75, 0.79], [26, 0]);
|
||||
|
||||
// Horizontal slide + scale per beat — same windows as the opacity above, so the
|
||||
// card glides between its stage anchors (Left → Center → Right → Left → Center-Hero)
|
||||
// in lockstep with the camera. Each card enters from the direction of the previous
|
||||
// stage and drifts toward the next as it leaves, reading as one continuous travel.
|
||||
const p1x = useTransform(scroll, [0.135, 0.165, 0.255, 0.275], [-52, 0, 0, 52]);
|
||||
const p1s = useTransform(scroll, [0.135, 0.165, 0.255, 0.275], [0.965, 1, 1, 0.965]);
|
||||
const p2x = useTransform(scroll, [0.29, 0.32, 0.415, 0.435], [-52, 0, 0, 52]);
|
||||
const p2s = useTransform(scroll, [0.29, 0.32, 0.415, 0.435], [0.965, 1, 1, 0.965]);
|
||||
const p3x = useTransform(scroll, [0.45, 0.48, 0.575, 0.595], [-52, 0, 0, -52]);
|
||||
const p3s = useTransform(scroll, [0.45, 0.48, 0.575, 0.595], [0.965, 1, 1, 0.965]);
|
||||
const p4x = useTransform(scroll, [0.61, 0.64, 0.715, 0.735], [52, 0, 0, 52]);
|
||||
const p4s = useTransform(scroll, [0.61, 0.64, 0.715, 0.735], [0.965, 1, 1, 0.965]);
|
||||
// Hero (final selection): scales up a touch and holds center as it settles.
|
||||
const p5x = useTransform(scroll, [0.75, 0.78, 0.855, 0.875], [-52, 0, 0, 0]);
|
||||
const p5s = useTransform(scroll, [0.75, 0.78, 0.855, 0.875], [0.97, 1.05, 1.05, 1.0]);
|
||||
|
||||
const finaleOpacity = useTransform(scroll, [P.finale - 0.02, P.finale + 0.04], [0, 1]);
|
||||
const finaleY = useTransform(scroll, [P.finale - 0.02, P.finale + 0.06], [40, 0]);
|
||||
const taglineOpacity = useTransform(scroll, [P.finale + 0.04, P.finale + 0.1], [0, 1]);
|
||||
|
||||
const orders = useTransform(scroll, [P.finale, 0.97], [0, 59]);
|
||||
const cost = useTransform(scroll, [P.finale, 0.97], [0, 18]);
|
||||
|
||||
return (
|
||||
<section ref={containerRef} className={`dm-lb is-${pinState}${connected ? " is-connected" : ""}`} aria-label="Logistics Brain — one intelligent system">
|
||||
<div className="dm-lb-sticky">
|
||||
<div className="dm-lb-card">
|
||||
{mountScene && (
|
||||
<div className="dm-lb-canvas">
|
||||
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive && pinState === "pinned"} />
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay is mounted only once the section is pinned/activated, so its
|
||||
content (intro hint, header, story cards) can never be seen during the
|
||||
approach ("before"), where the sticky sits at the top of the tall
|
||||
section just off the previous workflow's seam. */}
|
||||
{pinState !== "before" && (
|
||||
<div className="dm-lb-ui">
|
||||
{/* Persistent header: what this is + where we are in the workflow */}
|
||||
<motion.div className="dm-lb-top" style={{ opacity: headerOpacity }}>
|
||||
<div className="dm-lb-eyebrow">
|
||||
<span className="dm-lb-dot" /> MileTruth Routing Engine
|
||||
</div>
|
||||
<StepRail active={step} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div className="dm-lb-scrollhint" style={{ opacity: introOpacity }}>
|
||||
<span>Scroll to see how every shipment 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 route 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 move 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> fulfilled <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 logistics 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 arrival 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 Fulfilled</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; }
|
||||
}
|
||||
`;
|
||||
@@ -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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,368 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface AiCoreAnimationProps {
|
||||
variant?: "compact" | "full";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AiCoreAnimation({
|
||||
variant = "full",
|
||||
className = "",
|
||||
}: AiCoreAnimationProps) {
|
||||
const isCompact = variant === "compact";
|
||||
|
||||
// Cycle phase for organic ecosystem simulation (0 to 10)
|
||||
const [phase, setPhase] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setPhase((prev) => (prev + 1) % 11);
|
||||
}, 1250); // ~13.7s total continuous ecosystem cycle
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<div className={`sol-s05-svg-core ${className}`} style={{ width: "100%", height: "100%", position: "relative" }}>
|
||||
<style jsx>{`
|
||||
.compact-route-dash {
|
||||
stroke-dasharray: 4 6;
|
||||
animation: cDashFlow 2s linear infinite;
|
||||
}
|
||||
@keyframes cDashFlow { to { stroke-dashoffset: -20; } }
|
||||
`}</style>
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: "100%", height: "100%" }}>
|
||||
<path d="M 10 30 H 90 M 10 70 H 90 M 30 10 V 90 M 70 10 V 90" stroke="rgba(14,14,16,0.1)" strokeWidth="1.5" />
|
||||
<path d="M 30 70 C 45 30, 55 30, 70 30" stroke="#4ADE80" strokeWidth="2.5" strokeLinecap="round" className="compact-route-dash" />
|
||||
<circle cx="30" cy="70" r="4" fill="#0C0C14" />
|
||||
<circle cx="70" cy="30" r="4" fill="#4ADE80" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ecosystem phase indicators
|
||||
const isTrafficActive = phase >= 2 && phase <= 4;
|
||||
const isNetworkReacting = phase >= 3 && phase <= 8;
|
||||
const isBypassIlluminated = phase >= 4 && phase <= 8;
|
||||
const isEtaImproved = phase >= 6;
|
||||
const isDelivered = phase >= 8;
|
||||
const isSlaProtected = phase >= 9;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-living-ecosystem ${className}`}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
.ecosystem-hero-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 680px;
|
||||
max-height: 440px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Flowing Laser Signal Dashes across City Network */
|
||||
.city-laser-flow {
|
||||
stroke-dasharray: 10 16;
|
||||
animation: cityLaserDash 2.4s linear infinite;
|
||||
}
|
||||
|
||||
.city-laser-fast {
|
||||
stroke-dasharray: 14 10;
|
||||
animation: cityLaserDash 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cityLaserDash {
|
||||
to { stroke-dashoffset: -52; }
|
||||
}
|
||||
|
||||
/* Traffic Congestion Segment Glow */
|
||||
.traffic-red-pulse {
|
||||
animation: redPulse 1.2s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes redPulse {
|
||||
0% { stroke-opacity: 0.45; }
|
||||
100% { stroke-opacity: 1; }
|
||||
}
|
||||
|
||||
/* Smooth Easing Vehicle Motion */
|
||||
.vehicle-pod-smooth {
|
||||
transition: all 1.15s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
/* Floating Apple-Style Badges */
|
||||
.apple-pill-float {
|
||||
animation: appleFloat 3s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes appleFloat {
|
||||
0% { transform: translateY(0px); }
|
||||
100% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* Ambient Particle Drift */
|
||||
.p-drift-1 { animation: pDrift1 14s linear infinite; }
|
||||
.p-drift-2 { animation: pDrift2 18s linear infinite; }
|
||||
|
||||
@keyframes pDrift1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
50% { transform: translate(15px, -12px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes pDrift2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-20px, 15px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 680 440"
|
||||
className="ecosystem-hero-svg"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
{/* Micro Dot Grid Pattern for Clean Off-White Background */}
|
||||
<pattern id="appleHeroDotGrid" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||
<circle cx="12" cy="12" r="1" fill="rgba(14, 14, 16, 0.05)" />
|
||||
</pattern>
|
||||
|
||||
{/* Emerald Green Route Gradient */}
|
||||
<linearGradient id="emeraldCityGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#22C55E" />
|
||||
<stop offset="50%" stopColor="#4ADE80" />
|
||||
<stop offset="100%" stopColor="#22C55E" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Red Congestion Route Gradient */}
|
||||
<linearGradient id="redTrafficGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#EF4444" />
|
||||
<stop offset="100%" stopColor="#DC2626" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Apple Product Artwork Soft Shadow */}
|
||||
<filter id="appleHeroShadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="6" floodColor="#000000" floodOpacity="0.07" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* ============================================================
|
||||
1. OFF-WHITE CANVAS & AMBIENT TELEMETRY PARTICLES
|
||||
============================================================ */}
|
||||
<rect width="680" height="440" fill="url(#appleHeroDotGrid)" opacity="0.9" />
|
||||
|
||||
{/* Ambient Telemetry Particles Drifting Across Network */}
|
||||
<g className="p-drift-1">
|
||||
<circle cx="140" cy="80" r="1.5" fill="rgba(14,14,16,0.25)" />
|
||||
<circle cx="520" cy="380" r="2" fill="rgba(74,222,128,0.3)" />
|
||||
<circle cx="340" cy="60" r="1.5" fill="rgba(14,14,16,0.2)" />
|
||||
</g>
|
||||
<g className="p-drift-2">
|
||||
<circle cx="480" cy="360" r="1.5" fill="rgba(14,14,16,0.25)" />
|
||||
<circle cx="200" cy="380" r="2" fill="rgba(239,68,68,0.3)" />
|
||||
<circle cx="580" cy="80" r="1.5" fill="rgba(14,14,16,0.2)" />
|
||||
</g>
|
||||
|
||||
{/* ============================================================
|
||||
2. ABSTRACT CITY LOGISTICS NETWORK TOPOLOGY
|
||||
============================================================ */}
|
||||
|
||||
{/* THIN GREY ARTERIAL HIGHWAYS & STREETS */}
|
||||
<path d="M 80 100 H 600" stroke="rgba(14, 14, 16, 0.1)" strokeWidth="2" />
|
||||
<path d="M 80 220 H 600" stroke="rgba(14, 14, 16, 0.12)" strokeWidth="2.5" />
|
||||
<path d="M 80 340 H 600" stroke="rgba(14, 14, 16, 0.1)" strokeWidth="2" />
|
||||
|
||||
{/* CONNECTING VERTICAL AVENUES */}
|
||||
<path d="M 200 60 V 380" stroke="rgba(14, 14, 16, 0.08)" strokeWidth="1.8" />
|
||||
<path d="M 340 60 V 380" stroke="rgba(14, 14, 16, 0.08)" strokeWidth="1.8" />
|
||||
<path d="M 480 60 V 380" stroke="rgba(14, 14, 16, 0.08)" strokeWidth="1.8" />
|
||||
|
||||
{/* DIAGONAL BYPASS CONNECTORS & RING EXPRESSWAY ARCS */}
|
||||
<path d="M 200 220 L 340 100 L 480 220" stroke="rgba(14, 14, 16, 0.09)" strokeWidth="2" fill="none" />
|
||||
<path d="M 200 220 L 340 340 L 480 220" stroke="rgba(14, 14, 16, 0.09)" strokeWidth="2" fill="none" />
|
||||
<path d="M 80 220 C 180 60, 500 60, 600 220" stroke="rgba(14, 14, 16, 0.06)" strokeWidth="1.5" strokeDasharray="4 6" fill="none" />
|
||||
|
||||
{/* LIVE DATA SIGNALS FLOWING ACROSS CITY NETWORK */}
|
||||
<path d="M 80 100 H 600" stroke="rgba(14, 14, 16, 0.2)" strokeWidth="1.2" fill="none" className="city-laser-flow" />
|
||||
<path d="M 80 340 H 600" stroke="rgba(14, 14, 16, 0.2)" strokeWidth="1.2" fill="none" className="city-laser-flow" />
|
||||
<path d="M 340 60 V 380" stroke="rgba(14, 14, 16, 0.15)" strokeWidth="1.2" fill="none" className="city-laser-flow" />
|
||||
|
||||
{/* ============================================================
|
||||
3. DYNAMIC ROAD STATES (Congestion Red vs Emerald Bypass)
|
||||
============================================================ */}
|
||||
|
||||
{/* CONGESTED ROAD SEGMENT (200, 220 -> 480, 220) */}
|
||||
<path
|
||||
d="M 200 220 H 480"
|
||||
stroke={isTrafficActive ? "url(#redTrafficGrad)" : "rgba(14, 14, 16, 0.12)"}
|
||||
strokeWidth={isTrafficActive ? "5" : "2.5"}
|
||||
strokeDasharray={isBypassIlluminated ? "4 8" : "none"}
|
||||
className={isTrafficActive ? "traffic-red-pulse" : ""}
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{/* ORGANIC TRAFFIC ALERT INDICATOR AT CONGESTION ZONE */}
|
||||
{isTrafficActive && (
|
||||
<g transform="translate(340, 220)">
|
||||
<circle cx="0" cy="0" r="16" fill="rgba(239, 68, 68, 0.2)" />
|
||||
<circle cx="0" cy="0" r="7" fill="#EF4444" filter="url(#appleHeroShadow)" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* SUBTLE ILLUMINATED EMERALD GREEN BYPASS ROAD (200, 220 -> 340, 100 -> 480, 220) */}
|
||||
{isBypassIlluminated && (
|
||||
<g>
|
||||
<path
|
||||
d="M 200 220 L 340 100 L 480 220"
|
||||
stroke="url(#emeraldCityGrad)"
|
||||
strokeWidth="4.5"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
className="city-laser-fast"
|
||||
filter="url(#appleHeroShadow)"
|
||||
/>
|
||||
{/* Ambient Green Glow Trail */}
|
||||
<path
|
||||
d="M 200 220 L 340 100 L 480 220"
|
||||
stroke="#4ADE80"
|
||||
strokeWidth="10"
|
||||
strokeOpacity="0.22"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* ============================================================
|
||||
4. MINIMAL LOGISTICS FACILITIES (Warehouses, Hubs, Destinations)
|
||||
============================================================ */}
|
||||
|
||||
{/* Warehouse 1 (North Dispatch at 80, 100) */}
|
||||
<g transform="translate(80, 100)">
|
||||
<rect x="-14" y="-12" width="28" height="24" rx="5" fill="#FFFFFF" stroke="#0C0C14" strokeWidth="1.5" filter="url(#appleHeroShadow)" />
|
||||
<path d="M-5 4V-2l5-3 5 3v5" stroke="#0C0C14" strokeWidth="1.2" fill="none" />
|
||||
</g>
|
||||
|
||||
{/* Warehouse 2 (Main Dispatch at 80, 220) */}
|
||||
<g transform="translate(80, 220)">
|
||||
<rect x="-16" y="-14" width="32" height="28" rx="6" fill="#0C0C14" stroke="#FFFFFF" strokeWidth="1.5" filter="url(#appleHeroShadow)" />
|
||||
<path d="M-6 5V-2l6-4 6 4v7" stroke="#FFFFFF" strokeWidth="1.5" fill="none" />
|
||||
</g>
|
||||
|
||||
{/* Distribution Hub 1 (200, 220) */}
|
||||
<circle cx="200" cy="220" r="7" fill="#FFFFFF" stroke="#0C0C14" strokeWidth="1.8" filter="url(#appleHeroShadow)" />
|
||||
|
||||
{/* Distribution Hub 2 (340, 100) */}
|
||||
<circle cx="340" cy="100" r="7" fill={isBypassIlluminated ? "#22C55E" : "#FFFFFF"} stroke={isBypassIlluminated ? "#22C55E" : "#0C0C14"} strokeWidth="1.8" filter="url(#appleHeroShadow)" />
|
||||
|
||||
{/* Distribution Hub 3 (480, 220) */}
|
||||
<circle cx="480" cy="220" r="7" fill="#FFFFFF" stroke="#0C0C14" strokeWidth="1.8" filter="url(#appleHeroShadow)" />
|
||||
|
||||
{/* Destination 1 (North Target at 600, 100) */}
|
||||
<circle cx="600" cy="100" r="8" fill="#FFFFFF" stroke="rgba(14,14,16,0.3)" strokeWidth="1.5" />
|
||||
|
||||
{/* Destination 2 (Main Target at 600, 220) */}
|
||||
<g transform="translate(600, 220)">
|
||||
<circle cx="0" cy="0" r="16" fill={isDelivered ? "rgba(34,197,94,0.18)" : "#FFFFFF"} stroke={isDelivered ? "#22C55E" : "#0C0C14"} strokeWidth="2" filter="url(#appleHeroShadow)" />
|
||||
<circle cx="0" cy="0" r="5" fill={isDelivered ? "#22C55E" : "#0C0C14"} />
|
||||
</g>
|
||||
|
||||
{/* Destination 3 (South Target at 600, 340) */}
|
||||
<circle cx="600" cy="340" r="8" fill="#FFFFFF" stroke="rgba(14,14,16,0.3)" strokeWidth="1.5" />
|
||||
|
||||
{/* ============================================================
|
||||
5. MULTIPLE DELIVERY VEHICLES MOVING SIMULTANEOUSLY
|
||||
============================================================ */}
|
||||
|
||||
{/* VEHICLE 1 (PRIMARY FLEET UNIT - REROUTES UPON CONGESTION) */}
|
||||
{(() => {
|
||||
let vx = 80, vy = 220;
|
||||
if (phase === 0) { vx = 80; vy = 220; }
|
||||
else if (phase === 1) { vx = 140; vy = 220; }
|
||||
else if (phase === 2) { vx = 200; vy = 220; } // Encounters traffic at Hub 1 (200, 220)
|
||||
else if (phase === 3) { vx = 210; vy = 210; } // Network reacts
|
||||
else if (phase === 4) { vx = 270; vy = 160; } // Smoothly curves onto Green Bypass Road
|
||||
else if (phase === 5) { vx = 340; vy = 100; } // At Hub 2 Apex (340, 100)
|
||||
else if (phase === 6) { vx = 410; vy = 160; } // Sweeping down to Hub 3 (480, 220)
|
||||
else if (phase === 7) { vx = 480; vy = 220; } // At Hub 3
|
||||
else if (phase >= 8) { vx = 600; vy = 220; } // Delivered at Destination 2
|
||||
|
||||
return (
|
||||
<g transform={`translate(${vx}, ${vy})`} className="vehicle-pod-smooth">
|
||||
<circle cx="0" cy="0" r="13" fill={isBypassIlluminated ? "rgba(34, 197, 94, 0.28)" : "rgba(226, 53, 74, 0.25)"} />
|
||||
<rect x="-9" y="-6" width="18" height="12" rx="2.5" fill="#0C0C14" stroke={isBypassIlluminated ? "#22C55E" : "#E2354A"} strokeWidth="1.5" filter="url(#appleHeroShadow)" />
|
||||
<circle cx="4" cy="0" r="1.5" fill="#FFFFFF" />
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* VEHICLE 2 (NORTH ARTERIAL VAN - MOVES UNINTERRUPTED) */}
|
||||
<g transform={`translate(${80 + ((phase * 52) % 520)}, 100)`} className="vehicle-pod-smooth">
|
||||
<rect x="-8" y="-5" width="16" height="10" rx="2" fill="#0C0C14" stroke="rgba(255,255,255,0.8)" strokeWidth="1" />
|
||||
<circle cx="3" cy="0" r="1" fill="#FFFFFF" />
|
||||
</g>
|
||||
|
||||
{/* VEHICLE 3 (SOUTH COURIER VAN - MOVES UNINTERRUPTED) */}
|
||||
<g transform={`translate(${600 - ((phase * 48) % 520)}, 340)`} className="vehicle-pod-smooth">
|
||||
<rect x="-8" y="-5" width="16" height="10" rx="2" fill="#0C0C14" stroke="rgba(255,255,255,0.8)" strokeWidth="1" />
|
||||
<circle cx="-3" cy="0" r="1" fill="#FFFFFF" />
|
||||
</g>
|
||||
|
||||
{/* VEHICLE 4 (CROSS-TOWN RELAY VAN) */}
|
||||
<g transform={`translate(340, ${380 - ((phase * 32) % 320)})`} className="vehicle-pod-smooth">
|
||||
<rect x="-5" y="-8" width="10" height="16" rx="2" fill="#0C0C14" stroke="rgba(255,255,255,0.8)" strokeWidth="1" />
|
||||
</g>
|
||||
|
||||
{/* VEHICLE 5 (RING EXPRESSWAY SHUTTLE) */}
|
||||
<g transform={`translate(${480 - ((phase * 28) % 280)}, 340)`} className="vehicle-pod-smooth">
|
||||
<circle cx="0" cy="0" r="4.5" fill="#0C0C14" stroke="#E2354A" strokeWidth="1" />
|
||||
</g>
|
||||
|
||||
{/* ============================================================
|
||||
6. APPLE-STYLE MINIMAL BADGES (ETA & SLA CONFIRMATION)
|
||||
============================================================ */}
|
||||
|
||||
{/* Live ETA Badge (Top Right) */}
|
||||
<g transform="translate(485, 30)" className="apple-pill-float">
|
||||
<rect x="0" y="0" width="165" height="42" rx="21" fill="#FFFFFF" stroke={isEtaImproved ? "#22C55E" : "rgba(14,14,16,0.15)"} strokeWidth="1.5" filter="url(#appleHeroShadow)" />
|
||||
<circle cx="21" cy="21" r="5" fill={isEtaImproved ? "#22C55E" : "#E2354A"} />
|
||||
<text x="34" y="25" fill="#0E0E12" fontSize="13" fontWeight="800" fontFamily="sans-serif">
|
||||
{isEtaImproved ? "41 min" : isTrafficActive ? "58 min" : "45 min"}
|
||||
</text>
|
||||
{isEtaImproved && (
|
||||
<text x="96" y="25" fill="#22C55E" fontSize="9.5" fontWeight="800" fontFamily="sans-serif">
|
||||
· Saved 17m
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* SLA Protected Pill (Appears at Step 9 Post-Reroute) */}
|
||||
{isSlaProtected && (
|
||||
<g transform="translate(460, 275)" className="apple-pill-float">
|
||||
<rect x="0" y="0" width="165" height="32" rx="16" fill="#FFFFFF" stroke="#22C55E" strokeWidth="1.2" filter="url(#appleHeroShadow)" />
|
||||
<circle cx="16" cy="16" r="4" fill="#22C55E" />
|
||||
<text x="28" y="20" fill="#0E0E12" fontSize="10" fontWeight="800" fontFamily="sans-serif">
|
||||
✓ SLA Protected (99.2%)
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ContentBlock,
|
||||
formatDate,
|
||||
estimateReadingTime,
|
||||
} from "@/data/blog";
|
||||
} from "@/shared/data/blog";
|
||||
|
||||
function ContentRenderer({ block }: { block: ContentBlock }) {
|
||||
switch (block.type) {
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { ScrollReveal } from "../../animations/Reveal";
|
||||
|
||||
type Stat = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type Stage = {
|
||||
img: string;
|
||||
label: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
desc: string;
|
||||
points: string[];
|
||||
stats?: Stat[];
|
||||
};
|
||||
|
||||
const STAGES: Stage[] = [
|
||||
{
|
||||
img: "/images/first-mile-approach.webp",
|
||||
label: "Stage 01",
|
||||
title: "First Mile Warehouse",
|
||||
subtitle: "Consolidation & Prep",
|
||||
desc: "Incoming shipments are securely loaded, checked, and queued for transfer in our high-capacity fulfillment centers.",
|
||||
points: ["AI-scheduled pickups", "Dynamic load consolidation", "Yard & dock management"],
|
||||
stats: [
|
||||
{ value: "14,250", label: "Parcels Processed" },
|
||||
{ value: "99.98%", label: "Sorting Accuracy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
img: "/images/mid-mile-approach.webp",
|
||||
label: "Stage 02",
|
||||
title: "Mid Mile Transit",
|
||||
subtitle: "Hub-to-Hub Transport",
|
||||
desc: "Freight is routed dynamically through our network of strategically located hubs using automated sortation and linehaul scheduling.",
|
||||
points: ["Optimised line-haul routing", "Cross-docking & sortation", "Live SLA monitoring"],
|
||||
stats: [
|
||||
{ value: "1,240+", label: "Daily Line-Hauls" },
|
||||
{ value: "98.5%", label: "SLA Adherence" }
|
||||
]
|
||||
},
|
||||
{
|
||||
img: "/images/last-mile-approach.webp",
|
||||
label: "Stage 03",
|
||||
title: "Last Mile Logistics",
|
||||
subtitle: "Hub to Doorstep",
|
||||
desc: "The final handoff. Our routing engine optimizes multi-stop itineraries to move parcels directly to customers' doorsteps in record time.",
|
||||
points: ["Multi-stop route optimisation", "Precise arrival windows", "Digital proof of delivery"],
|
||||
stats: [
|
||||
{ value: "450K+", label: "Shipments Delivered" },
|
||||
{ value: "2.8 Hours", label: "Average Turnaround" }
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
export default function WhyChooseDoormile() {
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
/* =====================================================================
|
||||
"Why Businesses Choose Doormile" — First / Mid / Last Mile stage cards.
|
||||
===================================================================== */
|
||||
.wcd-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: auto;
|
||||
margin: -250px 20px 20px 20px;
|
||||
background-color: #1F1F1F;
|
||||
border-radius: 0 0 25px 25px;
|
||||
padding: 50px 0 110px;
|
||||
}
|
||||
.wcd-inner {
|
||||
width: 100%;
|
||||
max-width: 1630px;
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wcd-head {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin: 0 0 70px;
|
||||
}
|
||||
@keyframes wcd-float {
|
||||
0% { transform: translateX(-50%) translateY(0px) scale(1); }
|
||||
50% { transform: translateX(-50%) translateY(-12px) scale(1.02); }
|
||||
100% { transform: translateX(-50%) translateY(0px) scale(1); }
|
||||
}
|
||||
.wcd-head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 75%;
|
||||
transform: translateX(-50%);
|
||||
width: min(1180px, 95%);
|
||||
aspect-ratio: 2 / 1;
|
||||
background: url('/images/bg-map.png') center / contain no-repeat;
|
||||
opacity: 0.06;
|
||||
filter: invert(1);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
animation: wcd-float 20s ease-in-out infinite;
|
||||
}
|
||||
.wcd-card-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.wcd-eyebrow {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 2.1429em;
|
||||
letter-spacing: 0.5px;
|
||||
color: #FFFFFF;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.logico-front-end .wcd-section h2.wcd-heading {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: clamp(34px, 4.4vw, 60px);
|
||||
font-weight: 500;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
text-transform: uppercase;
|
||||
color: #FFFFFF;
|
||||
-webkit-text-fill-color: #FFFFFF;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.wcd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 30px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Premium Glassmorphism & Card Interaction */
|
||||
.wcd-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: border-color 0.4s cubic-bezier(0.165, 0.84, 0.44, 1),
|
||||
box-shadow 0.4s cubic-bezier(0.165, 0.84, 0.44, 1),
|
||||
transform 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
}
|
||||
.wcd-card:hover {
|
||||
border-color: #c01227 !important;
|
||||
box-shadow: 0 20px 40px rgba(192, 18, 39, 0.15), inset 0 0 20px rgba(255, 255, 255, 0.02) !important;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
.wcd-card-media {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wcd-card-media img {
|
||||
object-fit: cover;
|
||||
transition: transform 0.6s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
}
|
||||
.wcd-card:hover .wcd-card-media img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Body Data Container (Unified Div) */
|
||||
.wcd-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Modern Pill Badge for Label */
|
||||
.wcd-card-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
padding: 6px 14px;
|
||||
background: rgba(192, 18, 39, 0.08);
|
||||
border: 1px solid rgba(192, 18, 39, 0.25);
|
||||
border-radius: 100px;
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #ff3344;
|
||||
margin: 0 0 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.wcd-card:hover .wcd-card-label {
|
||||
background: rgba(192, 18, 39, 0.18);
|
||||
border-color: #c01227;
|
||||
box-shadow: 0 0 10px rgba(192, 18, 39, 0.15);
|
||||
}
|
||||
|
||||
.logico-front-end .wcd-section h3.wcd-card-title {
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #FFFFFF;
|
||||
-webkit-text-fill-color: #FFFFFF;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.wcd-card-subtitle {
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin: 0 0 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.wcd-card-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
margin: 0 0 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wcd-card-desc {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.wcd-card-points {
|
||||
list-style: none;
|
||||
margin: 0 0 30px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.wcd-section .wcd-card-points li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding-left: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.wcd-section .wcd-card-points li::before {
|
||||
content: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wcd-card-points .wcd-check {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 0.08em;
|
||||
color: #c01227;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.wcd-card:hover .wcd-card-points .wcd-check {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Integrated Stats Section */
|
||||
.wcd-card-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.wcd-stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wcd-stat-value {
|
||||
font-family: var(--font-manrope), "Manrope", sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #FFFFFF;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.wcd-card:hover .wcd-stat-value {
|
||||
color: #ff3344;
|
||||
}
|
||||
.wcd-stat-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.wcd-section { margin: 0 10px 10px 10px; padding: 40px 0 80px; }
|
||||
.wcd-inner { padding: 0; }
|
||||
.wcd-grid { grid-template-columns: 1fr; gap: 24px; }
|
||||
.wcd-card-body { padding: 32px; }
|
||||
.logico-front-end .wcd-section h3.wcd-card-title { font-size: 24px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.wcd-section { margin: 0 10px 10px 10px; border-radius: 0 0 20px 20px; padding: 30px 0 64px; }
|
||||
.wcd-inner { padding: 0; }
|
||||
.wcd-card-body { padding: 24px; }
|
||||
}
|
||||
`}} />
|
||||
|
||||
<section className="wcd-section">
|
||||
<div className="wcd-inner">
|
||||
<ScrollReveal delay={0.1}>
|
||||
<div className="wcd-head">
|
||||
<div className="wcd-eyebrow">/ Why Businesses Choose Doormile /</div>
|
||||
<h2 className="wcd-heading">The connected approach delivers measurable advantages</h2>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="wcd-grid">
|
||||
{STAGES.map((stage, index) => (
|
||||
<ScrollReveal
|
||||
key={stage.label}
|
||||
delay={index * 0.15}
|
||||
duration={0.8}
|
||||
yOffset={50}
|
||||
className="wcd-card-wrapper"
|
||||
>
|
||||
<article className="wcd-card" style={{ height: "100%" }}>
|
||||
<div className="wcd-card-media">
|
||||
<Image
|
||||
src={stage.img}
|
||||
alt={stage.title}
|
||||
fill
|
||||
sizes="(max-width: 1020px) 100vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
{/* Single Parent Div for All Data Content */}
|
||||
<div className="wcd-card-body">
|
||||
<div className="wcd-card-label">{stage.label}</div>
|
||||
|
||||
<h3 className="wcd-card-title">{stage.title}</h3>
|
||||
|
||||
{stage.subtitle && (
|
||||
<div className="wcd-card-subtitle">{stage.subtitle}</div>
|
||||
)}
|
||||
|
||||
<div className="wcd-card-divider" />
|
||||
|
||||
<p className="wcd-card-desc">{stage.desc}</p>
|
||||
|
||||
<ul className="wcd-card-points">
|
||||
{stage.points.map((point) => (
|
||||
<li key={point}>
|
||||
<svg
|
||||
className="wcd-check"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Integrated Statistics Grid */}
|
||||
{stage.stats && stage.stats.length > 0 && (
|
||||
<div className="wcd-card-stats">
|
||||
{stage.stats.map((stat) => (
|
||||
<div key={stat.label} className="wcd-stat-item">
|
||||
<span className="wcd-stat-value">{stat.value}</span>
|
||||
<span className="wcd-stat-label">{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import IndustryWorldMap from "./IndustryWorldMap";
|
||||
import IndustryWorldMap from "@/components/sections/IndustryWorldMap";
|
||||
|
||||
type Tab = "ch" | "so";
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useSceneStore } from '../../store/useSceneStore'
|
||||
|
||||
export default function Hero() {
|
||||
const lenis = useSceneStore((state) => state.lenis)
|
||||
const handleScrollToStart = () => {
|
||||
// Scroll down to the first active transition point
|
||||
lenis?.scrollTo(window.innerHeight * 0.5, { duration: 1.5 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hero-overlay" id="home-hero">
|
||||
{/* Dynamic mouse scrolling indicator */}
|
||||
<div className="scroll-indicator" onClick={handleScrollToStart}>
|
||||
<div className="mouse-frame">
|
||||
<div className="mouse-dot" />
|
||||
</div>
|
||||
<div className="scroll-text">Scroll to start</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export const colors = {
|
||||
primary: '#0071e3', // Apple Blue
|
||||
secondary: '#86868b', // Apple Gray
|
||||
background: '#ffffff', // White
|
||||
backgroundDark: '#f5f5f7', // Off-white/Light gray
|
||||
text: '#1d1d1f', // Premium dark gray (text)
|
||||
textMuted: '#86868b', // Subtitle text
|
||||
border: '#d2d2d7', // Thin borders
|
||||
success: '#34c759', // Apple Green
|
||||
accentBlue: '#2997ff', // Bright active blue
|
||||
cardBg: 'rgba(255, 255, 255, 0.8)',
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export const easeInOutCubic = (t) => {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
|
||||
}
|
||||
|
||||
export const easeOutQuad = (t) => {
|
||||
return t * (2 - t)
|
||||
}
|
||||
|
||||
export const easeInQuad = (t) => {
|
||||
return t * t
|
||||
}
|
||||
|
||||
export const easeOutCubic = (t) => {
|
||||
return 1 - Math.pow(1 - t, 3)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getBodyClasses } from "@/lib/bodyClasses";
|
||||
import { getBodyClasses } from "@/shared/lib/bodyClasses";
|
||||
|
||||
/**
|
||||
* Applies production WP/Elementor body classes per route.
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { sendEmailAction } from "@/actions/sendEmail";
|
||||
import { sendEmailAction } from "@/shared/actions/sendEmail";
|
||||
import { ScrollReveal } from "@/animations/Reveal";
|
||||
|
||||
export default function Footer() {
|
||||
Reference in New Issue
Block a user