3679 lines
190 KiB
TypeScript
3679 lines
190 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import Image from "next/image";
|
|
import gsap from "gsap";
|
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
import { motion, AnimatePresence, useInView, useMotionValue, useTransform, useSpring, animate, useReducedMotion, useScroll } from "framer-motion";
|
|
import { ScrollReveal, CountUp } from "@/animations/Reveal";
|
|
import QuantumStage from "./QuantumStage";
|
|
import MileTruthNetwork from "./MileTruthNetwork";
|
|
import ConnectedLogistics from "./ConnectedLogistics";
|
|
import ConnectedLogisticsSection from "./ConnectedLogisticsSection";
|
|
|
|
if (typeof window !== "undefined") {
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
}
|
|
|
|
/* ============================================================
|
|
Doormile Solutions — premium storytelling redesign (v2).
|
|
|
|
Visual system: seven distinct ground textures (Dark Grey, Charcoal,
|
|
Mesh Gradient, Soft Graph Texture, Radial Red Glow, Blueprint,
|
|
Logistics Grid) so no two sections read as "black on black". Width
|
|
matches the Hero/Footer near-edge-to-edge convention (calc(100% -
|
|
40px), no narrow content cap) with true split layouts for two
|
|
sections. Every section carries a distinct layout identity and a
|
|
motion sequence that explains what's happening, not just a fade-up.
|
|
============================================================ */
|
|
|
|
/* ---------------------------------------------------------------
|
|
Icon primitives
|
|
--------------------------------------------------------------- */
|
|
function Svg({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
|
{children}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
const IconBag = <Svg><path d="M6 8h12l-1 12.5a1.5 1.5 0 01-1.5 1.5H8.5A1.5 1.5 0 017 20.5L6 8z" /><path d="M9 8V6a3 3 0 016 0v2" /></Svg>;
|
|
const IconDriver = <Svg><circle cx="12" cy="7" r="3" /><path d="M5 21c0-4 3-6.5 7-6.5s7 2.5 7 6.5" /></Svg>;
|
|
const IconRoute = <Svg><path d="M3 17l5-10 4 6 4-8 5 12" /></Svg>;
|
|
const IconPin = <Svg><path d="M12 2a6 6 0 00-6 6c0 4.5 6 12 6 12s6-7.5 6-12a6 6 0 00-6-6z" /><circle cx="12" cy="8" r="2" /></Svg>;
|
|
const IconBoxCheck = <Svg><rect x="4" y="8" width="16" height="12" rx="1.5" /><path d="M4 8l8-4 8 4" /><path d="M9 14l2 2 4-4" /></Svg>;
|
|
const IconBox = <Svg><rect x="4" y="8" width="16" height="12" rx="1.5" /><path d="M4 8l8-4 8 4" /></Svg>;
|
|
const IconShieldCheck = <Svg><path d="M12 2l7 3v6c0 5-3 8-7 9-4-1-7-4-7-9V5l7-3z" /><path d="M9.3 12l2 2 3.6-4.2" /></Svg>;
|
|
const IconTruck = <Svg><rect x="1" y="7" width="13" height="10" rx="1" /><path d="M14 11h4l3 3v3h-3" /><circle cx="5" cy="19" r="2" /><circle cx="17" cy="19" r="2" /></Svg>;
|
|
const IconLayers = <Svg><path d="M12 3l8 4-8 4-8-4 8-4z" /><path d="M4 13l8 4 8-4" /></Svg>;
|
|
const IconDatabase = <Svg><ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5" /><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" /></Svg>;
|
|
const IconCpu = <Svg><rect x="7" y="7" width="10" height="10" rx="1.5" /><path d="M7 3v3M12 3v3M17 3v3M7 18v3M12 18v3M17 18v3M3 7h3M3 12h3M3 17h3M18 7h3M18 12h3M18 17h3" /></Svg>;
|
|
const IconBulb = <Svg><path d="M9 18h6" /><path d="M10 21h4" /><path d="M12 3a6 6 0 00-3 11.2c.6.4 1 1.1 1 1.8h4c0-.7.4-1.4 1-1.8A6 6 0 0012 3z" /></Svg>;
|
|
const IconTrendUp = <Svg><path d="M3 17l6-6 4 4 8-8" /><path d="M15 7h6v6" /></Svg>;
|
|
const IconTrendDown = <Svg><path d="M3 7l6 6 4-4 8 8" /><path d="M15 17h6v-6" /></Svg>;
|
|
const IconAlert = <Svg><path d="M12 3l9 16H3L12 3z" /><path d="M12 9v5M12 17h.01" /></Svg>;
|
|
const IconTarget = <Svg><circle cx="12" cy="12" r="8.5" /><circle cx="12" cy="12" r="4.5" /><circle cx="12" cy="12" r="1" fill="currentColor" /></Svg>;
|
|
const IconClock = <Svg><circle cx="12" cy="12" r="8.5" /><path d="M12 7v5l3.2 2" /></Svg>;
|
|
const IconZap = <Svg><path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z" /></Svg>;
|
|
const IconGlobe = <Svg><circle cx="12" cy="12" r="8.5" /><path d="M3.5 12h17M12 3.5c2.6 2.4 2.6 14.6 0 17M12 3.5c-2.6 2.4-2.6 14.6 0 17" /></Svg>;
|
|
const IconCheck = <Svg><path d="M4 12l6 6L20 6" /></Svg>;
|
|
const IconX = <Svg><path d="M6 6l12 12M18 6L6 18" /></Svg>;
|
|
const IconLock = <Svg><rect x="5" y="11" width="14" height="9" rx="2.2" /><path d="M8 11V7.5a4 4 0 018 0V11" /></Svg>;
|
|
|
|
/* ---------------------------------------------------------------
|
|
Shared shell: backgrounds, section rhythm, floating cards
|
|
--------------------------------------------------------------- */
|
|
type BgVariant = "grey" | "charcoal" | "graph" | "mesh" | "redglow" | "engine" | "logisticsgrid" | "white";
|
|
|
|
function Bg({ variant }: { variant: BgVariant }) {
|
|
return <div className={`sw-bg sw-bg--${variant}`} aria-hidden="true" />;
|
|
}
|
|
|
|
function Section({
|
|
id,
|
|
bg,
|
|
className = "",
|
|
children,
|
|
}: {
|
|
id: string;
|
|
bg: BgVariant;
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<section id={id} className={`sw-sec sw-sec--${bg}${className ? ` ${className}` : ""}`}>
|
|
<Bg variant={bg} />
|
|
<div className="sw-in">{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function GsapWordRevealText({
|
|
children,
|
|
className = "",
|
|
style,
|
|
tag: Tag = "div",
|
|
delay = 0,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
tag?: any;
|
|
delay?: number;
|
|
}) {
|
|
const containerRef = useRef<HTMLElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
|
|
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
|
return;
|
|
}
|
|
|
|
const words = el.querySelectorAll(".gsap-word-inner");
|
|
if (!words.length) return;
|
|
|
|
gsap.set(words, { y: "115%", opacity: 0 });
|
|
|
|
const st = ScrollTrigger.create({
|
|
trigger: el,
|
|
start: "top 88%",
|
|
once: true,
|
|
onEnter: () => {
|
|
gsap.to(words, {
|
|
y: "0%",
|
|
opacity: 1,
|
|
duration: 0.75,
|
|
stagger: 0.035,
|
|
ease: "power3.out",
|
|
delay,
|
|
});
|
|
},
|
|
});
|
|
|
|
return () => st.kill();
|
|
}, [children, delay]);
|
|
|
|
const wrapWords = (node: React.ReactNode, keyPrefix = "w"): React.ReactNode => {
|
|
if (typeof node === "string") {
|
|
const words = node.split(" ");
|
|
return words.map((w, idx) => {
|
|
if (!w) return null;
|
|
return (
|
|
<span
|
|
key={`${keyPrefix}-${idx}`}
|
|
className="gsap-word-outer"
|
|
style={{
|
|
display: "inline-block",
|
|
overflow: "hidden",
|
|
verticalAlign: "bottom",
|
|
paddingBottom: "0.08em",
|
|
marginBottom: "-0.08em",
|
|
marginRight: "0.28em",
|
|
}}
|
|
>
|
|
<span className="gsap-word-inner" style={{ display: "inline-block" }}>
|
|
{w}
|
|
</span>
|
|
</span>
|
|
);
|
|
});
|
|
}
|
|
if (React.isValidElement(node)) {
|
|
const children = React.Children.map(
|
|
(node.props as any).children,
|
|
(child, i) => wrapWords(child, `${keyPrefix}-${i}`)
|
|
);
|
|
return React.cloneElement(node, { ...(node.props as object), children } as any);
|
|
}
|
|
if (Array.isArray(node)) {
|
|
return node.map((item, i) => wrapWords(item, `${keyPrefix}-${i}`));
|
|
}
|
|
return node;
|
|
};
|
|
|
|
return (
|
|
<Tag ref={containerRef} className={className} style={style}>
|
|
{wrapWords(children)}
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
function SectionHead({
|
|
n,
|
|
eyebrow = "Solutions",
|
|
title,
|
|
lede,
|
|
className = "",
|
|
darkText = false,
|
|
}: {
|
|
n?: string;
|
|
eyebrow?: string;
|
|
title: React.ReactNode;
|
|
lede: string;
|
|
className?: string;
|
|
darkText?: boolean;
|
|
}) {
|
|
const isCenter = className.includes("sw-head--center");
|
|
const cleanEyebrow = eyebrow.replace(/^\/|\/$/g, "").trim();
|
|
|
|
return (
|
|
<div className={`sw-head ${className}`}>
|
|
<ScrollReveal yOffset={16} triggerOnce>
|
|
<span className="sw-eyebrow sw-eyebrow--slash">/ {cleanEyebrow} /</span>
|
|
</ScrollReveal>
|
|
|
|
<motion.h2
|
|
className="sw-h"
|
|
style={darkText ? { color: "#111827" } : undefined}
|
|
initial={{ opacity: 0, y: 22, filter: "blur(10px)" }}
|
|
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
|
viewport={{ once: true, margin: "-10% 0px" }}
|
|
transition={{ duration: 0.65, delay: 0.08, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
{title}
|
|
</motion.h2>
|
|
|
|
<GsapWordRevealText
|
|
tag="p"
|
|
className="sw-lede"
|
|
style={{
|
|
...(darkText ? { color: "#4b5563" } : {}),
|
|
...(isCenter ? { marginLeft: "auto", marginRight: "auto", textAlign: "center" } : {}),
|
|
}}
|
|
>
|
|
{lede}
|
|
</GsapWordRevealText>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Hover-glow + subtle mouse-parallax tilt wrapper (Framer Motion). */
|
|
function TiltCard({ className = "", children }: { className?: string; children: React.ReactNode }) {
|
|
const reduce = useReducedMotion();
|
|
const x = useMotionValue(0);
|
|
const y = useMotionValue(0);
|
|
const rotateX = useSpring(useTransform(y, [-40, 40], [7, -7]), { stiffness: 240, damping: 22 });
|
|
const rotateY = useSpring(useTransform(x, [-40, 40], [-7, 7]), { stiffness: 240, damping: 22 });
|
|
|
|
return (
|
|
<motion.div
|
|
className={`sw-tilt ${className}`}
|
|
style={reduce ? undefined : { rotateX, rotateY, transformPerspective: 900 }}
|
|
onMouseMove={(e) => {
|
|
if (reduce) return;
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|
x.set(e.clientX - r.left - r.width / 2);
|
|
y.set(e.clientY - r.top - r.height / 2);
|
|
}}
|
|
onMouseLeave={() => {
|
|
x.set(0);
|
|
y.set(0);
|
|
}}
|
|
whileHover={{ y: -4 }}
|
|
transition={{ type: "spring", stiffness: 260, damping: 22 }}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/** Framer-Motion count-up (distinct from the GSAP CountUp used in Section 10). */
|
|
function FCount({ to, decimals = 0, suffix = "" }: { to: number; decimals?: number; suffix?: string }) {
|
|
const ref = useRef<HTMLSpanElement>(null);
|
|
const inView = useInView(ref, { once: true, margin: "-10% 0px" });
|
|
const mv = useMotionValue(0);
|
|
const [display, setDisplay] = useState((0).toFixed(decimals) + suffix);
|
|
|
|
useEffect(() => {
|
|
const unsub = mv.on("change", (v) => setDisplay(v.toFixed(decimals) + suffix));
|
|
return unsub;
|
|
}, [mv, decimals, suffix]);
|
|
|
|
useEffect(() => {
|
|
if (!inView) return;
|
|
const controls = animate(mv, to, { duration: 1.5, ease: [0.16, 1, 0.3, 1] });
|
|
return controls.stop;
|
|
}, [inView, to, mv]);
|
|
|
|
return <span ref={ref}>{display}</span>;
|
|
}
|
|
|
|
/** Wheel + ground-shadow primitives for VehicleArt — module-level (not
|
|
* declared inside a render body) so the React Compiler doesn't treat them
|
|
* as freshly-created components on every render. */
|
|
function VWheel({ cx, rim }: { cx: number; rim: string }) {
|
|
return (
|
|
<>
|
|
<circle cx={cx} cy="37" r="7" fill="#0a0a0a" />
|
|
<circle cx={cx} cy="37" r="7" fill="none" stroke={rim} strokeWidth="1.6" />
|
|
<circle cx={cx} cy="37" r="2.2" fill="#1c1c1c" />
|
|
</>
|
|
);
|
|
}
|
|
function VShadow() {
|
|
return <ellipse cx="40" cy="44.5" rx="30" ry="2.6" fill="rgba(0,0,0,0.4)" />;
|
|
}
|
|
function VDefs() {
|
|
return (
|
|
<defs>
|
|
<linearGradient id="swVBodyActive" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stopColor="#ff4d61" />
|
|
<stop offset="1" stopColor="#a10f22" />
|
|
</linearGradient>
|
|
</defs>
|
|
);
|
|
}
|
|
|
|
/** Vehicle illustration built from primitives (filled body/glass/wheels + ground
|
|
* shadow) so it reads as a small rendered vehicle rather than a line icon. */
|
|
function VehicleArt({ type, active }: { type: "bike" | "ev" | "mini" | "van" | "truck"; active: boolean }) {
|
|
const body = active ? "url(#swVBodyActive)" : "#4b4b54";
|
|
const glass = active ? "rgba(255,255,255,0.5)" : "rgba(255,255,255,0.28)";
|
|
const wheelRim = active ? "rgba(226,53,74,0.55)" : "rgba(255,255,255,0.2)";
|
|
|
|
if (type === "bike") {
|
|
return (
|
|
<svg viewBox="0 0 80 48">
|
|
<VDefs /><VShadow />
|
|
<line x1="18" y1="37" x2="34" y2="17" stroke={body} strokeWidth="3.4" strokeLinecap="round" />
|
|
<line x1="34" y1="17" x2="50" y2="17" stroke={body} strokeWidth="3.4" strokeLinecap="round" />
|
|
<line x1="50" y1="17" x2="62" y2="37" stroke={body} strokeWidth="3.4" strokeLinecap="round" />
|
|
<line x1="34" y1="17" x2="30" y2="37" stroke={body} strokeWidth="3.4" strokeLinecap="round" />
|
|
<line x1="50" y1="17" x2="55" y2="11" stroke={body} strokeWidth="3.4" strokeLinecap="round" />
|
|
<rect x="29" y="13" width="10" height="4" rx="2" fill={body} />
|
|
<VWheel cx={18} rim={wheelRim} /><VWheel cx={62} rim={wheelRim} />
|
|
</svg>
|
|
);
|
|
}
|
|
if (type === "ev") {
|
|
return (
|
|
<svg viewBox="0 0 80 48">
|
|
<VDefs /><VShadow />
|
|
<rect x="12" y="30" width="44" height="8" rx="4" fill={body} />
|
|
<rect x="47" y="13" width="4" height="19" rx="2" fill={body} />
|
|
<rect x="43" y="11" width="13" height="3.4" rx="1.7" fill={body} />
|
|
<path d="M32 30l-4 7h4l-3 6 8-8h-4l3-5z" fill="#fff" opacity="0.85" />
|
|
<VWheel cx={18} rim={wheelRim} /><VWheel cx={52} rim={wheelRim} />
|
|
</svg>
|
|
);
|
|
}
|
|
if (type === "mini") {
|
|
return (
|
|
<svg viewBox="0 0 80 48">
|
|
<VDefs /><VShadow />
|
|
<rect x="8" y="19" width="17" height="15" rx="3" fill={body} />
|
|
<rect x="11" y="22" width="10" height="7" rx="1.5" fill={glass} />
|
|
<rect x="26" y="12" width="35" height="22" rx="2.5" fill={body} />
|
|
<VWheel cx={19} rim={wheelRim} /><VWheel cx={49} rim={wheelRim} />
|
|
</svg>
|
|
);
|
|
}
|
|
if (type === "van") {
|
|
return (
|
|
<svg viewBox="0 0 80 48">
|
|
<VDefs /><VShadow />
|
|
<rect x="8" y="13" width="58" height="21" rx="6" fill={body} />
|
|
<rect x="12" y="17" width="15" height="9" rx="2" fill={glass} />
|
|
<VWheel cx={21} rim={wheelRim} /><VWheel cx={55} rim={wheelRim} />
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg viewBox="0 0 80 48">
|
|
<VDefs /><VShadow />
|
|
<rect x="4" y="15" width="17" height="19" rx="3" fill={body} />
|
|
<rect x="7" y="18" width="10" height="8" rx="1.5" fill={glass} />
|
|
<rect x="23" y="9" width="45" height="25" rx="2.5" fill={body} />
|
|
<VWheel cx={15} rim={wheelRim} /><VWheel cx={43} rim={wheelRim} /><VWheel cx={59} rim={wheelRim} />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
SECTION 01 — The AI Logistics Engine
|
|
Not a diagram, not a timeline: one dominant, always-alive engine
|
|
core. An order travels in, the engine visibly analyzes it, then
|
|
dispatches three coordinated outcomes at once, closing on delivery.
|
|
Answers "what happens after I place an order?" in one glance.
|
|
============================================================ */
|
|
function Sec01() {
|
|
const outputs = [
|
|
{ icon: IconDriver, label: "Fleet Driver", note: "Nearest available operator" },
|
|
{ icon: IconTruck, label: "Vehicle", note: "Bike, EV, van or truck" },
|
|
{ icon: IconRoute, label: "Route", note: "Optimized for time & cost" },
|
|
];
|
|
const readoutRows = ["Reads the order", "Picks the fleet driver & vehicle", "Plans the best route"];
|
|
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const orderRef = useRef<HTMLDivElement>(null);
|
|
const c1Ref = useRef<HTMLSpanElement>(null);
|
|
const engineRowRef = useRef<HTMLDivElement>(null);
|
|
const coreTagRef = useRef<HTMLSpanElement>(null);
|
|
const readoutDotRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
|
const readoutCheckRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
|
const readoutRowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const fanRefs = useRef<(SVGPathElement | null)[]>([]);
|
|
const outNodeRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const c2Ref = useRef<HTMLSpanElement>(null);
|
|
const trackingRef = useRef<HTMLDivElement>(null);
|
|
const c3Ref = useRef<HTMLSpanElement>(null);
|
|
const deliveredRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const wrap = wrapRef.current;
|
|
const order = orderRef.current;
|
|
const c1 = c1Ref.current;
|
|
const engineRow = engineRowRef.current;
|
|
const coreTag = coreTagRef.current;
|
|
const readoutDots = readoutDotRefs.current.filter(Boolean) as HTMLSpanElement[];
|
|
const readoutChecks = readoutCheckRefs.current.filter(Boolean) as HTMLSpanElement[];
|
|
const readoutRowsEl = readoutRowRefs.current.filter(Boolean) as HTMLDivElement[];
|
|
const fanLines = fanRefs.current.filter(Boolean) as SVGPathElement[];
|
|
const outNodes = outNodeRefs.current.filter(Boolean) as HTMLDivElement[];
|
|
const c2 = c2Ref.current;
|
|
const tracking = trackingRef.current;
|
|
const c3 = c3Ref.current;
|
|
const delivered = deliveredRef.current;
|
|
if (!wrap || !order || !c1 || !engineRow || !coreTag || fanLines.length === 0 || !c2 || !tracking || !c3 || !delivered) return;
|
|
const coreStatus = ["READING…", "CHOOSING…", "PLANNING…"];
|
|
|
|
const fanLens = fanLines.map((p) => p.getTotalLength());
|
|
gsap.set(fanLines, { strokeDasharray: (i: number) => fanLens[i], strokeDashoffset: (i: number) => fanLens[i] });
|
|
gsap.set(order, { opacity: 0, y: -14, scale: 0.9 });
|
|
gsap.set([c1, c2, c3], { "--fill": 0 } as gsap.TweenVars);
|
|
gsap.set(readoutDots, { opacity: 1 });
|
|
gsap.set(readoutChecks, { opacity: 0, scale: 0.4 });
|
|
gsap.set(readoutRowsEl, { opacity: 0.4 });
|
|
gsap.set(outNodes, { opacity: 0.28, y: 10, scale: 0.94 });
|
|
gsap.set(tracking, { opacity: 0.28, y: 10, scale: 0.94 });
|
|
gsap.set(delivered, { opacity: 0, scale: 0.7 });
|
|
|
|
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
if (reduced) {
|
|
gsap.set(order, { opacity: 1, y: 0, scale: 1 });
|
|
gsap.set([c1, c2, c3], { "--fill": 1 } as gsap.TweenVars);
|
|
gsap.set(fanLines, { strokeDashoffset: 0 });
|
|
gsap.set(readoutDots, { opacity: 0 });
|
|
gsap.set(readoutChecks, { opacity: 1, scale: 1 });
|
|
gsap.set(readoutRowsEl, { opacity: 1 });
|
|
gsap.set(outNodes, { opacity: 1, y: 0, scale: 1 });
|
|
gsap.set(tracking, { opacity: 1, y: 0, scale: 1 });
|
|
gsap.set(delivered, { opacity: 1, scale: 1 });
|
|
return;
|
|
}
|
|
|
|
const play = () => {
|
|
const tl = gsap.timeline();
|
|
// Order arrives, then travels into the engine.
|
|
tl.to(order, { opacity: 1, y: 0, scale: 1, duration: 0.45, ease: "back.out(2)" }, 0)
|
|
.to(c1, { "--fill": 1, duration: 0.5, ease: "power2.in" } as gsap.TweenVars, 0.5)
|
|
// The order stays on screen as the top of the flow (not a one-shot
|
|
// pulse that disappears) so the diagram reads top-to-bottom even
|
|
// to someone who arrives after the intro has already played.
|
|
// The engine visibly thinks: orbit + waveform speed up, and the
|
|
// readout ticks off analysing -> comparing -> selecting one at a time.
|
|
.add(() => engineRow.classList.add("is-active"), 1.05);
|
|
readoutRowsEl.forEach((row, i) => {
|
|
const at = 1.2 + i * 0.4;
|
|
// The core's own status readout — not just the side panel — cycles
|
|
// through what it's doing, so the "thinking" is legible even to
|
|
// someone glancing only at the core itself.
|
|
tl.call(() => { coreTag.textContent = coreStatus[i] ?? coreStatus[coreStatus.length - 1]; }, [], at)
|
|
.to(row, { opacity: 1, duration: 0.2 }, at)
|
|
.to(readoutDots[i], { opacity: 0, scale: 0.4, duration: 0.18 }, at + 0.16)
|
|
.to(readoutChecks[i], { opacity: 1, scale: 1, duration: 0.3, ease: "back.out(3)" }, at + 0.16);
|
|
});
|
|
const afterReadout = 1.2 + readoutRowsEl.length * 0.4 + 0.3;
|
|
tl.call(() => { coreTag.textContent = "DECIDED"; }, [], afterReadout - 0.15)
|
|
.call(() => { coreTag.textContent = "Doormile AI"; }, [], afterReadout + 0.5)
|
|
.add(() => engineRow.classList.remove("is-active"), afterReadout)
|
|
// Three coordinated outcomes dispatch together.
|
|
.to(fanLines, { strokeDashoffset: 0, duration: 0.5, stagger: 0.14, ease: "power2.out" }, afterReadout + 0.1)
|
|
.to(outNodes, { opacity: 1, y: 0, scale: 1, duration: 0.4, stagger: 0.14, ease: "back.out(2.2)" }, afterReadout + 0.1)
|
|
// Then live tracking, then delivered.
|
|
.to(c2, { "--fill": 1, duration: 0.4, ease: "power2.inOut" } as gsap.TweenVars, afterReadout + 0.75)
|
|
.to(tracking, { opacity: 1, y: 0, scale: 1, duration: 0.4, ease: "back.out(2.2)" }, afterReadout + 0.95)
|
|
.to(c3, { "--fill": 1, duration: 0.35, ease: "power2.inOut" } as gsap.TweenVars, afterReadout + 1.4)
|
|
.to(delivered, { opacity: 1, scale: 1, duration: 0.4, ease: "back.out(2.4)" }, afterReadout + 1.65);
|
|
};
|
|
const st = ScrollTrigger.create({ trigger: wrap, start: "top 75%", once: true, onEnter: play });
|
|
return () => st.kill();
|
|
}, [outputs.length, readoutRows.length]);
|
|
|
|
return (
|
|
<Section id="platform" bg="engine">
|
|
<SectionHead
|
|
n="01"
|
|
eyebrow="Order Processing"
|
|
title={<>What happens after a customer <span className="accent">places an order</span></>}
|
|
lede="One AI engine reads every order, decides the fleet driver, the vehicle and the route, then stays with the shipment until it's signed for."
|
|
/>
|
|
<div className="sw-eng" ref={wrapRef}>
|
|
<div className="sw-eng-order" ref={orderRef}>
|
|
<span className="sw-eng-order-ic">{IconBag}</span>
|
|
<span className="sw-eng-order-label">Customer Places Order</span>
|
|
<span className="sw-eng-order-note">Any channel, one order</span>
|
|
</div>
|
|
|
|
<span className="sw-eng-connector" ref={c1Ref}><span className="sw-eng-connector-fill" /></span>
|
|
|
|
<div className="sw-eng-engine-row" ref={engineRowRef}>
|
|
{/* A single, calm AI node — the Doormile mark plus a short status
|
|
label — instead of the earlier busy processor (orbiting dots,
|
|
corner brackets, radar sweep, waveform). Easier to read at a
|
|
glance: this is simply "the AI deciding". */}
|
|
<div className="sw-eng-core">
|
|
<div className="sw-eng-core-inner">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src="/images/doormile-mark.png" alt="" className="sw-eng-core-logo" loading="lazy" decoding="async" />
|
|
<span className="sw-eng-core-tag" ref={coreTagRef}>Doormile AI</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sw-eng-readout">
|
|
<span className="sw-eng-readout-title">Doormile AI Logistics Engine</span>
|
|
{readoutRows.map((r, i) => (
|
|
<div className="sw-eng-readout-row" ref={(el) => { readoutRowRefs.current[i] = el; }} key={r}>
|
|
<span className="sw-eng-readout-indicator">
|
|
<span className="sw-eng-readout-dot" ref={(el) => { readoutDotRefs.current[i] = el; }} />
|
|
<span className="sw-eng-readout-check" ref={(el) => { readoutCheckRefs.current[i] = el; }}>{IconCheck}</span>
|
|
</span>
|
|
<span className="sw-eng-readout-label">{r}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<svg className="sw-eng-fan" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
<path ref={(el) => { fanRefs.current[0] = el; }} d="M 50 0 Q 28 55 15 100" className="sw-eng-line--out" />
|
|
<path ref={(el) => { fanRefs.current[1] = el; }} d="M 50 0 L 50 100" className="sw-eng-line--out" />
|
|
<path ref={(el) => { fanRefs.current[2] = el; }} d="M 50 0 Q 72 55 85 100" className="sw-eng-line--out" />
|
|
</svg>
|
|
|
|
<div className="sw-eng-outputs">
|
|
{outputs.map((o, i) => (
|
|
<div className="sw-eng-out" ref={(el) => { outNodeRefs.current[i] = el; }} key={o.label}>
|
|
<span className="sw-eng-out-ic">{o.icon}</span>
|
|
<span className="sw-eng-out-label">{o.label}</span>
|
|
<span className="sw-eng-out-note">{o.note}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<span className="sw-eng-connector" ref={c2Ref}><span className="sw-eng-connector-fill" /></span>
|
|
|
|
<div className="sw-eng-tracking" ref={trackingRef}>
|
|
<span className="sw-eng-out-ic">{IconPin}</span>
|
|
<span className="sw-eng-out-label">Live Tracking</span>
|
|
<span className="sw-eng-out-note">Visible to everyone, live</span>
|
|
</div>
|
|
|
|
<span className="sw-eng-connector" ref={c3Ref}><span className="sw-eng-connector-fill" /></span>
|
|
|
|
<div className="sw-eng-delivered" ref={deliveredRef}>
|
|
<span className="sw-eng-delivered-ic">{IconBoxCheck}</span>
|
|
<span>Delivered</span>
|
|
<em>Confirmed at the door</em>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Premium filled (not outline) illustrations for the five pickup
|
|
* checkpoints — two-tone red/steel shapes rather than thin-stroke icons. */
|
|
function PickupIcon({ kind }: { kind: "request" | "driver" | "arrival" | "otp" | "collected" }) {
|
|
if (kind === "request") {
|
|
return (
|
|
<svg viewBox="0 0 32 32">
|
|
<rect x="5" y="12" width="22" height="15" rx="2" fill="#C01227" />
|
|
<path d="M5 12l11-6 11 6" fill="none" stroke="#fff" strokeWidth="1.6" strokeLinejoin="round" />
|
|
<rect x="14" y="12" width="4" height="15" fill="rgba(255,255,255,0.5)" />
|
|
</svg>
|
|
);
|
|
}
|
|
if (kind === "driver") {
|
|
return (
|
|
<svg viewBox="0 0 32 32">
|
|
<path d="M16 4a8 8 0 00-8 8c0 6 8 16 8 16s8-10 8-16a8 8 0 00-8-8z" fill="#C01227" />
|
|
<circle cx="16" cy="12" r="3.4" fill="#fff" />
|
|
</svg>
|
|
);
|
|
}
|
|
if (kind === "arrival") {
|
|
return (
|
|
<svg viewBox="0 0 32 32">
|
|
<path d="M5 15l11-9 11 9" fill="none" stroke="#C01227" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
<rect x="8" y="14" width="16" height="13" rx="1.5" fill="#1c1c1c" stroke="rgba(255,255,255,0.2)" />
|
|
<rect x="14" y="19" width="4" height="8" fill="#C01227" />
|
|
</svg>
|
|
);
|
|
}
|
|
if (kind === "otp") {
|
|
return (
|
|
<svg viewBox="0 0 32 32">
|
|
<rect x="9" y="4" width="14" height="24" rx="3" fill="#1c1c1c" stroke="rgba(255,255,255,0.25)" />
|
|
<circle cx="12.5" cy="16" r="1.4" fill="#E2354A" />
|
|
<circle cx="16" cy="16" r="1.4" fill="#E2354A" />
|
|
<circle cx="19.5" cy="16" r="1.4" fill="#E2354A" />
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg viewBox="0 0 32 32">
|
|
<rect x="5" y="12" width="22" height="15" rx="2" fill="#3a3a3a" />
|
|
<path d="M5 12l11-6 11 6" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.6" />
|
|
<circle cx="24" cy="10" r="7" fill="#C01227" />
|
|
<path d="M21 10l2 2 4-4" stroke="#fff" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** One wheel: a hub + realistic 5-spoke rim so rotation is actually visible
|
|
* (a plain circle would spin invisibly). `sw-van-wheel` is rotated directly
|
|
* every frame in Sec02 — transformBox/transformOrigin make the CSS
|
|
* transform rotate around the hub, not the SVG's (0,0) origin. */
|
|
/** `front` marks the wheel Sec02 gives an extra small steering rotation to
|
|
* on top of its continuous spin — the van always travels leading with this
|
|
* wheel (see the flip/tilt math in Sec02, which orients the sprite so
|
|
* cx=100 always faces the direction of travel). */
|
|
function VanWheel({ cx, front }: { cx: number; front?: boolean }) {
|
|
return (
|
|
<g className={`sw-van-wheel${front ? " sw-van-wheel--front" : ""}`} style={{ transformBox: "fill-box", transformOrigin: "center" }}>
|
|
<circle cx={cx} cy="50" r="12" fill="#0a0a0a" />
|
|
<circle cx={cx} cy="50" r="12" fill="none" stroke="url(#swVanRim)" strokeWidth="2" />
|
|
<circle cx={cx} cy="50" r="7.4" fill="#1b1b1e" stroke="rgba(255,255,255,0.28)" strokeWidth="0.8" />
|
|
{/* Spoke offsets are precomputed (not Math.cos/sin at render time) —
|
|
trig can round to a different last digit between the server and
|
|
client JS engines, which React's hydration check flags as a
|
|
mismatch even though it's visually meaningless. */}
|
|
{[
|
|
{ dx: 6.6, dy: 0 },
|
|
{ dx: 2.04, dy: 6.28 },
|
|
{ dx: -5.34, dy: 3.88 },
|
|
{ dx: -5.34, dy: -3.88 },
|
|
{ dx: 2.04, dy: -6.28 },
|
|
].map(({ dx, dy }) => (
|
|
<line
|
|
key={`${dx}-${dy}`}
|
|
x1={cx}
|
|
y1="50"
|
|
x2={cx + dx}
|
|
y2={50 + dy}
|
|
stroke="rgba(220,222,228,0.85)"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
/>
|
|
))}
|
|
<circle cx={cx} cy="50" r="2.1" fill="#4a4a50" />
|
|
</g>
|
|
);
|
|
}
|
|
|
|
/** Shared gradient/filter defs for VanArt — module scope so they aren't
|
|
* redeclared (and re-IDed) on every render. */
|
|
function VanDefs() {
|
|
return (
|
|
<defs>
|
|
<linearGradient id="swVanBody" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stopColor="#ff5166" />
|
|
<stop offset="0.45" stopColor="#d81b32" />
|
|
<stop offset="1" stopColor="#8c0f1f" />
|
|
</linearGradient>
|
|
<linearGradient id="swVanCab" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stopColor="#f6f6f8" />
|
|
<stop offset="0.55" stopColor="#dcdce0" />
|
|
<stop offset="1" stopColor="#aeaeb6" />
|
|
</linearGradient>
|
|
<linearGradient id="swVanGlass" x1="0" y1="0" x2="1" y2="1">
|
|
<stop offset="0" stopColor="rgba(255,255,255,0.9)" />
|
|
<stop offset="0.5" stopColor="rgba(150,185,210,0.45)" />
|
|
<stop offset="1" stopColor="rgba(18,26,34,0.65)" />
|
|
</linearGradient>
|
|
<linearGradient id="swVanRim" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stopColor="#e8e9ee" />
|
|
<stop offset="1" stopColor="#8a8a92" />
|
|
</linearGradient>
|
|
<radialGradient id="swVanHeadlight" cx="0.5" cy="0.5" r="0.5">
|
|
<stop offset="0" stopColor="#fff9e0" />
|
|
<stop offset="1" stopColor="#ffd66b" />
|
|
</radialGradient>
|
|
</defs>
|
|
);
|
|
}
|
|
|
|
/** A premium, filled delivery-van illustration — body panels, cab glass with
|
|
* a reflection streak, side mirror, head/tail lights and realistic-rim
|
|
* wheels that actually spin (see VanWheel) — that physically rides the
|
|
* pickup route (see Sec02 below). Cargo box (rear) sits over cx=26, the cab
|
|
* and hood (front) over cx=100 — the sprite's native, unflipped pose noses
|
|
* toward cx=100, the side the flip/tilt math in Sec02 treats as the
|
|
* direction of travel. The ground shadow is rendered as a separate element
|
|
* outside this SVG (sw-pickup-van-shadow) so it can stay flat on the road
|
|
* while the body above it leans and bounces. Rim-light and contact-shadow
|
|
* are applied as a filter on .sw-van-svg (see CSS) rather than per-shape
|
|
* outlines, so every part of the silhouette gets the same edge without
|
|
* duplicating each path. */
|
|
function VanArt() {
|
|
return (
|
|
<svg viewBox="0 0 130 66" className="sw-van-svg">
|
|
<VanDefs />
|
|
|
|
{/* Cargo box (rear) */}
|
|
<rect x="6" y="10" width="76" height="40" rx="4" fill="url(#swVanBody)" />
|
|
<rect x="8" y="11.5" width="72" height="3.5" rx="1.75" fill="rgba(255,255,255,0.22)" />
|
|
<rect x="6" y="30" width="76" height="6" fill="rgba(255,255,255,0.94)" />
|
|
<line x1="14" y1="16" x2="14" y2="49" stroke="rgba(0,0,0,0.32)" strokeWidth="1.1" />
|
|
<rect x="10.5" y="30.8" width="2.6" height="4.4" rx="1.1" fill="rgba(0,0,0,0.4)" />
|
|
<rect x="6" y="15" width="4.2" height="8.5" rx="1.6" fill="#7a0e1a" />
|
|
|
|
{/* Cab + hood (front) */}
|
|
<path
|
|
d="M82 14 C93 14.5 101 17.5 108 22 C114.5 26 118.5 32 120.5 40 L120.5 50 L82 50 Z"
|
|
fill="url(#swVanCab)"
|
|
/>
|
|
<path
|
|
d="M91 21.5 C97.5 23.4 103 26.6 107.2 31.5 L106 38 L88.5 38 L88 25 Z"
|
|
fill="url(#swVanGlass)"
|
|
/>
|
|
<path d="M92 23.5 L96.5 22.4 L100 27.5 L94.5 29.5 Z" fill="rgba(255,255,255,0.55)" />
|
|
<rect x="103.5" y="24" width="6.5" height="3.2" rx="1.4" fill="#2a2a2e" transform="rotate(18 103.5 24)" />
|
|
|
|
{/* Lower body trim ties cab + cargo into one silhouette */}
|
|
<rect x="6" y="44" width="115" height="6" rx="2.4" fill="rgba(14,14,16,0.88)" />
|
|
|
|
{/* Front bumper + headlight */}
|
|
<rect x="113" y="40.5" width="8" height="9.5" rx="2" fill="#161618" />
|
|
<ellipse className="sw-van-headlight" cx="119.5" cy="42.5" rx="3.1" ry="3.6" fill="url(#swVanHeadlight)" />
|
|
|
|
{/* Wheels paint last so they sit in front of the body's lower trim,
|
|
not tucked behind it — they're the part of the vehicle closest to
|
|
the viewer in a side-profile illustration. */}
|
|
<VanWheel cx={26} />
|
|
<VanWheel cx={100} front />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
SECTION 02 — Smart Pickup Management
|
|
Not another progress timeline: an illustrated delivery van physically
|
|
drives a route drawn over the pickup photo, accelerating and decelerating
|
|
between five real checkpoints (AI-coordinated, not a generic list).
|
|
============================================================ */
|
|
/** Waypoints the van drives through, in section-relative percent, in the
|
|
* same order as `stops` below (Pickup Request -> ... -> Package Collected)
|
|
* so checkpoint reading order, road-progress direction and van travel
|
|
* direction all agree — left to right, like Google Maps navigation. The
|
|
* photo (see sw-pickup-img object-position below) is framed so the open
|
|
* road runs across its lower half with the van's open door at the left —
|
|
* these points trace a single road-perspective curve along that pavement
|
|
* (monotonic in both x and y: near/left is lower and closer, distant/right
|
|
* is higher and further out) rather than zig-zagging between arbitrary
|
|
* heights, so it reads as one route instead of a glitch line. Starts at the
|
|
* van's open door, where the destination pin sits and the delivery van
|
|
* departs from. Straight segments between them are interpolated directly as CSS
|
|
* left/top percentages (not CSS offset-path — bare path() coordinates are
|
|
* pixel units, not container-relative, so they don't scale with a
|
|
* responsive container). The whole group sits ~16 points higher than the
|
|
* raw pavement trace would put it (max y 68, not 84) so there's a real
|
|
* Photo -> Road -> Van -> whitespace -> section-end margin at the bottom
|
|
* instead of the lowest checkpoint's label crowding (or clipping against)
|
|
* the .sw-edge overflow:hidden boundary. */
|
|
const PICKUP_PTS = [
|
|
{ x: 18, y: 68 }, { x: 31, y: 64 }, { x: 49, y: 59 }, { x: 70, y: 53 }, { x: 91, y: 47 },
|
|
];
|
|
const PICKUP_DEST = { x: PICKUP_PTS[0].x, y: PICKUP_PTS[0].y - 15 };
|
|
|
|
/** Rounds each interior joint of a polyline into a short quadratic-curve
|
|
* corner (clamped to a fraction of the shorter adjacent segment so corners
|
|
* never overlap) — a believable turning radius instead of the sharp,
|
|
* decorative-looking angles a raw polyline draws at every waypoint. */
|
|
function roundedRouteD(points: { x: number; y: number }[], radius: number): string {
|
|
if (points.length < 3) return "M " + points.map((p) => `${p.x} ${p.y}`).join(" L ");
|
|
let d = `M ${points[0].x} ${points[0].y} `;
|
|
for (let i = 1; i < points.length - 1; i++) {
|
|
const prev = points[i - 1];
|
|
const curr = points[i];
|
|
const next = points[i + 1];
|
|
const v1x = curr.x - prev.x, v1y = curr.y - prev.y;
|
|
const v2x = next.x - curr.x, v2y = next.y - curr.y;
|
|
const len1 = Math.hypot(v1x, v1y);
|
|
const len2 = Math.hypot(v2x, v2y);
|
|
const r = Math.min(radius, len1 * 0.42, len2 * 0.42);
|
|
const p1x = curr.x - (v1x / len1) * r, p1y = curr.y - (v1y / len1) * r;
|
|
const p2x = curr.x + (v2x / len2) * r, p2y = curr.y + (v2y / len2) * r;
|
|
d += `L ${p1x} ${p1y} Q ${curr.x} ${curr.y} ${p2x} ${p2y} `;
|
|
}
|
|
const last = points[points.length - 1];
|
|
d += `L ${last.x} ${last.y}`;
|
|
return d;
|
|
}
|
|
/** The actual drawn/ridden road — rounded corners at every checkpoint so
|
|
* turns read as a real navigation route (Google Maps/Uber style) instead
|
|
* of an angular decorative polyline. Checkpoint markers still sit at the
|
|
* original sharp waypoints (PICKUP_PTS), which the rounded curve passes
|
|
* within `radius` units of. */
|
|
const PICKUP_ROAD_D = roundedRouteD(PICKUP_PTS, 4.5);
|
|
|
|
function Sec02() {
|
|
const stops = [
|
|
{ icon: <PickupIcon kind="request" />, label: "Pickup Request", note: "Order raised from any source" },
|
|
{ icon: <PickupIcon kind="driver" />, label: "Nearest Driver", note: "AI matches the closest rider" },
|
|
{ icon: <PickupIcon kind="arrival" />, label: "Driver Arrival", note: "Real-time ETA to origin" },
|
|
{ icon: <PickupIcon kind="otp" />, label: "OTP Verification", note: "Secure, tamper-proof handoff" },
|
|
{ icon: <PickupIcon kind="collected" />, label: "Package Collected", note: "Delivery lifecycle begins" },
|
|
];
|
|
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
const vanRef = useRef<HTMLDivElement>(null);
|
|
const vanBounceRef = useRef<HTMLDivElement>(null);
|
|
const vanRotRef = useRef<HTMLDivElement>(null);
|
|
const vanShadowRef = useRef<HTMLSpanElement>(null);
|
|
const routeProgressRef = useRef<SVGPathElement>(null);
|
|
const stopRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const confirmRef = useRef<HTMLDivElement>(null);
|
|
const confirmLabelRef = useRef<HTMLSpanElement>(null);
|
|
const aiDotRef = useRef<HTMLSpanElement>(null);
|
|
const aiCheckRef = useRef<HTMLSpanElement>(null);
|
|
const aiTextRef = useRef<HTMLSpanElement>(null);
|
|
const successGlowRef = useRef<HTMLSpanElement>(null);
|
|
|
|
useEffect(() => {
|
|
const wrap = wrapRef.current;
|
|
const img = imgRef.current;
|
|
const van = vanRef.current;
|
|
const vanBounce = vanBounceRef.current;
|
|
const vanRot = vanRotRef.current;
|
|
const vanShadow = vanShadowRef.current;
|
|
const routeProgress = routeProgressRef.current;
|
|
const confirm = confirmRef.current;
|
|
const confirmLabel = confirmLabelRef.current;
|
|
const aiDot = aiDotRef.current;
|
|
const aiCheck = aiCheckRef.current;
|
|
const aiText = aiTextRef.current;
|
|
const successGlow = successGlowRef.current;
|
|
const stopEls = stopRefs.current.filter(Boolean) as HTMLDivElement[];
|
|
const wheelEls = van ? Array.from(van.querySelectorAll<SVGGElement>(".sw-van-wheel")) : [];
|
|
const frontWheelEl = van ? van.querySelector<SVGGElement>(".sw-van-wheel--front") : null;
|
|
if (!wrap || !img || !van || !vanBounce || !vanRot || !vanShadow || !routeProgress || !confirm || !confirmLabel || !aiDot || !aiCheck || !aiText || !successGlow || stopEls.length === 0) return;
|
|
|
|
const routeLen = routeProgress.getTotalLength();
|
|
gsap.set(routeProgress, { strokeDasharray: routeLen, strokeDashoffset: routeLen });
|
|
|
|
const activate = (i: number) => {
|
|
stopEls.forEach((el, k) => el.classList.toggle("is-active", k <= i));
|
|
};
|
|
|
|
// Sample the van's position AND heading directly off the rendered,
|
|
// rounded-corner road (same <path> the progress line draws) instead of
|
|
// computing straight-segment math separately — guarantees the wheels
|
|
// stay glued to the visible curve through the turns, not just at
|
|
// waypoints. Symmetric before/after sampling gives a stable tangent.
|
|
const sampleRoad = (pct: number) => {
|
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
const len = (clamped / 100) * routeLen;
|
|
const eps = Math.max(0.4, routeLen * 0.015);
|
|
const ptA = routeProgress.getPointAtLength(Math.max(0, len - eps));
|
|
const ptB = routeProgress.getPointAtLength(Math.min(routeLen, len + eps));
|
|
const pt = routeProgress.getPointAtLength(len);
|
|
const dx = ptB.x - ptA.x, dy = ptB.y - ptA.y;
|
|
const flip = dx < 0 ? -1 : 1;
|
|
const tilt = Math.atan2(dy, Math.abs(dx)) * (180 / Math.PI);
|
|
return { x: pt.x, y: pt.y, tilt, flip };
|
|
};
|
|
|
|
// Real-vehicle motion is derived from actual elapsed time between frames
|
|
// (not the tween's nominal duration, since scrub time is scroll-driven
|
|
// and can move at any real-world speed): wheel spin, steering and the
|
|
// ground shadow all react to genuine instantaneous speed, so they
|
|
// naturally ease in with the tween's acceleration and settle to a stop
|
|
// as it brakes into each checkpoint.
|
|
let lastPct = 0;
|
|
let lastTs = typeof performance !== "undefined" ? performance.now() : 0;
|
|
let lastTilt = 0;
|
|
let wheelDeg = 0;
|
|
let movingUntil = 0;
|
|
const place = (pct: number) => {
|
|
const now = typeof performance !== "undefined" ? performance.now() : lastTs + 16;
|
|
const dtMs = Math.max(1, now - lastTs);
|
|
const dPct = pct - lastPct;
|
|
const speed = Math.abs(dPct) / dtMs; // % of route per ms
|
|
lastPct = pct;
|
|
lastTs = now;
|
|
|
|
const p = sampleRoad(pct);
|
|
van.style.left = `${p.x}%`;
|
|
van.style.top = `${p.y}%`;
|
|
// scaleX flips the sprite to face its direction of travel (it's drawn
|
|
// facing right-ish by default); rotate applies only the small road-
|
|
// slope tilt, never the raw heading angle — a full atan2(dy,dx)
|
|
// rotate() would flip the van upside-down whenever a route bends back
|
|
// on itself. A CSS transition on this element (see
|
|
// .sw-pickup-van-rot) eases the tilt change with a slight overshoot,
|
|
// reading as banking into the turn instead of snapping.
|
|
vanRot.style.transform = `scaleX(${p.flip}) rotate(${p.flip * p.tilt}deg)`;
|
|
|
|
wheelDeg += dPct * 22;
|
|
const turnRate = p.tilt - lastTilt;
|
|
lastTilt = p.tilt;
|
|
const steer = Math.max(-16, Math.min(16, turnRate * 5));
|
|
for (const w of wheelEls) w.style.transform = `rotate(${wheelDeg}deg)`;
|
|
if (frontWheelEl) frontWheelEl.style.transform = `rotate(${wheelDeg + steer}deg)`;
|
|
|
|
const moving = speed > 0.0015;
|
|
if (moving) movingUntil = now + 160; // small grace period so bounce/speed-lines don't flicker off between frames
|
|
const isMoving = now < movingUntil;
|
|
vanBounce.classList.toggle("is-moving", isMoving);
|
|
van.classList.toggle("is-moving", isMoving);
|
|
|
|
// The shadow lives outside the rotate/bounce chain so it stays flat on
|
|
// the road while the body above it leans and bobs — a real contact
|
|
// shadow, not one that's rigidly glued to the sprite. Motion blur is
|
|
// deliberately NOT applied here — it was making the van harder to
|
|
// see while moving, which is the opposite of the goal; the speed
|
|
// lines (CSS, see .sw-pickup-van-speedlines) carry the sense of
|
|
// speed instead, without ever softening the van itself.
|
|
const shadowSpread = Math.min(0.3, speed * 6);
|
|
vanShadow.style.opacity = String(0.85 - shadowSpread);
|
|
vanShadow.style.transform = `scaleX(${1 + shadowSpread})`;
|
|
|
|
routeProgress.style.strokeDashoffset = `${routeLen * (1 - pct / 100)}`;
|
|
};
|
|
const showToast = (label: string) => {
|
|
confirmLabel.textContent = label;
|
|
gsap.fromTo(confirm, { opacity: 0, scale: 0.4 }, { opacity: 1, scale: 1, duration: 0.2, ease: "back.out(3)" });
|
|
};
|
|
const finish = () => {
|
|
aiText.textContent = "AI Coordinated Pickup";
|
|
gsap.to(aiDot, { opacity: 0, duration: 0.2 });
|
|
gsap.to(aiCheck, { opacity: 1, scale: 1, duration: 0.35, ease: "back.out(3)" });
|
|
vanBounce.classList.remove("is-moving");
|
|
van.classList.remove("is-moving");
|
|
// A brief, subtle success flash across the frame — the completed
|
|
// route and checkmarks stay put, this just punctuates the moment.
|
|
// The van's own fade-out lives as a tween on the main scrubbed
|
|
// timeline (not here) so scrolling back up reverses it smoothly —
|
|
// an imperative one-shot gsap.to() in this callback doesn't
|
|
// un-happen on scroll-back the way a scrubbed tween does.
|
|
gsap.fromTo(successGlow, { opacity: 0 }, { opacity: 1, duration: 0.25, ease: "power1.out", yoyo: true, repeat: 1 });
|
|
};
|
|
|
|
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
gsap.set(confirm, { opacity: 0, scale: 0.4 });
|
|
gsap.set(aiCheck, { opacity: 0, scale: 0.4 });
|
|
gsap.set(successGlow, { opacity: 0 });
|
|
place(0);
|
|
|
|
if (reduced) {
|
|
place(100);
|
|
activate(stops.length - 1);
|
|
finish();
|
|
return;
|
|
}
|
|
|
|
const proxy = { v: 0 };
|
|
|
|
// A fixed timeline "budget" (10 units, arbitrary but easy to reason
|
|
// about in tenths) split across stages so scroll % always means the
|
|
// same thing: 0-20% idle at Pickup Request, 20/40/60/80% continuous
|
|
// driving between the four remaining checkpoints, 95% arrival, and a
|
|
// 95-100% exit beat — never "scroll 40%, van still at 0%" or "van
|
|
// already parked at 80% with 20% of empty scroll left to go".
|
|
const IDLE = 2; // 0 -> 20%: waiting at Pickup Request (CSS idle-breathe/headlight-blink below, no JS needed)
|
|
const LEG = 2; // 20->40, 40->60, 60->80
|
|
const LEG4 = 1.5; // 80 -> 95
|
|
const EXIT = 0.5; // 95 -> 100: glow, badge, package-loaded toast, van fade
|
|
const CP_HOLD = 0.3; // brief full stop at a checkpoint before departing again — skipped on the very first leg, since the idle stage already is that pause
|
|
|
|
// end at "bottom 84%" (not 42%) so the ride finishes while the section's
|
|
// top — and the "AI Coordinated Pickup" badge pinned there — clears the
|
|
// ~115px sticky header with real margin, instead of the finish state
|
|
// landing scrolled out from under the nav.
|
|
const tl = gsap.timeline({
|
|
scrollTrigger: { trigger: wrap, start: "top 82%", end: "bottom 84%", scrub: 0.7 },
|
|
});
|
|
tl.call(() => activate(0), [], 0);
|
|
|
|
const legs = [
|
|
{ from: 0, to: 25, start: IDLE, dur: LEG, hold: 0 },
|
|
{ from: 25, to: 50, start: IDLE + LEG, dur: LEG, hold: CP_HOLD },
|
|
{ from: 50, to: 75, start: IDLE + LEG * 2, dur: LEG, hold: CP_HOLD },
|
|
{ from: 75, to: 100, start: IDLE + LEG * 3, dur: LEG4, hold: CP_HOLD },
|
|
];
|
|
legs.forEach((leg, idx) => {
|
|
const driveStart = leg.start + leg.hold;
|
|
const driveDur = leg.dur - leg.hold;
|
|
const accelD = driveDur * 0.32, cruiseD = driveDur * 0.36, brakeD = driveDur * 0.32;
|
|
const accelEnd = leg.from + (leg.to - leg.from) * 0.28;
|
|
const cruiseEnd = leg.from + (leg.to - leg.from) * 0.72;
|
|
const arrival = leg.start + leg.dur;
|
|
// Depart, cruise, brake — three distinct phases per leg instead of one
|
|
// symmetric ease, so the ride reads as accelerate / hold speed / slow
|
|
// into the checkpoint rather than one smooth blob of motion.
|
|
tl.to(proxy, { v: accelEnd, duration: accelD, ease: "power2.in", onUpdate: () => place(proxy.v) }, driveStart)
|
|
.to(proxy, { v: cruiseEnd, duration: cruiseD, ease: "none", onUpdate: () => place(proxy.v) }, driveStart + accelD)
|
|
.to(proxy, { v: leg.to, duration: brakeD, ease: "power2.out", onUpdate: () => place(proxy.v) }, driveStart + accelD + cruiseD)
|
|
.call(() => activate(idx + 1), [], arrival);
|
|
if (idx === 2) {
|
|
// OTP Verification arrival (80%).
|
|
tl.call(() => showToast("OTP Verified"), [], arrival)
|
|
.to(confirm, { opacity: 0, duration: 0.2 }, arrival + 0.25);
|
|
}
|
|
});
|
|
|
|
// Exit beat, entirely inside the last 5% of scroll: arrive -> scan ->
|
|
// package loaded -> success glow + badge swap -> van fades. The fade is
|
|
// a normal tween on this SAME scrubbed timeline (not a one-shot
|
|
// gsap.to() outside it), so scrolling back up smoothly un-fades the
|
|
// van instead of leaving it permanently hidden.
|
|
const exitStart = IDLE + LEG * 3 + LEG4; // 9.5 -> 95%, matches the last leg's own arrival time
|
|
tl.call(() => showToast("Scanning package…"), [], exitStart)
|
|
.call(() => showToast("Package Loaded"), [], exitStart + 0.15)
|
|
.to(confirm, { opacity: 0, duration: 0.15 }, exitStart + 0.3)
|
|
.call(finish, [], exitStart + 0.3)
|
|
.to(van, { opacity: 0, duration: EXIT - 0.3, ease: "power1.in" }, exitStart + 0.3);
|
|
|
|
tl.to(img, { scale: 1.07, ease: "none", duration: tl.duration() }, 0);
|
|
|
|
return () => {
|
|
tl.scrollTrigger?.kill();
|
|
tl.kill();
|
|
};
|
|
}, [stops.length]);
|
|
|
|
return (
|
|
<Section id="pickup" bg="charcoal">
|
|
<SectionHead
|
|
n="02"
|
|
eyebrow="First Mile Pickup"
|
|
title={<>Smart <span className="accent">pickup</span> management</>}
|
|
lede="Every pickup request is received, validated, assigned and confirmed before a single mile is driven — so deliveries start the right way, every time."
|
|
/>
|
|
<div className="sw-edge sw-pickup" ref={wrapRef}>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img ref={imgRef} src="/images/tab-pic-1.webp" alt="Doormile pickup in progress" className="sw-pickup-img" loading="lazy" decoding="async" />
|
|
<div className="sw-pickup-overlay" aria-hidden="true" />
|
|
|
|
<span className="sw-pickup-ai-badge">
|
|
<span className="sw-pickup-ai-indicator">
|
|
<span className="sw-livedot" ref={aiDotRef} />
|
|
<span className="sw-pickup-ai-check" ref={aiCheckRef}>{IconCheck}</span>
|
|
</span>
|
|
<span ref={aiTextRef}>AI Coordinating Pickup</span>
|
|
</span>
|
|
<div className="sw-pickup-confirm" ref={confirmRef} aria-hidden="true">{IconCheck}<span ref={confirmLabelRef}>Verified</span></div>
|
|
|
|
<svg className="sw-pickup-road" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
<path d={PICKUP_ROAD_D} className="sw-pickup-road-shadow" />
|
|
<path d={PICKUP_ROAD_D} className="sw-pickup-road-path" />
|
|
<path d={PICKUP_ROAD_D} className="sw-pickup-road-lane" />
|
|
{/* Full route, always shown, in neutral grey — the "not yet
|
|
travelled" state, Google Maps/Uber style. */}
|
|
<path d={PICKUP_ROAD_D} className="sw-pickup-road-remaining" />
|
|
{/* Same path, drawn on top in brand red via strokeDashoffset —
|
|
covers the grey up to the van's current position only, so the
|
|
road ahead of the van always stays grey instead of the whole
|
|
route reading as "done" at once. */}
|
|
<path ref={routeProgressRef} d={PICKUP_ROAD_D} className="sw-pickup-road-progress" />
|
|
</svg>
|
|
|
|
<div className="sw-pickup-dest" style={{ left: `${PICKUP_DEST.x}%`, top: `${PICKUP_DEST.y}%` }}>
|
|
{IconPin}
|
|
<span>Pickup Point</span>
|
|
</div>
|
|
|
|
{/* Static left/top (matching place(0) in the effect below) so the
|
|
van renders parked at Pickup Request in the server-rendered
|
|
markup itself — visible on first paint, not just once JS
|
|
hydrates and the effect runs a moment later. */}
|
|
<div className="sw-pickup-van" ref={vanRef} style={{ left: `${PICKUP_PTS[0].x}%`, top: `${PICKUP_PTS[0].y}%` }}>
|
|
<span className="sw-pickup-van-shadow" ref={vanShadowRef} aria-hidden="true" />
|
|
<span className="sw-pickup-van-speedlines" aria-hidden="true"><i /><i /><i /></span>
|
|
<div className="sw-pickup-van-bounce" ref={vanBounceRef}>
|
|
<div className="sw-pickup-van-rot" ref={vanRotRef}>
|
|
<VanArt />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<span className="sw-pickup-success-glow" ref={successGlowRef} aria-hidden="true" />
|
|
|
|
{stops.map((s, i) => (
|
|
<div className="sw-pickup-stop" style={{ left: `${PICKUP_PTS[i].x}%`, top: `${PICKUP_PTS[i].y}%` }} ref={(el) => { stopRefs.current[i] = el; }} key={s.label}>
|
|
<span className="sw-pickup-stop-icwrap">
|
|
<span className="sw-pickup-stop-ripple" aria-hidden="true" />
|
|
<span className="sw-pickup-stop-ic">{s.icon}</span>
|
|
<span className="sw-pickup-stop-badge" aria-hidden="true">{IconCheck}</span>
|
|
</span>
|
|
<span className="sw-pickup-stop-label">{s.label}</span>
|
|
<span className="sw-pickup-stop-note">{s.note}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
SECTION 03 — AI Vehicle Selection (split layout)
|
|
Richer vehicle illustrations. Hovering an order profile triggers a
|
|
brief "AI evaluating" scan sweep before the vehicle reselects.
|
|
============================================================ */
|
|
/** Rejection reason shown on each non-winning vehicle card, indexed
|
|
* [profileIndex][vehicleIndex] — "" for whichever vehicle that profile
|
|
* actually selects (never read, since that card gets .is-winner instead). */
|
|
const VEH_REJECT_REASONS: string[][] = [
|
|
["", "Not Needed", "Too Large", "Too Large", "Too Large"], // Small Parcel -> Bike
|
|
["Too Small", "Capacity", "", "Overkill", "Too Large"], // Retail Restock -> Mini Truck
|
|
["Too Small", "Capacity", "Capacity", "", "Overkill"], // Bulk Pallet -> Van
|
|
["Range Limit", "Range Limit", "Capacity", "Capacity", ""], // Long Haul -> Truck
|
|
];
|
|
const VEH_DECISION_STEPS = ["Analysing", "Comparing", "Filtering", "Selecting", "Dispatch Ready"];
|
|
|
|
const AI_VEHICLE_SELECTION_ITEMS = [
|
|
{ title: "Small Parcel", text: "Instantly routed to agile urban two-wheeler & EV bike fleets for zero-delay last-mile delivery. (2 kg · 4 km)" },
|
|
{ title: "Retail Restock", text: "Optimized for mid-capacity electric vans and urban cargo units with strict SLA time windows. (80 kg · 12 km)" },
|
|
{ title: "Bulk Pallet", text: "Matched to heavy-duty mini trucks and multi-pallet freight vehicles with route weight clearance. (600 kg · 30 km)" },
|
|
{ title: "Long Haul", text: "Assigned to primary long-distance transport trucks with real-time driver & route monitoring. (2 t · 140 km)" },
|
|
];
|
|
|
|
function Sec03() {
|
|
return (
|
|
<ConnectedLogisticsSection
|
|
id="vehicle-ai"
|
|
eyebrow="/ Vehicle Selection /"
|
|
title="AI vehicle selection"
|
|
imageSrc="/images/home2-pic-3.webp"
|
|
imageAlt="AI Vehicle Selection"
|
|
items={AI_VEHICLE_SELECTION_ITEMS}
|
|
/>
|
|
);
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
SECTION 04 — Smart Route Intelligence (AI Decision Engine)
|
|
A complete replacement for the earlier map/vehicle visualization. The
|
|
real value of the product is the AI *making the decision*, not a truck
|
|
driving — so this is an enterprise decision dashboard, not navigation.
|
|
Four candidate routes appear as glass cards; the AI scores them on live
|
|
metrics (ETA / distance / fuel / traffic) with values counting in and
|
|
bars filling, then the losers mute and one card wins, a "Best Route
|
|
Selected" confirmation lands, and a small dispatch beat runs. Self-
|
|
looping ~9s real-time timeline (the model this section has always used),
|
|
started once on scroll-in.
|
|
============================================================ */
|
|
type AdeRoute = { id: string; name: string; eta: number; dist: number; fuel: number; traffic: "Light" | "Moderate" | "Heavy"; score: number; reason: string | null; winner?: boolean };
|
|
/** Deterministic candidates — the winner (D) is genuinely best on every
|
|
* axis (lowest ETA, shortest distance, least fuel, light traffic, top
|
|
* score) so the AI's choice reads as obviously correct, not arbitrary. */
|
|
const ADE_ROUTES: AdeRoute[] = [
|
|
{ id: "A", name: "Route A", eta: 22, dist: 19.8, fuel: 71, traffic: "Heavy", score: 64, reason: "Heavy traffic" },
|
|
{ id: "B", name: "Route B", eta: 18, dist: 22.4, fuel: 66, traffic: "Moderate", score: 73, reason: "Longer distance" },
|
|
{ id: "C", name: "Route C", eta: 16, dist: 17.1, fuel: 70, traffic: "Light", score: 81, reason: "Higher fuel cost" },
|
|
{ id: "D", name: "Route D", eta: 13, dist: 15.6, fuel: 52, traffic: "Light", score: 97, reason: null, winner: true },
|
|
];
|
|
const ADE_WINNER = ADE_ROUTES.findIndex((r) => r.winner);
|
|
|
|
function Sec04() {
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const statusRef = useRef<HTMLSpanElement>(null);
|
|
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const flowRef = useRef<HTMLDivElement>(null);
|
|
const resultRef = useRef<HTMLDivElement>(null);
|
|
const dispatchRef = useRef<HTMLDivElement>(null);
|
|
const dispatchBarRef = useRef<HTMLSpanElement>(null);
|
|
|
|
useEffect(() => {
|
|
const panel = panelRef.current;
|
|
const status = statusRef.current;
|
|
const cards = cardRefs.current.filter(Boolean) as HTMLDivElement[];
|
|
const flow = flowRef.current;
|
|
const result = resultRef.current;
|
|
const dispatch = dispatchRef.current;
|
|
const dispatchBar = dispatchBarRef.current;
|
|
if (!panel || !status || cards.length === 0 || !flow || !result || !dispatch || !dispatchBar) return;
|
|
|
|
// Per-card element handles — queried once (same technique the old
|
|
// section used with the van's wheels) so the timeline can drive each
|
|
// card's numbers/bars without a giant ref matrix.
|
|
const parts = cards.map((card) => ({
|
|
card,
|
|
eta: card.querySelector<HTMLElement>('[data-k="eta"]')!,
|
|
dist: card.querySelector<HTMLElement>('[data-k="dist"]')!,
|
|
fuel: card.querySelector<HTMLElement>('[data-k="fuel"]')!,
|
|
fuelBar: card.querySelector<HTMLElement>(".sw-ade-fuel-bar > i")!,
|
|
scoreVal: card.querySelector<HTMLElement>(".sw-ade-score-val")!,
|
|
scoreBar: card.querySelector<HTMLElement>(".sw-ade-score-bar > i")!,
|
|
traffic: card.querySelector<HTMLElement>(".sw-ade-traffic")!,
|
|
verdict: card.querySelector<HTMLElement>(".sw-ade-verdict")!,
|
|
}));
|
|
|
|
const blankNums = () => {
|
|
parts.forEach((p) => { p.eta.textContent = "—"; p.dist.textContent = "—"; p.fuel.textContent = "—"; p.scoreVal.textContent = "0"; });
|
|
};
|
|
|
|
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
if (reduced) {
|
|
gsap.set(cards, { opacity: 1, y: 0 });
|
|
parts.forEach((p, i) => {
|
|
const r = ADE_ROUTES[i];
|
|
p.eta.textContent = `${r.eta}`; p.dist.textContent = r.dist.toFixed(1); p.fuel.textContent = `${r.fuel}`; p.scoreVal.textContent = `${r.score}`;
|
|
gsap.set(p.fuelBar, { width: `${r.fuel}%` }); gsap.set(p.scoreBar, { width: `${r.score}%` });
|
|
gsap.set([p.traffic, p.verdict], { opacity: 1, y: 0 });
|
|
p.card.classList.toggle("is-winner", i === ADE_WINNER);
|
|
p.card.classList.toggle("is-muted", i !== ADE_WINNER);
|
|
});
|
|
gsap.set([flow, result, dispatch], { opacity: 1, y: 0 });
|
|
gsap.set(dispatchBar, { width: "100%" });
|
|
status.textContent = "Best route selected";
|
|
return;
|
|
}
|
|
|
|
const STATUS: [number, string][] = [
|
|
[0, "Initializing decision engine…"],
|
|
[0.6, "Generating route options…"],
|
|
[1.9, "Reading live traffic & road data…"],
|
|
[3.0, "Calculating fuel, distance & ETA…"],
|
|
[4.2, "Scoring 4 candidate routes…"],
|
|
[5.6, "Comparing against optimal thresholds…"],
|
|
[6.6, "Best route selected"],
|
|
[7.6, "Dispatching vehicle…"],
|
|
[8.9, "Shipment in transit"],
|
|
];
|
|
|
|
const tl = gsap.timeline({ paused: true, repeat: -1, repeatDelay: 1.1 });
|
|
tl.eventCallback("onUpdate", () => {
|
|
const t = tl.time();
|
|
let s = STATUS[0][1];
|
|
for (const [time, text] of STATUS) { if (t >= time) s = text; else break; }
|
|
status.textContent = s;
|
|
// "AI is reading this card" glow only during the scoring window.
|
|
const scoring = t >= 1.9 && t < 5.5;
|
|
cards.forEach((c) => c.classList.toggle("is-scoring", scoring));
|
|
});
|
|
|
|
// Count-up helper — adds a proxy tween straight to the timeline (fromTo
|
|
// so every loop restarts cleanly from 0), writing the rounded value to
|
|
// the element each frame.
|
|
const countTo = (el: HTMLElement, to: number, at: number, dur: number, dec = 0) => {
|
|
const o = { v: 0 };
|
|
tl.fromTo(o, { v: 0 }, { v: to, duration: dur, ease: "power2.out", onUpdate: () => { el.textContent = dec ? o.v.toFixed(dec) : `${Math.round(o.v)}`; } }, at);
|
|
};
|
|
|
|
// t=0 reset (re-runs every loop): clear classes + blank the numbers so
|
|
// Step 1 always begins from a clean slate.
|
|
tl.call(() => { cards.forEach((c) => c.classList.remove("is-scoring", "is-muted", "is-winner")); blankNums(); }, [], 0);
|
|
|
|
// Step 1 — the four route options appear.
|
|
tl.fromTo(cards, { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.5, stagger: 0.12, ease: "power3.out" }, 0.5);
|
|
|
|
// Step 2 — the AI evaluates each route: metrics count in, bars fill,
|
|
// score climbs, traffic pill lands.
|
|
parts.forEach((p, i) => {
|
|
const r = ADE_ROUTES[i];
|
|
const at = 1.9 + i * 0.22;
|
|
tl.fromTo(p.traffic, { opacity: 0 }, { opacity: 1, duration: 0.3 }, at);
|
|
countTo(p.eta, r.eta, at, 1.0);
|
|
countTo(p.dist, r.dist, at, 1.0, 1);
|
|
countTo(p.fuel, r.fuel, at, 1.0);
|
|
tl.fromTo(p.fuelBar, { width: "0%" }, { width: `${r.fuel}%`, duration: 1.0, ease: "power2.out" }, at);
|
|
countTo(p.scoreVal, r.score, at + 0.3, 1.3);
|
|
tl.fromTo(p.scoreBar, { width: "0%" }, { width: `${r.score}%`, duration: 1.3, ease: "power2.out" }, at + 0.3);
|
|
});
|
|
|
|
// Flow connector fades in as the compare begins.
|
|
tl.fromTo(flow, { opacity: 0 }, { opacity: 1, duration: 0.4 }, 5.3);
|
|
|
|
// Step 3 — compare: losers mute, the winner highlights, verdicts land.
|
|
tl.call(() => {
|
|
cards.forEach((c, i) => { c.classList.toggle("is-winner", i === ADE_WINNER); c.classList.toggle("is-muted", i !== ADE_WINNER); });
|
|
}, [], 5.6);
|
|
parts.forEach((p, i) => { tl.fromTo(p.verdict, { opacity: 0, y: 4 }, { opacity: 1, y: 0, duration: 0.4, ease: "power2.out" }, 5.7 + i * 0.1); });
|
|
|
|
// Step 4 — the confirmation banner.
|
|
tl.fromTo(result, { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.5, ease: "back.out(1.4)" }, 6.6);
|
|
|
|
// Step 5 — dispatch: truck + "Delivery Started" + a progress bar.
|
|
tl.fromTo(dispatch, { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: 0.45, ease: "power2.out" }, 7.6);
|
|
tl.fromTo(dispatchBar, { width: "0%" }, { width: "100%", duration: 1.4, ease: "power2.inOut" }, 7.8);
|
|
|
|
const st = ScrollTrigger.create({ trigger: panel, start: "top 80%", once: true, onEnter: () => tl.play(0) });
|
|
return () => { st.kill(); tl.kill(); };
|
|
}, []);
|
|
|
|
return (
|
|
<Section id="routing" bg="white">
|
|
<div className="sw-qhead">
|
|
<ScrollReveal yOffset={16} triggerOnce>
|
|
<div className="sw-qhead-kicker">
|
|
<span className="sw-qhead-eyebrow">/ Smart Route Intelligence /</span>
|
|
<hr className="sw-qhead-divider" />
|
|
</div>
|
|
</ScrollReveal>
|
|
|
|
<ScrollReveal yOffset={20} delay={0.05} triggerOnce>
|
|
<div className="sw-qhead-stroke-title">
|
|
Smart Route Intelligence
|
|
</div>
|
|
</ScrollReveal>
|
|
|
|
<div className="sw-qhead-split">
|
|
<GsapWordRevealText
|
|
tag="h2"
|
|
className="sw-qhead-h2"
|
|
style={{ textTransform: "none" }}
|
|
delay={0.08}
|
|
>
|
|
Parallel Evaluation & Instant AI Dispatch
|
|
</GsapWordRevealText>
|
|
<div className="sw-qhead-desc">
|
|
<GsapWordRevealText tag="p" delay={0.16}>
|
|
Before a single dispatch is made, Doormile generates every possible route and lets the AI decide in real time.
|
|
</GsapWordRevealText>
|
|
<GsapWordRevealText tag="p" delay={0.24}>
|
|
Scoring each option on live traffic, fuel, distance, and ETA guarantees every order is committed to the single path that wins.
|
|
</GsapWordRevealText>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="sw-ade" ref={panelRef}>
|
|
<div className="sw-ade-bar">
|
|
<span className="sw-ade-brand"><span className="sw-ade-brand-ic">{IconCpu}</span>AI Decision Engine</span>
|
|
<span className="sw-ade-live"><span className="sw-ade-livedot" aria-hidden="true" />LIVE</span>
|
|
</div>
|
|
<div className="sw-ade-status-row"><span className="sw-ade-status" ref={statusRef} /></div>
|
|
<div className="sw-ade-grid">
|
|
{ADE_ROUTES.map((r, i) => (
|
|
<div className="sw-ade-card" key={r.id} ref={(el) => { cardRefs.current[i] = el; }}>
|
|
<div className="sw-ade-card-top">
|
|
<span className="sw-ade-card-name">{r.name}</span>
|
|
<span className={`sw-ade-traffic sw-ade-traffic--${r.traffic.toLowerCase()}`}>{r.traffic}</span>
|
|
</div>
|
|
<div className="sw-ade-rows">
|
|
<div className="sw-ade-row"><span className="sw-ade-row-l">ETA</span><span className="sw-ade-row-v"><b data-k="eta">—</b> min</span></div>
|
|
<div className="sw-ade-row"><span className="sw-ade-row-l">Distance</span><span className="sw-ade-row-v"><b data-k="dist">—</b> km</span></div>
|
|
<div className="sw-ade-row"><span className="sw-ade-row-l">Fuel</span><span className="sw-ade-row-v"><b data-k="fuel">—</b> %</span></div>
|
|
<div className="sw-ade-fuel-bar"><i /></div>
|
|
</div>
|
|
<div className="sw-ade-score">
|
|
<div className="sw-ade-score-head"><span>AI Score</span><span className="sw-ade-score-val">0</span></div>
|
|
<div className="sw-ade-score-bar"><i /></div>
|
|
</div>
|
|
<div className="sw-ade-verdict">
|
|
{r.winner
|
|
? <><span className="sw-ade-verdict-ic sw-ade-verdict-ic--win">{IconCheck}</span>Best match</>
|
|
: <><span className="sw-ade-verdict-ic">{IconX}</span>{r.reason}</>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="sw-ade-flow" ref={flowRef}>
|
|
<span className="sw-ade-flow-arrow" aria-hidden="true" />
|
|
<span className="sw-ade-flow-chip">{IconCpu}AI Analysis</span>
|
|
<span className="sw-ade-flow-arrow" aria-hidden="true" />
|
|
</div>
|
|
<div className="sw-ade-result" ref={resultRef}>
|
|
<span className="sw-ade-result-badge"><span className="sw-ade-livedot sw-ade-livedot--green" aria-hidden="true" />{IconCheck}Best Route Selected</span>
|
|
<span className="sw-ade-result-sub">Route D · 13 min ETA · 52% fuel · Light traffic</span>
|
|
</div>
|
|
<div className="sw-ade-dispatch" ref={dispatchRef}>
|
|
<span className="sw-ade-dispatch-ic">{IconTruck}</span>
|
|
<div className="sw-ade-dispatch-body">
|
|
<span className="sw-ade-dispatch-text">Shipment In Transit</span>
|
|
<span className="sw-ade-dispatch-bar"><i ref={dispatchBarRef} /></span>
|
|
</div>
|
|
<span className="sw-ade-dispatch-eta">ETA 13 min</span>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
SECTION 05 — Quantum Decision Engine (near-full-viewport flagship)
|
|
============================================================ */
|
|
function Sec05() {
|
|
return (
|
|
<Section id="quantum" bg="white">
|
|
<div className="sw-qhead">
|
|
<ScrollReveal yOffset={16} triggerOnce>
|
|
<div className="sw-qhead-kicker">
|
|
<span className="sw-qhead-eyebrow">/ Quantum Decision Engine /</span>
|
|
<hr className="sw-qhead-divider" />
|
|
</div>
|
|
</ScrollReveal>
|
|
|
|
<ScrollReveal yOffset={20} delay={0.05} triggerOnce>
|
|
<div className="sw-qhead-stroke-title">
|
|
Quantum Decision Engine
|
|
</div>
|
|
</ScrollReveal>
|
|
|
|
<div className="sw-qhead-split">
|
|
<GsapWordRevealText
|
|
tag="h2"
|
|
className="sw-qhead-h2"
|
|
style={{ textTransform: "none" }}
|
|
delay={0.08}
|
|
>
|
|
Simulating Hundreds of Parallel Possibilities in Real Time
|
|
</GsapWordRevealText>
|
|
<div className="sw-qhead-desc">
|
|
<GsapWordRevealText tag="p" delay={0.16}>
|
|
Traditional systems calculate one static route and dispatch it sequentially. Doormile Quantum evaluates hundreds of route possibilities simultaneously in milliseconds.
|
|
</GsapWordRevealText>
|
|
<GsapWordRevealText tag="p" delay={0.24}>
|
|
Simulating every traffic, SLA, and fuel cost outcome in parallel guarantees every order is auto-dispatched along the single most efficient path.
|
|
</GsapWordRevealText>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<QuantumStage />
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------
|
|
Phone chrome: a real iOS-style status bar (live JS clock, static
|
|
signal/5G/Wi-Fi/battery icons) and a glass notification banner.
|
|
Purely presentational additions to the Sec06 mockup — they don't
|
|
touch the map SVG or its native marker animation.
|
|
--------------------------------------------------------------- */
|
|
function PhoneStatusBar() {
|
|
// Rendered empty on the server / first paint so client and server markup
|
|
// agree, then filled in and re-synced on each minute boundary.
|
|
const [time, setTime] = useState("");
|
|
useEffect(() => {
|
|
const fmt = () => {
|
|
const d = new Date();
|
|
let h = d.getHours() % 12;
|
|
if (h === 0) h = 12;
|
|
return `${h}:${d.getMinutes().toString().padStart(2, "0")}`;
|
|
};
|
|
setTime(fmt());
|
|
let interval: ReturnType<typeof setInterval> | undefined;
|
|
const now = new Date();
|
|
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
|
const timeout = setTimeout(() => {
|
|
setTime(fmt());
|
|
interval = setInterval(() => setTime(fmt()), 60000);
|
|
}, msToNextMinute);
|
|
return () => {
|
|
clearTimeout(timeout);
|
|
if (interval) clearInterval(interval);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="sw-phone-statusbar" aria-hidden="true">
|
|
<span className="sw-phone-time">{time}</span>
|
|
<span className="sw-phone-status-icons">
|
|
<svg className="sw-phone-ic" viewBox="0 0 18 12" fill="currentColor">
|
|
<rect x="0" y="8" width="3" height="4" rx="1" />
|
|
<rect x="5" y="5.5" width="3" height="6.5" rx="1" />
|
|
<rect x="10" y="3" width="3" height="9" rx="1" />
|
|
<rect x="15" y="0.5" width="3" height="11.5" rx="1" />
|
|
</svg>
|
|
<span className="sw-phone-5g">5G</span>
|
|
<svg className="sw-phone-ic sw-phone-ic-wifi" viewBox="0 0 16 12" fill="currentColor">
|
|
<path d="M8 2.4c2.7 0 5.2 1 7.1 2.8L13.5 6.9C12 5.5 10.1 4.7 8 4.7S4 5.5 2.5 6.9L.9 5.2C2.8 3.4 5.3 2.4 8 2.4z" />
|
|
<path d="M8 6.6c1.5 0 2.9.6 3.9 1.6L8 12 4.1 8.2C5.1 7.2 6.5 6.6 8 6.6z" />
|
|
</svg>
|
|
<svg className="sw-phone-ic sw-phone-ic-batt" viewBox="0 0 27 13" fill="none">
|
|
<rect x="0.6" y="0.6" width="22" height="11.8" rx="3.4" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.1" />
|
|
<rect x="2.2" y="2.2" width="17.5" height="8.6" rx="2" fill="currentColor" />
|
|
<path d="M24.6 4.2c1 .5 1 3.6 0 4.1z" fill="currentColor" fillOpacity="0.5" />
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PhoneNotification() {
|
|
return (
|
|
<div className="sw-phone-notif">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src="/images/cropped-image-2.png" alt="Doormile Logo" className="sw-phone-notif-ic" loading="lazy" decoding="async" />
|
|
<div className="sw-phone-notif-body">
|
|
<div className="sw-phone-notif-top">
|
|
<span className="sw-phone-notif-app">Doormile</span>
|
|
<span className="sw-phone-notif-time">now</span>
|
|
</div>
|
|
<span className="sw-phone-notif-title">Your shipment is on the way</span>
|
|
<span className="sw-phone-notif-sub">Arriving in 12 min</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------
|
|
Sec06 feature-card hero illustrations. Large, flat, brand-red-on-dark
|
|
SVGs (not tiny icons) — each is the visual hero of its card. Declared
|
|
at module scope so the React Compiler treats them as stable components.
|
|
--------------------------------------------------------------- */
|
|
function ArtTracking() {
|
|
return (
|
|
<svg viewBox="0 0 340 200" className="sw-ldi-svg" role="img" aria-label="Live map tracking">
|
|
<defs>
|
|
<linearGradient id="ldiTrackBg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="rgba(255,255,255,0.07)" /><stop offset="1" stopColor="rgba(255,255,255,0.015)" /></linearGradient>
|
|
<linearGradient id="ldiRoute" x1="0" y1="1" x2="1" y2="0"><stop offset="0" stopColor="#C01227" /><stop offset="1" stopColor="#ff6a7d" /></linearGradient>
|
|
<radialGradient id="ldiPulse" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="rgba(226,53,74,0.55)" /><stop offset="1" stopColor="rgba(226,53,74,0)" /></radialGradient>
|
|
</defs>
|
|
<rect x="0.5" y="0.5" width="339" height="199" rx="18" fill="url(#ldiTrackBg)" stroke="rgba(255,255,255,0.08)" />
|
|
<g stroke="rgba(255,255,255,0.05)" strokeWidth="1">
|
|
<path d="M0 50h340M0 100h340M0 150h340M85 0v200M170 0v200M255 0v200" />
|
|
</g>
|
|
<g stroke="rgba(255,255,255,0.06)" strokeWidth="9" strokeLinecap="round" fill="none">
|
|
<path d="M-10 130 H120 V-10" /><path d="M255 210 V70 H360" />
|
|
</g>
|
|
<path d="M42 162 C 96 150 96 96 150 96 S 232 66 300 40" fill="none" stroke="url(#ldiRoute)" strokeWidth="5" strokeLinecap="round" />
|
|
<path d="M42 162 C 96 150 96 96 150 96 S 232 66 300 40" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="5" strokeLinecap="round" strokeDasharray="1 15" opacity="0.5" />
|
|
<circle cx="42" cy="162" r="7" fill="#0e0f12" stroke="#E2354A" strokeWidth="2.5" />
|
|
<circle cx="150" cy="96" r="28" fill="url(#ldiPulse)" />
|
|
<circle cx="150" cy="96" r="9.5" fill="#E2354A" stroke="#fff" strokeWidth="2.6" />
|
|
<path d="M300 20a17 17 0 00-17 17c0 12 17 27 17 27s17-15 17-27a17 17 0 00-17-17z" fill="#fff" />
|
|
<circle cx="300" cy="37" r="6.2" fill="#C01227" />
|
|
</svg>
|
|
);
|
|
}
|
|
function ArtVisibility() {
|
|
const nodes = [
|
|
{ x: 46, y: 40 }, { x: 294, y: 40 }, { x: 46, y: 160 }, { x: 294, y: 160 },
|
|
];
|
|
return (
|
|
<svg viewBox="0 0 340 200" className="sw-ldi-svg" role="img" aria-label="Shared operational visibility">
|
|
<defs>
|
|
<linearGradient id="ldiVisBg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="rgba(255,255,255,0.07)" /><stop offset="1" stopColor="rgba(255,255,255,0.015)" /></linearGradient>
|
|
<radialGradient id="ldiHub" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#ff6a7d" /><stop offset="1" stopColor="#C01227" /></radialGradient>
|
|
<radialGradient id="ldiHubGlow" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="rgba(226,53,74,0.5)" /><stop offset="1" stopColor="rgba(226,53,74,0)" /></radialGradient>
|
|
</defs>
|
|
<rect x="0.5" y="0.5" width="339" height="199" rx="18" fill="url(#ldiVisBg)" stroke="rgba(255,255,255,0.08)" />
|
|
<g stroke="rgba(226,53,74,0.4)" strokeWidth="1.6" strokeDasharray="2 6">
|
|
{nodes.map((n, i) => <line key={i} x1="170" y1="100" x2={n.x} y2={n.y} />)}
|
|
</g>
|
|
{nodes.map((n, i) => (
|
|
<g key={i}>
|
|
<circle cx={n.x} cy={n.y} r="20" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.12)" />
|
|
<circle cx={n.x} cy={n.y - 5} r="6" fill="rgba(255,255,255,0.55)" />
|
|
<path d={`M${n.x - 10} ${n.y + 13}c0 -7 4.5 -11 10 -11s10 4 10 11z`} fill="rgba(255,255,255,0.35)" />
|
|
</g>
|
|
))}
|
|
<circle cx="170" cy="100" r="52" fill="url(#ldiHubGlow)" />
|
|
<circle cx="170" cy="100" r="30" fill="url(#ldiHub)" />
|
|
<circle cx="170" cy="100" r="30" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" />
|
|
<circle cx="158" cy="100" r="3.4" fill="#fff" /><circle cx="170" cy="100" r="3.4" fill="#fff" /><circle cx="182" cy="100" r="3.4" fill="#fff" />
|
|
</svg>
|
|
);
|
|
}
|
|
function ArtProof() {
|
|
return (
|
|
<svg viewBox="0 0 340 200" className="sw-ldi-svg" role="img" aria-label="Verified proof of delivery">
|
|
<defs>
|
|
<linearGradient id="ldiProofBg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="rgba(255,255,255,0.07)" /><stop offset="1" stopColor="rgba(255,255,255,0.015)" /></linearGradient>
|
|
<linearGradient id="ldiBox" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#e8e9ee" /><stop offset="1" stopColor="#b7b9c2" /></linearGradient>
|
|
<linearGradient id="ldiBoxTop" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#f6f6f8" /><stop offset="1" stopColor="#d5d6dc" /></linearGradient>
|
|
<linearGradient id="ldiShield" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#ff6a7d" /><stop offset="1" stopColor="#C01227" /></linearGradient>
|
|
</defs>
|
|
<rect x="0.5" y="0.5" width="339" height="199" rx="18" fill="url(#ldiProofBg)" stroke="rgba(255,255,255,0.08)" />
|
|
{/* parcel */}
|
|
<path d="M78 92 L128 74 L188 92 L138 112 Z" fill="url(#ldiBoxTop)" />
|
|
<path d="M78 92 L138 112 L138 166 L78 146 Z" fill="url(#ldiBox)" />
|
|
<path d="M188 92 L138 112 L138 166 L188 146 Z" fill="#9a9ca6" />
|
|
<path d="M128 74 L128 128 L138 112 Z" fill="rgba(192,18,39,0.85)" />
|
|
<path d="M78 92 L138 112 L128 128 L78 108 Z" fill="rgba(0,0,0,0.08)" />
|
|
{/* OTP dots */}
|
|
<g>
|
|
<rect x="196" y="150" width="86" height="26" rx="8" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.1)" />
|
|
<circle cx="212" cy="163" r="4" fill="#E2354A" /><circle cx="228" cy="163" r="4" fill="#E2354A" /><circle cx="244" cy="163" r="4" fill="rgba(255,255,255,0.3)" /><circle cx="260" cy="163" r="4" fill="rgba(255,255,255,0.3)" />
|
|
</g>
|
|
{/* shield check */}
|
|
<path d="M228 40l30 11v22c0 20-13 31-30 37-17-6-30-17-30-37V51l30-11z" fill="url(#ldiShield)" />
|
|
<path d="M228 40l30 11v22c0 20-13 31-30 37-17-6-30-17-30-37V51l30-11z" fill="none" stroke="rgba(255,255,255,0.4)" strokeWidth="1.4" />
|
|
<path d="M216 74l9 9 16-19" fill="none" stroke="#fff" strokeWidth="4.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
SECTION 06 — Live Delivery Intelligence
|
|
Top-anchored heading, then a two-column showcase: a sticky live
|
|
phone mockup (unchanged) beside four scroll-revealed feature cards.
|
|
============================================================ */
|
|
function Sec06() {
|
|
// ETA still counts down live inside the phone's bottom card.
|
|
const [eta, setEta] = useState(24);
|
|
useEffect(() => {
|
|
const id = setInterval(() => setEta((v) => (v <= 4 ? 24 : v - 1)), 1400);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
const headingLines = [
|
|
<>Every shipment.</>,
|
|
<><span className="accent">Visible.</span></>,
|
|
<>In <span className="accent">real time.</span></>,
|
|
];
|
|
|
|
const features = [
|
|
{
|
|
art: <ArtTracking />,
|
|
title: "Live Tracking",
|
|
desc: "Follow every fleet driver on a live map from pickup to drop, with the exact location and route updating second by second.",
|
|
},
|
|
{
|
|
art: (
|
|
<div className="sw-ldi-card-art-stage">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
className="sw-ldi-svg-img"
|
|
src="/doorven.svg"
|
|
alt="Shipment journey — pickup request, fleet driver assignment, OTP verification and package collection"
|
|
/>
|
|
</div>
|
|
),
|
|
title: "Shipment Journey",
|
|
desc: "Track every milestone — from pickup request and fleet driver assignment to OTP verification and successful package collection — with complete real-time visibility.",
|
|
},
|
|
{
|
|
art: <ArtVisibility />,
|
|
title: "Shared Visibility",
|
|
desc: "Merchants, fleet drivers, operations teams and clients all watch the same shipment journey from a single source of truth.",
|
|
},
|
|
{
|
|
art: <ArtProof />,
|
|
title: "Proof of Delivery",
|
|
desc: "Secure OTP and photo confirmation close every shipment at the doorstep, giving tamper-proof evidence the parcel arrived.",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Section id="tracking" bg="grey">
|
|
<div className="sw-ldi">
|
|
<div className="sw-ldi-head">
|
|
<motion.span
|
|
className="sw-eyebrow sw-ldi-eyebrow"
|
|
initial={{ opacity: 0, y: 16, filter: "blur(8px)" }}
|
|
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
|
viewport={{ once: true, margin: "-12% 0px" }}
|
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
/ Live Logistics Intelligence /
|
|
</motion.span>
|
|
<h2 className="sw-ldi-h">
|
|
{headingLines.map((line, i) => (
|
|
<motion.span
|
|
className="sw-ldi-line"
|
|
key={i}
|
|
initial={{ opacity: 0, y: 26, filter: "blur(14px)" }}
|
|
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
|
viewport={{ once: true, margin: "-12% 0px" }}
|
|
transition={{ duration: 0.7, delay: 0.15 + i * 0.12, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
{line}
|
|
</motion.span>
|
|
))}
|
|
</h2>
|
|
<motion.p
|
|
className="sw-ldi-lede"
|
|
initial={{ opacity: 0, y: 18, filter: "blur(8px)" }}
|
|
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
|
viewport={{ once: true, margin: "-10% 0px" }}
|
|
transition={{ duration: 0.7, delay: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
Every stakeholder sees the same shipment journey in real time — from dispatch to
|
|
doorstep — with live tracking, intelligent updates and complete operational visibility.
|
|
</motion.p>
|
|
</div>
|
|
|
|
<div className="sw-ldi-grid">
|
|
<div className="sw-ldi-phone-col">
|
|
<div className="sw-ldi-phone-sticky">
|
|
<div className="sw-phone">
|
|
<div className="sw-phone-screen">
|
|
<div className="sw-phone-island" />
|
|
<PhoneStatusBar />
|
|
<PhoneNotification />
|
|
<div className="sw-phone-map">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
className="sw-phone-track-svg"
|
|
src="/live-track.svg"
|
|
alt="Live shipment route with the fleet driver moving along the tracked path"
|
|
/>
|
|
<div className="sw-phone-badge">
|
|
<motion.span
|
|
className="sw-phone-badge-dot"
|
|
animate={{ scale: [1, 1.35, 1], opacity: [0.65, 1, 0.65] }}
|
|
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
|
|
/>
|
|
LIVE
|
|
</div>
|
|
</div>
|
|
<div className="sw-phone-card">
|
|
<span className="sw-phone-card-label">Arriving in</span>
|
|
<span className="sw-phone-card-eta">
|
|
{eta}
|
|
<em>min</em>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sw-ldi-cards">
|
|
{features.map((f, i) => (
|
|
<motion.article
|
|
className="sw-ldi-card"
|
|
key={f.title}
|
|
initial={{ opacity: 0, y: 40, scale: 0.96 }}
|
|
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
|
viewport={{ once: true, margin: "-12% 0px" }}
|
|
transition={{ duration: 0.8, delay: i * 0.08, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
<div className="sw-ldi-card-art">{f.art}</div>
|
|
<h3 className="sw-ldi-card-title">{f.title}</h3>
|
|
<p className="sw-ldi-card-desc">{f.desc}</p>
|
|
</motion.article>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
SECTION 07 — Door-to-Door Delivery Workflow
|
|
Edge-to-edge aerial banner. GSAP scrubs the timeline fill, moves a
|
|
travelling marker, and highlights the current stage.
|
|
============================================================ */
|
|
function Sec07() {
|
|
const stages = [
|
|
{ icon: IconBox, label: "Pickup", note: "Order collected from source" },
|
|
{ icon: IconLayers, label: "Sorting", note: "Smart sorting at hubs" },
|
|
{ icon: IconRoute, label: "Route Assignment", note: "AI assigns best route & vehicle" },
|
|
{ icon: IconTruck, label: "Transit", note: "Real-time monitoring" },
|
|
{ icon: IconPin, label: "Out For Delivery", note: "Fleet driver reaches destination" },
|
|
{ icon: IconBoxCheck, label: "Delivered", note: "Proof of delivery & confirmation" },
|
|
];
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const stageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
|
|
useEffect(() => {
|
|
const wrap = wrapRef.current;
|
|
if (!wrap) return;
|
|
const stageEls = stageRefs.current.filter(Boolean) as HTMLDivElement[];
|
|
|
|
const setActiveIndex = (idx: number) => {
|
|
stageEls.forEach((el, i) => el.classList.toggle("is-active", i <= idx));
|
|
};
|
|
|
|
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
gsap.set(wrap, { "--fill": 0 } as gsap.TweenVars);
|
|
if (reduced) {
|
|
gsap.set(wrap, { "--fill": 1 } as gsap.TweenVars);
|
|
setActiveIndex(stages.length - 1);
|
|
return;
|
|
}
|
|
const st = ScrollTrigger.create({
|
|
trigger: wrap,
|
|
start: "top 80%",
|
|
end: "bottom 45%",
|
|
scrub: 0.6,
|
|
onUpdate: (self) => {
|
|
gsap.set(wrap, { "--fill": self.progress } as gsap.TweenVars);
|
|
setActiveIndex(Math.min(stages.length - 1, Math.floor(self.progress * stages.length)));
|
|
},
|
|
});
|
|
return () => st.kill();
|
|
}, [stages.length]);
|
|
|
|
return (
|
|
<Section id="delivery-flow" bg="charcoal">
|
|
<SectionHead
|
|
n="07"
|
|
eyebrow="Door to Door Delivery"
|
|
title={<>Door-to-door <span className="accent">logistics</span>, simplified</>}
|
|
lede="One continuous lifecycle — from the moment an order is picked up to the moment it's signed for at the door — visible at every stage, for every stakeholder."
|
|
/>
|
|
<div className="sw-edge sw-flow" ref={wrapRef}>
|
|
<div className="sw-flow-bg" aria-hidden="true">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src="/images/miletruth-bg.webp" alt="" loading="lazy" decoding="async" />
|
|
</div>
|
|
<div className="sw-flow-rail">
|
|
{stages.map((s, i) => (
|
|
<div className="sw-flow-stage" ref={(el) => { stageRefs.current[i] = el; }} key={s.label}>
|
|
<span className="sw-flow-num">{String(i + 1).padStart(2, "0")}</span>
|
|
<span className="sw-flow-ic">{s.icon}</span>
|
|
<span className="sw-flow-label">{s.label}</span>
|
|
<span className="sw-flow-note">{s.note}</span>
|
|
</div>
|
|
))}
|
|
<div className="sw-flow-track" aria-hidden="true"><span className="sw-flow-track-fill" /><span className="sw-flow-marker" /></div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
SECTION 08 — MileTruth™ AI (split layout)
|
|
The page's second (lightweight) 3D surface, paired beside the
|
|
sequential data -> AI -> recommendation -> improvement flow.
|
|
============================================================ */
|
|
function Sec08({ sectionId = "miletruth" }: { sectionId?: string } = {}) {
|
|
const flow = [
|
|
{ icon: IconDatabase, label: "Logistics Data", note: "Every completed trip" },
|
|
{ icon: IconCpu, label: "MileTruth AI", note: "Patterns & anomalies" },
|
|
{ icon: IconBulb, label: "Recommendations", note: "What to change" },
|
|
{ icon: IconTrendUp, label: "Improvement", note: "Measured lift" },
|
|
];
|
|
const feats = [
|
|
{ icon: IconShieldCheck, label: "SLA Monitoring" },
|
|
{ icon: IconTarget, label: "Demand Forecasting" },
|
|
{ icon: IconRoute, label: "Route & Fleet Optimization" },
|
|
{ icon: IconAlert, label: "Anomaly Detection" },
|
|
{ icon: IconTrendUp, label: "Performance Insights" },
|
|
];
|
|
return (
|
|
<Section id={sectionId} bg="mesh">
|
|
<div className="sw-split">
|
|
<div className="sw-mt-copy">
|
|
<SectionHead
|
|
n="08"
|
|
eyebrow="MileTruth AI"
|
|
title={<>MileTruth<span className="sw-tm">™</span> <span className="accent">AI</span></>}
|
|
lede="MileTruth continuously analyzes every completed shipment — turning raw operational data into recommendations that make the next mile faster, cheaper and more reliable."
|
|
className="sw-head--split"
|
|
/>
|
|
<div className="sw-mt-flow">
|
|
{flow.map((f, i) => (
|
|
<React.Fragment key={f.label}>
|
|
<motion.div
|
|
className="sw-mt-step"
|
|
initial={{ opacity: 0, y: 16 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true, margin: "-10% 0px" }}
|
|
transition={{ duration: 0.5, delay: i * 0.12 }}
|
|
>
|
|
<span className="sw-mt-step-ic">{f.icon}</span>
|
|
<span className="sw-mt-step-label">{f.label}</span>
|
|
<span className="sw-mt-step-note">{f.note}</span>
|
|
</motion.div>
|
|
{i < flow.length - 1 && <span className="sw-veh-arrow" aria-hidden="true">→</span>}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
<div className="sw-mt-feats">
|
|
{feats.map((f) => (
|
|
<span className="sw-mt-feat" key={f.label}><span className="sw-mt-feat-ic">{f.icon}</span>{f.label}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="sw-mt-canvas-col">
|
|
<div className="sw-mt-canvas"><MileTruthNetwork ariaLabel="MileTruth AI learning loop — shipment data feeds the AI core, which issues recommendations whose measured results feed the next shipment" /></div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
SECTION 09 — Operations Dashboard
|
|
Premium glass panel, widened. Framer Motion counters, growing
|
|
charts, staggered reveals, and a live-scrolling activity feed.
|
|
============================================================ */
|
|
const FEED_EVENTS = [
|
|
"Order #48213 delivered — Chennai Zone 4",
|
|
"Fleet driver assigned — TN 09 CD 7790",
|
|
"SLA risk flagged — Route 12, rerouting",
|
|
"Pickup confirmed — OTP verified, Anna Nagar",
|
|
"EV charging stop scheduled — Fleet 3",
|
|
"98.5% on-time — last 24 hours",
|
|
];
|
|
|
|
function LiveFeed() {
|
|
const [rows, setRows] = useState(() => FEED_EVENTS.slice(0, 4));
|
|
const nextRef = useRef(4);
|
|
const slotRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
const id = setInterval(() => {
|
|
const slot = slotRef.current;
|
|
const next = nextRef.current;
|
|
setRows((current) => {
|
|
const updated = [...current];
|
|
updated[slot] = FEED_EVENTS[next % FEED_EVENTS.length];
|
|
return updated;
|
|
});
|
|
slotRef.current = (slot + 1) % 4;
|
|
nextRef.current = next + 1;
|
|
}, 2500);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="sw-feed">
|
|
{rows.map((txt, i) => (
|
|
<div className="sw-feed-slot" key={i}>
|
|
<AnimatePresence initial={false} mode="wait">
|
|
<motion.div
|
|
key={txt}
|
|
className="sw-feed-row"
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -8 }}
|
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
<span className="sw-livedot" />{txt}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Sec09() {
|
|
const [kpiValues, setKpiValues] = useState({
|
|
activeFleet: 482,
|
|
shipments: 3140,
|
|
sla: 98.5,
|
|
exceptions: 12,
|
|
});
|
|
const [statusMix, setStatusMix] = useState([68, 22, 10]);
|
|
const [bars, setBars] = useState([38, 52, 46, 61, 58, 72, 66]);
|
|
const [heat, setHeat] = useState([12, 34, 62, 48, 20, 8, 44, 70, 58, 30, 16, 52, 66, 40, 22, 10, 36, 60, 54, 28, 14, 46, 64, 38, 20, 8, 30, 56]);
|
|
const [hotCells, setHotCells] = useState<number[]>([]);
|
|
const [fleet, setFleet] = useState([
|
|
{ id: "TN 01 EV 1234", status: "On Route", eta: "12 min", tone: "moving" },
|
|
{ id: "TN 04 AB 5521", status: "Delayed", eta: "28 min", tone: "delayed" },
|
|
{ id: "TN 09 CD 7790", status: "Loading", eta: "—", tone: "loading" },
|
|
{ id: "TN 02 XY 3348", status: "Idle", eta: "—", tone: "idle" },
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
if (reduced) return;
|
|
|
|
const kpiId = setInterval(() => {
|
|
setKpiValues((v) => ({
|
|
activeFleet: Math.max(476, Math.min(489, v.activeFleet + (Math.random() > 0.5 ? 1 : -1) * (Math.random() > 0.72 ? 2 : 1))),
|
|
shipments: v.shipments + 1 + Math.floor(Math.random() * 3),
|
|
sla: Math.max(98.3, Math.min(98.7, Number((v.sla + (Math.random() > 0.5 ? 0.1 : -0.1)).toFixed(1)))),
|
|
exceptions: Math.max(9, Math.min(15, v.exceptions + (Math.random() > 0.72 ? (Math.random() > 0.5 ? 1 : -1) : 0))),
|
|
}));
|
|
}, 2600);
|
|
|
|
const mixId = setInterval(() => {
|
|
const delivered = 66 + Math.round(Math.random() * 4);
|
|
const delayed = 9 + Math.round(Math.random() * 2);
|
|
setStatusMix([delivered, 100 - delivered - delayed, delayed]);
|
|
}, 4200);
|
|
|
|
const barsId = setInterval(() => {
|
|
setBars((current) => current.map((value) => Math.max(32, Math.min(78, value + Math.round(Math.random() * 6 - 3)))));
|
|
}, 3200);
|
|
|
|
const heatId = setInterval(() => {
|
|
const changed = Array.from({ length: 4 }, () => Math.floor(Math.random() * 28));
|
|
setHotCells(changed);
|
|
setHeat((current) => current.map((value, index) => (
|
|
changed.includes(index)
|
|
? Math.max(8, Math.min(72, value + Math.round(Math.random() * 18 - 9)))
|
|
: value
|
|
)));
|
|
window.setTimeout(() => setHotCells([]), 900);
|
|
}, 2100);
|
|
|
|
const fleetStates = [
|
|
{ status: "On Route", tone: "moving" },
|
|
{ status: "Loading", tone: "loading" },
|
|
{ status: "Delayed", tone: "delayed" },
|
|
{ status: "Idle", tone: "idle" },
|
|
];
|
|
const fleetId = setInterval(() => {
|
|
setFleet((current) => {
|
|
const index = Math.floor(Math.random() * current.length);
|
|
const nextState = fleetStates[Math.floor(Math.random() * fleetStates.length)];
|
|
return current.map((row, rowIndex) => {
|
|
if (rowIndex !== index) return row;
|
|
const eta =
|
|
nextState.tone === "idle" || nextState.tone === "loading"
|
|
? "—"
|
|
: `${6 + Math.floor(Math.random() * 24)} min`;
|
|
return { ...row, ...nextState, eta };
|
|
});
|
|
});
|
|
}, 5200);
|
|
|
|
return () => {
|
|
clearInterval(kpiId);
|
|
clearInterval(mixId);
|
|
clearInterval(barsId);
|
|
clearInterval(heatId);
|
|
clearInterval(fleetId);
|
|
};
|
|
}, []);
|
|
|
|
const kpis = [
|
|
{ icon: IconTruck, value: kpiValues.activeFleet, label: "Active Fleet" },
|
|
{ icon: IconBox, value: kpiValues.shipments, label: "Shipments Today" },
|
|
{ icon: IconTarget, value: kpiValues.sla, decimals: 1, suffix: "%", label: "SLA Compliance" },
|
|
{ icon: IconAlert, value: kpiValues.exceptions, label: "Open Exceptions" },
|
|
];
|
|
const heatColor = (v: number) => `rgba(192,18,39,${0.08 + (v / 70) * 0.6})`;
|
|
|
|
const ringSegs = [
|
|
{ pct: statusMix[0], color: "var(--sw-red)" },
|
|
{ pct: statusMix[1], color: "rgba(17,24,39,0.3)" },
|
|
{ pct: statusMix[2], color: "rgba(17,24,39,0.12)" },
|
|
];
|
|
const circ = 2 * Math.PI * 15.9155;
|
|
let cum = 0;
|
|
|
|
return (
|
|
<Section id="ops-dashboard" bg="white">
|
|
<SectionHead
|
|
n="09"
|
|
eyebrow="Operations Dashboard"
|
|
title={<>Operations <span className="accent">dashboard</span></>}
|
|
lede="One control tower for every operations team — fleet, drivers, shipments, exceptions, analytics and reports, all live in a single view."
|
|
/>
|
|
<div className="sw-panel sw-dash">
|
|
<div className="sw-dash-chrome">
|
|
<span className="sw-dash-brand"><span className="sw-livedot" />Doormile Operations</span>
|
|
<span className="sw-dash-live">LIVE · Updated just now</span>
|
|
</div>
|
|
|
|
<div className="sw-dash-kpis">
|
|
{kpis.map((k, i) => (
|
|
<motion.div
|
|
className="sw-kpi-tile"
|
|
key={k.label}
|
|
initial={{ opacity: 0, y: 18 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true, margin: "-10% 0px" }}
|
|
transition={{ duration: 0.5, delay: i * 0.08 }}
|
|
>
|
|
<span className="sw-kpi-tile-ic">{k.icon}</span>
|
|
<b className="sw-kpi-val"><FCount to={k.value} decimals={k.decimals ?? 0} suffix={k.suffix ?? ""} /></b>
|
|
<span className="sw-kpi-label">{k.label}</span>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="sw-dash-body">
|
|
<motion.div className="sw-dash-panel" initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-10% 0px" }} transition={{ duration: 0.5 }}>
|
|
<span className="sw-dash-panel-h">Shipment Status Mix</span>
|
|
<div className="sw-ring-wrap">
|
|
<svg viewBox="0 0 36 36" className="sw-ring">
|
|
<circle cx="18" cy="18" r="15.9155" className="sw-ring-track" />
|
|
{ringSegs.map((s, i) => {
|
|
const segLen = (s.pct / 100) * circ;
|
|
const dash = `${segLen} ${circ - segLen}`;
|
|
const offset = -cum;
|
|
cum += segLen;
|
|
return (
|
|
<motion.circle key={i} cx="18" cy="18" r="15.9155" fill="none" stroke={s.color} strokeWidth="3.2"
|
|
animate={{ strokeDasharray: dash, strokeDashoffset: offset }}
|
|
transition={{ duration: 0.75, ease: [0.16, 1, 0.3, 1] }}
|
|
transform="rotate(-90 18 18)" />
|
|
);
|
|
})}
|
|
</svg>
|
|
<div className="sw-ring-center"><b>{kpiValues.sla.toFixed(1)}%</b><span>On-Time</span></div>
|
|
</div>
|
|
<div className="sw-ring-legend">
|
|
<span><i style={{ background: "var(--sw-red)" }} />Delivered</span>
|
|
<span><i style={{ background: "rgba(17,24,39,0.3)" }} />In Transit</span>
|
|
<span><i style={{ background: "rgba(17,24,39,0.12)" }} />Delayed</span>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div className="sw-dash-panel" initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-10% 0px" }} transition={{ duration: 0.5, delay: 0.08 }}>
|
|
<span className="sw-dash-panel-h">Weekly Shipment Volume</span>
|
|
<div className="sw-bars">
|
|
{bars.map((h, i) => (
|
|
<motion.span
|
|
key={i}
|
|
className="sw-bar"
|
|
animate={{ height: `${h}%` }}
|
|
initial={{ scaleY: 0 }}
|
|
whileInView={{ scaleY: 1 }}
|
|
viewport={{ once: true, margin: "-10% 0px" }}
|
|
transition={{ duration: 0.75, delay: i * 0.04, ease: [0.16, 1, 0.3, 1] }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div className="sw-dash-panel" initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-10% 0px" }} transition={{ duration: 0.5, delay: 0.16 }}>
|
|
<span className="sw-dash-panel-h">Zone Demand Heatmap</span>
|
|
<div className="sw-heat">
|
|
{heat.map((v, i) => (
|
|
<motion.span
|
|
key={i}
|
|
className={`sw-heat-cell${hotCells.includes(i) ? " is-hot" : ""}`}
|
|
animate={{
|
|
backgroundColor: heatColor(v),
|
|
opacity: 1,
|
|
scale: hotCells.includes(i) ? 1.12 : 1,
|
|
}}
|
|
initial={{ opacity: 0, scale: 0.5 }}
|
|
whileInView={{ opacity: 1, scale: 1 }}
|
|
viewport={{ once: true, margin: "-10% 0px" }}
|
|
transition={{ duration: 0.45, delay: i * 0.006 }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div className="sw-dash-panel sw-dash-panel--fleet" initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-10% 0px" }} transition={{ duration: 0.5, delay: 0.24 }}>
|
|
<span className="sw-dash-panel-h">Fleet Status</span>
|
|
<div className="sw-fleet-list">
|
|
{fleet.map((f) => (
|
|
<div className="sw-fleet-row" key={f.id}>
|
|
<span className={`sw-fleet-dot sw-fleet-dot--${f.tone}`} />
|
|
<span className="sw-fleet-id">{f.id}</span>
|
|
<span className="sw-fleet-status">{f.status}</span>
|
|
<span className="sw-fleet-eta">{f.eta}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div className="sw-dash-panel sw-dash-panel--wide" initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-10% 0px" }} transition={{ duration: 0.5, delay: 0.3 }}>
|
|
<span className="sw-dash-panel-h">Live Activity</span>
|
|
<LiveFeed />
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
SECTION 10 — Why Businesses Choose Doormile
|
|
Open grid of individually floating stat cards, wider canvas.
|
|
============================================================ */
|
|
function Sec10() {
|
|
const cards = [
|
|
{ icon: IconTarget, value: 99.9, decimals: 1, suffix: "%", label: "Dispatch Accuracy" },
|
|
{ icon: IconZap, value: 45, suffix: "ms", label: "Decision Time" },
|
|
{ icon: IconClock, text: "24/7", label: "Live Monitoring" },
|
|
{ icon: IconShieldCheck, value: 100, suffix: "%", label: "SLA First" },
|
|
{ icon: IconTrendDown, text: "Lower", label: "Operational Cost" },
|
|
{ icon: IconGlobe, text: "Enterprise", label: "Scale-Ready" },
|
|
];
|
|
return (
|
|
<Section id="why-doormile" bg="grey">
|
|
<SectionHead
|
|
n="10"
|
|
eyebrow="Enterprise Growth"
|
|
title={<>Why businesses choose <span className="accent">Doormile</span></>}
|
|
lede="An AI decision engine, SLA-first operations and enterprise-grade scale — built to move from pickup to doorstep without the guesswork."
|
|
/>
|
|
<div className="sw-kpi10-grid">
|
|
{cards.map((c) => (
|
|
<TiltCard className="sw-kpi10-card" key={c.label}>
|
|
<span className="sw-kpi10-shine" aria-hidden="true" />
|
|
<span className="sw-kpi10-ic">{c.icon}</span>
|
|
<b className="sw-kpi10-val">
|
|
{c.text ?? <CountUp end={c.value!} decimals={c.decimals} suffix={c.suffix} />}
|
|
</b>
|
|
<span className="sw-kpi10-label">{c.label}</span>
|
|
</TiltCard>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function SolutionsWorkflowShell({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<>
|
|
<style dangerouslySetInnerHTML={{ __html: CSS }} />
|
|
<div id="sw-root">
|
|
<div className="sw-noise" aria-hidden="true" />
|
|
{children}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
Default export
|
|
============================================================ */
|
|
export default function SolutionsWorkflow() {
|
|
return (
|
|
<SolutionsWorkflowShell>
|
|
<Sec01 />
|
|
<Sec02 />
|
|
<Sec07 />
|
|
<Sec10 />
|
|
</SolutionsWorkflowShell>
|
|
);
|
|
}
|
|
|
|
function MigratedSolutionSection({ children }: { children: React.ReactNode }) {
|
|
return <SolutionsWorkflowShell>{children}</SolutionsWorkflowShell>;
|
|
}
|
|
|
|
export function SolutionAIVehicleSelection() {
|
|
return <MigratedSolutionSection><Sec03 /></MigratedSolutionSection>;
|
|
}
|
|
|
|
export function SolutionSmartRouteIntelligence() {
|
|
return <MigratedSolutionSection><Sec04 /></MigratedSolutionSection>;
|
|
}
|
|
|
|
export function SolutionQuantumDecisionEngine() {
|
|
return <MigratedSolutionSection><Sec05 /></MigratedSolutionSection>;
|
|
}
|
|
|
|
export function SolutionLiveLogisticsIntelligence() {
|
|
return <MigratedSolutionSection><Sec06 /></MigratedSolutionSection>;
|
|
}
|
|
|
|
export function SolutionMileTruthAI() {
|
|
return <MigratedSolutionSection><Sec08 sectionId="solution-miletruth-ai" /></MigratedSolutionSection>;
|
|
}
|
|
|
|
export function SolutionOperationsDashboard() {
|
|
return <MigratedSolutionSection><Sec09 /></MigratedSolutionSection>;
|
|
}
|
|
|
|
export function MigratedSolutionSections() {
|
|
return (
|
|
<SolutionsWorkflowShell>
|
|
<Sec03 />
|
|
<Sec04 />
|
|
<Sec05 />
|
|
<Sec06 />
|
|
<Sec08 sectionId="solution-miletruth-ai" />
|
|
<Sec09 />
|
|
</SolutionsWorkflowShell>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
Styles
|
|
============================================================ */
|
|
const CSS = `
|
|
#sw-root {
|
|
--sw-red: #C01227;
|
|
--sw-red-bright: #E2354A;
|
|
--sw-line: rgba(255,255,255,0.09);
|
|
--sw-text-dim: rgba(255,255,255,0.68);
|
|
/* Shared inner-card padding — the same clamp() token .sw-panel already
|
|
uses for its own interior. Applied to .sw-in so every section's title,
|
|
heading, subtitle and other loose content sits clear of the card's
|
|
rounded edge instead of touching it, on the same single value site-wide
|
|
(see .sw-in and .sw-edge below). */
|
|
--sw-in-pad: clamp(16px, 3vw, 32px);
|
|
--fill: 0;
|
|
position: relative;
|
|
/* clip (not hidden) so it still contains horizontal overflow from the
|
|
section animations without creating a scroll container — the latter
|
|
would break the Sec06 sticky phone. */
|
|
overflow: clip;
|
|
/* No background color here: each section now paints its own colour
|
|
entirely within its inset, rounded .sw-bg card (see the section-rhythm
|
|
rule below), so the page's own white canvas shows through in the
|
|
gutters/gaps between cards — the same way it shows around the Hero
|
|
and Footer cards, instead of a dark fill masking the new spacing. */
|
|
}
|
|
#sw-root, #sw-root * { font-family: var(--font-manrope), "Manrope", sans-serif; box-sizing: border-box; }
|
|
|
|
#sw-root .sw-noise {
|
|
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0.035; mix-blend-mode: overlay;
|
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
|
background-size: 180px 180px;
|
|
}
|
|
|
|
/* ---- Section rhythm + width (one shared card system, matching Hero/Footer) ----
|
|
Every section is its own floating card, using EXACTLY the outer-spacing
|
|
system Hero and Footer already use: a 20px (10px ≤1024px) left/right inset
|
|
+ a 24px (20px ≤767px) corner radius on the coloured surface (.sw-bg),
|
|
plus a single 40/32/24px (desktop/≤1024/≤767) gap between every stacked
|
|
card — Hero-to-Section-1, Section-to-Section, and Section-10-to-Footer
|
|
alike. One token pair drives all 10 sections; nothing here is
|
|
section-specific, so every section's outer spacing is identical. */
|
|
#sw-root .sw-sec { position: relative; padding: 96px 0; margin-top: 40px; overflow: clip; }
|
|
/* Left/right margin measured directly off the live Hero and Footer
|
|
containers (20px above the 1024px breakpoint, 10px at/below) — one
|
|
shared horizontal margin every section inherits through .sw-in, never
|
|
overridden per-section, so every section's edges land on the same
|
|
vertical grid as the Hero and Footer. */
|
|
#sw-root .sw-in { position: relative; z-index: 1; width: calc(100% - 40px); margin: 0 auto; padding: 0 var(--sw-in-pad); }
|
|
#sw-root .sw-bg { position: absolute; left: 20px; right: 20px; top: 0; bottom: 0; z-index: 0; border-radius: 24px; overflow: hidden; }
|
|
#sw-root #routing.sw-sec,
|
|
#sw-root #quantum.sw-sec { padding-top: clamp(24px, 3.5vw, 44px) !important; padding-bottom: clamp(24px, 3.5vw, 44px) !important; margin-top: 24px !important; }
|
|
#sw-root #quantum .sw-head,
|
|
#sw-root #routing .sw-head { margin-bottom: 24px !important; }
|
|
|
|
/* Poster-scale Header Layout for Quantum Engine (Matching "The Doormile Way") */
|
|
#sw-root .sw-qhead {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
margin-bottom: clamp(24px, 3vw, 36px);
|
|
width: 100%;
|
|
}
|
|
#sw-root .sw-qhead-kicker {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
width: 100%;
|
|
}
|
|
#sw-root .sw-qhead-eyebrow {
|
|
font-size: 13.5px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.08em;
|
|
color: #6b7280;
|
|
margin-bottom: 14px;
|
|
text-transform: uppercase;
|
|
}
|
|
#sw-root .sw-qhead-divider {
|
|
width: 100%;
|
|
height: 1px;
|
|
border: none;
|
|
background-color: rgba(0, 0, 0, 0.12);
|
|
margin: 0 0 24px 0;
|
|
}
|
|
#sw-root .sw-qhead-stroke-title {
|
|
margin: 0 0 24px 0;
|
|
font-weight: 800;
|
|
letter-spacing: -0.01em !important;
|
|
line-height: 1.1;
|
|
color: #ffffff !important;
|
|
-webkit-text-stroke: 2px #dc2626;
|
|
font-size: clamp(34px, 4.8vw, 68px);
|
|
text-transform: none;
|
|
}
|
|
#sw-root .sw-qhead-split {
|
|
display: grid;
|
|
grid-template-columns: 1.15fr 1fr;
|
|
gap: clamp(28px, 4.5vw, 56px);
|
|
align-items: flex-start;
|
|
margin-top: 8px;
|
|
}
|
|
#sw-root .sw-qhead-h2 {
|
|
font-size: clamp(24px, 2.8vw, 36px);
|
|
font-weight: 800;
|
|
line-height: 1.35;
|
|
letter-spacing: -0.015em;
|
|
color: #111827;
|
|
margin: 0;
|
|
text-transform: none !important;
|
|
}
|
|
#sw-root .sw-qhead-desc {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
#sw-root .sw-qhead-desc p {
|
|
font-size: 15px;
|
|
line-height: 1.7;
|
|
color: #4b5563;
|
|
margin: 0;
|
|
font-weight: 450;
|
|
}
|
|
@media (max-width: 900px) {
|
|
#sw-root .sw-qhead-split {
|
|
grid-template-columns: 1fr;
|
|
gap: 16px;
|
|
}
|
|
}
|
|
#sw-root .sw-bg--grey { background: #121212; }
|
|
#sw-root .sw-bg--charcoal { background: #171717; }
|
|
#sw-root .sw-bg--charcoal::before {
|
|
content: ''; position: absolute; inset: 0;
|
|
background: radial-gradient(90% 60% at 50% 0%, rgba(255,255,255,0.035) 0%, transparent 60%);
|
|
}
|
|
/* The grid-line pattern fades out in a vignette (via mask-image on its own
|
|
::before layer, not on .sw-bg itself) so only the pattern softens toward
|
|
the edges — the card's own #141414 fill stays fully opaque right up to
|
|
its rounded corners, same as every other section's card. Masking .sw-bg
|
|
directly would fade the fill too, showing the page's white canvas through
|
|
the middle of the card instead of a clean rounded edge. */
|
|
#sw-root .sw-bg--graph { background: #141414; }
|
|
#sw-root .sw-bg--graph::before {
|
|
content: ''; position: absolute; inset: 0;
|
|
background-image:
|
|
linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px);
|
|
background-size: 54px 54px;
|
|
-webkit-mask-image: radial-gradient(85% 75% at 50% 35%, #000 45%, transparent 100%);
|
|
mask-image: radial-gradient(85% 75% at 50% 35%, #000 45%, transparent 100%);
|
|
}
|
|
#sw-root .sw-bg--mesh { background: #141414; }
|
|
#sw-root .sw-bg--mesh::before, #sw-root .sw-bg--mesh::after { content: ''; position: absolute; border-radius: 50%; }
|
|
#sw-root .sw-bg--mesh::before { width: 62vw; height: 62vw; left: -16vw; top: -20vw; background: radial-gradient(circle, rgba(192,18,39,0.26) 0%, transparent 70%); }
|
|
#sw-root .sw-bg--mesh::after { width: 48vw; height: 48vw; right: -12vw; bottom: -18vw; background: radial-gradient(circle, rgba(255,255,255,0.05) 0%, transparent 70%); }
|
|
#sw-root .sw-bg--redglow { background: #121212; }
|
|
#sw-root .sw-bg--redglow::before {
|
|
content: ''; position: absolute; inset: 0;
|
|
background: radial-gradient(65% 60% at 50% 12%, rgba(226,53,74,0.36) 0%, transparent 70%),
|
|
radial-gradient(45% 45% at 85% 92%, rgba(192,18,39,0.2) 0%, transparent 70%);
|
|
}
|
|
/* Smooth, non-repeating "engine" ground — a soft vignette + a single
|
|
ambient glow centered behind the core. No grid, no tiling, no pattern. */
|
|
#sw-root .sw-bg--engine {
|
|
background:
|
|
radial-gradient(46% 40% at 50% 40%, rgba(226,53,74,0.16) 0%, transparent 72%),
|
|
radial-gradient(120% 90% at 50% 0%, #171414 0%, #101010 55%, #0c0c0c 100%);
|
|
}
|
|
/* A faint diagonal texture — just enough "logistics grid" ground under
|
|
Sec04's AI Decision Engine panel without competing with the glass cards
|
|
and animated metrics that sit on top of it. */
|
|
#sw-root .sw-bg--logisticsgrid {
|
|
background: #141414;
|
|
background-image:
|
|
repeating-linear-gradient(45deg, rgba(255,255,255,0.02) 0 2px, transparent 2px 26px),
|
|
repeating-linear-gradient(-45deg, rgba(192,18,39,0.028) 0 2px, transparent 2px 26px);
|
|
}
|
|
|
|
/* ---- Head ---- */
|
|
#sw-root .sw-head { margin-bottom: clamp(32px, 4vw, 56px); max-width: 820px; }
|
|
#sw-root .sw-head--center { max-width: 820px; margin-left: auto; margin-right: auto; text-align: center; }
|
|
#sw-root .sw-head--split { max-width: none; margin-bottom: clamp(30px, 3.4vw, 42px); }
|
|
#sw-root .sw-eyebrow {
|
|
display: inline-flex; align-items: center; gap: 10px;
|
|
color: var(--sw-red-bright) !important; font-weight: 800; text-transform: uppercase;
|
|
letter-spacing: 0.16em; font-size: 12.5px; margin-bottom: 20px;
|
|
}
|
|
#sw-root .sw-head--center .sw-eyebrow { justify-content: center; }
|
|
#sw-root .sw-eyebrow b {
|
|
color: #fff !important; background: var(--sw-red); border-radius: 6px;
|
|
padding: 2px 7px; font-size: 11.5px; letter-spacing: 0.04em;
|
|
}
|
|
#sw-root .sw-eyebrow--slash {
|
|
color: #6b7280 !important;
|
|
font-weight: 500 !important;
|
|
font-size: 13.5px !important;
|
|
letter-spacing: 0.05em !important;
|
|
background: none !important;
|
|
padding: 0 !important;
|
|
}
|
|
#sw-root .sw-h {
|
|
color: #fff !important; font-weight: 800 !important;
|
|
font-size: clamp(30px, 4vw, 52px) !important; line-height: 1.14 !important;
|
|
letter-spacing: -0.02em; margin: 0 0 18px !important;
|
|
}
|
|
#sw-root .sw-h .accent { color: var(--sw-red-bright) !important; }
|
|
#sw-root .sw-tm { font-size: 0.5em; vertical-align: super; }
|
|
#sw-root .sw-lede { color: var(--sw-text-dim) !important; font-size: clamp(15.5px, 1.15vw, 19px) !important; line-height: 1.65 !important; margin: 0 !important; font-weight: 400 !important; max-width: 620px; }
|
|
#sw-root .sw-head--center .sw-lede,
|
|
#quantum .sw-head--center .sw-lede,
|
|
.sw-head--center .sw-lede {
|
|
margin-left: auto !important;
|
|
margin-right: auto !important;
|
|
text-align: center !important;
|
|
}
|
|
|
|
/* ---- White section overrides ---- */
|
|
#sw-root .sw-sec--white .sw-eyebrow,
|
|
#sw-root #quantum .sw-eyebrow,
|
|
#quantum .sw-eyebrow { color: var(--sw-red-bright) !important; }
|
|
#sw-root .sw-sec--white .sw-eyebrow b,
|
|
#sw-root #quantum .sw-eyebrow b,
|
|
#quantum .sw-eyebrow b { background: var(--sw-red); color: #fff !important; }
|
|
|
|
#sw-root .sw-sec--white h2,
|
|
#sw-root .sw-sec--white .sw-h,
|
|
#sw-root #quantum h2,
|
|
#sw-root #quantum .sw-h,
|
|
#quantum h2,
|
|
#quantum .sw-h,
|
|
div#sw-root section#quantum h2.sw-h,
|
|
.logico-front-end #quantum h2,
|
|
.elementor-59 #quantum h2 { color: #111827 !important; }
|
|
|
|
#sw-root .sw-sec--white h2 .accent,
|
|
#sw-root .sw-sec--white .sw-h .accent,
|
|
#sw-root #quantum h2 .accent,
|
|
#sw-root #quantum .sw-h .accent,
|
|
#quantum h2 .accent,
|
|
#quantum .sw-h .accent,
|
|
div#sw-root section#quantum h2.sw-h .accent,
|
|
.logico-front-end #quantum h2 .accent,
|
|
.elementor-59 #quantum h2 .accent { color: var(--sw-red-bright) !important; }
|
|
|
|
#sw-root .sw-sec--white p,
|
|
#sw-root .sw-sec--white .sw-lede,
|
|
#sw-root #quantum p,
|
|
#sw-root #quantum .sw-lede,
|
|
#quantum p,
|
|
#quantum .sw-lede,
|
|
div#sw-root section#quantum p.sw-lede,
|
|
.logico-front-end #quantum p,
|
|
.elementor-59 #quantum p { color: #4b5563 !important; }
|
|
|
|
/* ---- Split layout ---- */
|
|
#sw-root .sw-split { display: grid; grid-template-columns: minmax(300px, 0.86fr) minmax(0, 1.4fr); gap: clamp(36px, 5vw, 72px); align-items: center; }
|
|
#sw-root .sw-split--reverse { grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.9fr); }
|
|
#sw-root #miletruth .sw-split { grid-template-columns: minmax(320px, 1fr) minmax(0, 1.25fr); }
|
|
|
|
/* ---- Floating panel ---- */
|
|
#sw-root .sw-panel {
|
|
position: relative; width: 100%; border-radius: 24px;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.012));
|
|
border: 1px solid var(--sw-line);
|
|
box-shadow: 0 50px 100px -50px rgba(0,0,0,0.7);
|
|
padding: clamp(16px, 3vw, 32px);
|
|
}
|
|
|
|
/* ---- Edge-to-edge (Hero-language) frame ----
|
|
These banners/media are deliberately meant to run flush to the section
|
|
card's own rounded edge (matching how Hero's banner image touches its own
|
|
card edge) — so they cancel out .sw-in's new inner padding with an equal
|
|
negative margin + width expansion, on the same --sw-in-pad token, instead
|
|
of adopting a padded, narrower frame like the rest of a section's content. */
|
|
#sw-root .sw-edge { position: relative; width: calc(100% + var(--sw-in-pad) * 2); margin: 0 calc(var(--sw-in-pad) * -1); border-radius: 24px; overflow: hidden; }
|
|
|
|
/* ---- Shared chip / arrow / livedot primitives ---- */
|
|
#sw-root .sw-veh-chip {
|
|
display: inline-flex; align-items: center; padding: 8px 16px; border-radius: 999px;
|
|
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.11);
|
|
color: rgba(255,255,255,0.8); font-size: 13px; font-weight: 600; letter-spacing: 0.02em;
|
|
}
|
|
#sw-root .sw-veh-arrow { color: rgba(255,255,255,0.28); font-size: 20px; flex-shrink: 0; align-self: center; }
|
|
#sw-root .sw-livedot {
|
|
width: 8px; height: 8px; border-radius: 50%; background: var(--sw-red-bright);
|
|
box-shadow: 0 0 0 0 rgba(226,53,74,0.55); animation: swPulse 1.8s ease-out infinite; flex-shrink: 0; display: inline-block;
|
|
}
|
|
@keyframes swPulse { 0% { box-shadow: 0 0 0 0 rgba(226,53,74,0.55); } 70% { box-shadow: 0 0 0 9px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
|
|
@keyframes swSpin { to { transform: rotate(360deg); } }
|
|
|
|
/* ================= SECTION 01 — AI Logistics Engine ================= */
|
|
/* A robust flex-column stack (no fixed min-height, no absolute-% guessing) so
|
|
the diagram's height always matches its content — no dead space. */
|
|
#sw-root .sw-eng { display: flex; flex-direction: column; align-items: center; }
|
|
|
|
#sw-root .sw-eng-order {
|
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
|
padding: 14px 24px; border-radius: 16px;
|
|
background: rgba(255,255,255,0.045); border: 1px solid rgba(255,255,255,0.14);
|
|
}
|
|
#sw-root .sw-eng-order-ic { width: 34px; height: 34px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(226,53,74,0.14); border: 1px solid rgba(226,53,74,0.35); color: var(--sw-red-bright); }
|
|
#sw-root .sw-eng-order-ic svg { width: 17px; height: 17px; }
|
|
#sw-root .sw-eng-order-label { color: #fff; font-weight: 700; font-size: 13.5px; }
|
|
#sw-root .sw-eng-order-note { color: rgba(255,255,255,0.5); font-size: 11px; }
|
|
|
|
/* Vertical connector: a short filled rail (no forced min-height container to fight). */
|
|
#sw-root .sw-eng-connector { position: relative; display: block; width: 2px; height: clamp(34px, 3.6vw, 52px); background: rgba(255,255,255,0.14); border-radius: 2px; margin: 6px 0; }
|
|
#sw-root .sw-eng-connector-fill { position: absolute; inset: 0; height: calc(var(--fill, 0) * 100%); background: linear-gradient(180deg, var(--sw-red), var(--sw-red-bright)); box-shadow: 0 0 10px rgba(226,53,74,0.7); border-radius: 2px; }
|
|
|
|
/* Engine row: core (processor panel, not a plain circle) + live decision readout. */
|
|
#sw-root .sw-eng-engine-row { display: flex; align-items: center; gap: clamp(24px, 3.2vw, 44px); margin: clamp(18px, 2.2vw, 26px) 0; }
|
|
|
|
#sw-root .sw-eng-core {
|
|
position: relative; flex-shrink: 0;
|
|
width: clamp(150px, 14vw, 188px); height: clamp(150px, 14vw, 188px); border-radius: 50%;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: radial-gradient(120% 120% at 50% 22%, rgba(192,18,39,0.22), rgba(12,12,12,0.97) 72%);
|
|
border: 1px solid rgba(226,53,74,0.35);
|
|
box-shadow: 0 0 40px -14px rgba(192,18,39,0.45), inset 0 0 26px rgba(0,0,0,0.5);
|
|
transition: box-shadow 0.6s ease, border-color 0.6s ease;
|
|
}
|
|
/* A gentle brighten while the AI is "deciding" — a calm cue, not a strobe. */
|
|
#sw-root .sw-eng-engine-row.is-active .sw-eng-core { border-color: rgba(226,53,74,0.6); box-shadow: 0 0 56px -10px rgba(192,18,39,0.6), inset 0 0 26px rgba(0,0,0,0.5); }
|
|
/* A radar-style scan sweep inside the core — always turning slowly at
|
|
rest, spinning up sharply while "is-active" — so the core itself visibly
|
|
computes instead of just glowing brighter. Clipped to the core's own
|
|
rounded shape via overflow:hidden on this wrapper. */
|
|
#sw-root .sw-eng-core-scan { position: absolute; inset: 2px; border-radius: 25px; overflow: hidden; z-index: 1; opacity: 0.55; }
|
|
#sw-root .sw-eng-core-scan::before {
|
|
content: ''; position: absolute; inset: -60%;
|
|
background: conic-gradient(from 0deg, transparent 0deg, rgba(226,53,74,0.65) 28deg, transparent 68deg, transparent 360deg);
|
|
animation: swCoreScan 5s linear infinite;
|
|
}
|
|
#sw-root .sw-eng-engine-row.is-active .sw-eng-core-scan { opacity: 0.95; }
|
|
#sw-root .sw-eng-engine-row.is-active .sw-eng-core-scan::before { animation-duration: 0.9s; }
|
|
@keyframes swCoreScan { to { transform: rotate(360deg); } }
|
|
#sw-root .sw-eng-core-bracket { position: absolute; width: 22px; height: 22px; border-color: rgba(226,53,74,0.55); border-style: solid; border-width: 0; z-index: 2; }
|
|
#sw-root .sw-eng-core-bracket--tl { top: 10px; left: 10px; border-top-width: 2px; border-left-width: 2px; border-top-left-radius: 6px; }
|
|
#sw-root .sw-eng-core-bracket--tr { top: 10px; right: 10px; border-top-width: 2px; border-right-width: 2px; border-top-right-radius: 6px; }
|
|
#sw-root .sw-eng-core-bracket--bl { bottom: 10px; left: 10px; border-bottom-width: 2px; border-left-width: 2px; border-bottom-left-radius: 6px; }
|
|
#sw-root .sw-eng-core-bracket--br { bottom: 10px; right: 10px; border-bottom-width: 2px; border-right-width: 2px; border-bottom-right-radius: 6px; }
|
|
|
|
#sw-root .sw-eng-orbit { position: absolute; inset: 0; animation: swSpin 12s linear infinite; }
|
|
#sw-root .sw-eng-engine-row.is-active .sw-eng-orbit { animation-duration: 2.6s; }
|
|
#sw-root .sw-eng-orbit-dot {
|
|
position: absolute; top: 50%; left: 50%; width: 5px; height: 5px; margin: -2.5px;
|
|
border-radius: 50%; background: var(--sw-red-bright); box-shadow: 0 0 8px rgba(226,53,74,0.8); opacity: 0.7;
|
|
transform: rotate(calc(var(--i) * 45deg)) translateX(clamp(84px, 8vw, 104px));
|
|
}
|
|
|
|
#sw-root .sw-eng-core-inner { position: relative; z-index: 2; display: flex; flex-direction: column; align-items: center; gap: 9px; }
|
|
#sw-root .sw-eng-core-logo { width: clamp(52px, 4.6vw, 66px); height: auto; filter: drop-shadow(0 0 10px rgba(226,53,74,0.5)); }
|
|
#sw-root .sw-eng-core-wave { display: flex; align-items: flex-end; gap: 3px; height: 16px; }
|
|
#sw-root .sw-eng-core-wave span { width: 3px; border-radius: 2px; background: var(--sw-red-bright); animation: swWave 1.3s ease-in-out infinite; }
|
|
#sw-root .sw-eng-core-wave span:nth-child(1) { height: 40%; animation-delay: 0s; }
|
|
#sw-root .sw-eng-core-wave span:nth-child(2) { height: 80%; animation-delay: 0.12s; }
|
|
#sw-root .sw-eng-core-wave span:nth-child(3) { height: 100%; animation-delay: 0.24s; }
|
|
#sw-root .sw-eng-core-wave span:nth-child(4) { height: 65%; animation-delay: 0.36s; }
|
|
#sw-root .sw-eng-core-wave span:nth-child(5) { height: 45%; animation-delay: 0.48s; }
|
|
#sw-root .sw-eng-engine-row.is-active .sw-eng-core-wave span { animation-duration: 0.5s; }
|
|
@keyframes swWave { 0%, 100% { transform: scaleY(0.4); opacity: 0.6; } 50% { transform: scaleY(1); opacity: 1; } }
|
|
#sw-root .sw-eng-core-tag { color: rgba(255,255,255,0.5); font-size: 9.5px; font-weight: 800; letter-spacing: 0.14em; }
|
|
|
|
#sw-root .sw-eng-readout { display: flex; flex-direction: column; gap: 11px; padding: 18px 22px; border-radius: 16px; background: rgba(255,255,255,0.035); border: 1px solid rgba(255,255,255,0.1); min-width: clamp(200px, 20vw, 240px); }
|
|
#sw-root .sw-eng-readout-title { color: rgba(255,255,255,0.45); font-size: 10.5px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 2px; }
|
|
#sw-root .sw-eng-readout-row { display: flex; align-items: center; gap: 11px; opacity: 0.4; transition: opacity 0.3s ease; }
|
|
#sw-root .sw-eng-readout-indicator { position: relative; width: 18px; height: 18px; flex-shrink: 0; }
|
|
#sw-root .sw-eng-readout-dot { position: absolute; top: 5px; left: 5px; width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,0.3); }
|
|
#sw-root .sw-eng-readout-dot::after { content: ''; position: absolute; inset: -5px; border-radius: 50%; border: 1px solid rgba(226,53,74,0.5); animation: swReadoutPulse 1.3s ease-out infinite; }
|
|
@keyframes swReadoutPulse { 0% { transform: scale(0.6); opacity: 0.8; } 100% { transform: scale(1.7); opacity: 0; } }
|
|
#sw-root .sw-eng-readout-check { position: absolute; inset: 0; border-radius: 50%; background: var(--sw-red); color: #fff; display: flex; align-items: center; justify-content: center; opacity: 0; }
|
|
#sw-root .sw-eng-readout-check svg { width: 10px; height: 10px; stroke-width: 3; }
|
|
#sw-root .sw-eng-readout-label { color: #fff; font-size: 13px; font-weight: 600; }
|
|
|
|
/* Fan-out: a small, self-sized SVG (not a full-height overlay) linking the
|
|
engine to its three simultaneous outputs. */
|
|
#sw-root .sw-eng-fan { display: block; width: min(360px, 78%); height: clamp(44px, 4.6vw, 60px); }
|
|
#sw-root .sw-eng-fan path { fill: none; stroke: rgba(226,53,74,0.45); stroke-width: 2.2; vector-effect: non-scaling-stroke; }
|
|
|
|
#sw-root .sw-eng-outputs { display: flex; gap: clamp(14px, 2vw, 26px); flex-wrap: wrap; justify-content: center; }
|
|
#sw-root .sw-eng-out {
|
|
display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
|
|
width: 148px; padding: 16px 14px; border-radius: 16px;
|
|
background: rgba(255,255,255,0.035); border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
#sw-root .sw-eng-out-ic { width: 42px; height: 42px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: rgba(226,53,74,0.12); border: 1px solid rgba(226,53,74,0.3); color: var(--sw-red-bright); }
|
|
#sw-root .sw-eng-out-ic svg { width: 20px; height: 20px; }
|
|
#sw-root .sw-eng-out-label { color: #fff; font-weight: 700; font-size: 13px; }
|
|
#sw-root .sw-eng-out-note { color: rgba(255,255,255,0.55); font-size: 11px; line-height: 1.35; }
|
|
|
|
#sw-root .sw-eng-tracking { display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; padding: 16px 26px; border-radius: 16px; background: rgba(255,255,255,0.035); border: 1px solid rgba(255,255,255,0.1); }
|
|
|
|
#sw-root .sw-eng-delivered {
|
|
display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px; border-radius: 999px;
|
|
background: rgba(10,10,10,0.85); border: 1px solid rgba(226,53,74,0.5); color: #fff; font-weight: 700; font-size: 13px;
|
|
}
|
|
#sw-root .sw-eng-delivered-ic { color: var(--sw-red-bright); display: flex; }
|
|
#sw-root .sw-eng-delivered-ic svg { width: 16px; height: 16px; }
|
|
#sw-root .sw-eng-delivered em { font-style: normal; color: rgba(255,255,255,0.55); font-weight: 500; }
|
|
|
|
@media (max-width: 767px) {
|
|
#sw-root .sw-eng-engine-row { flex-direction: column; gap: 20px; }
|
|
#sw-root .sw-eng-fan { display: none; }
|
|
}
|
|
|
|
/* ================= SECTION 02 — Pickup (van-driven) ================= */
|
|
/* Flush with the shared .sw-edge (width: 100%) — the same outer frame
|
|
every other full-width section's primary panel uses (01, 04, 05, 07,
|
|
09, 10). This used to carry its own margin + compensating width
|
|
reduction to inset the whole layered scene, which broke alignment with
|
|
every other section's outer edge. Section 05's canvas gets breathing
|
|
room at its own inner layer (.wf-scene), not on its outer frame
|
|
(.qs-frame) — that's a per-scene styling choice, not part of the
|
|
shared container, so it isn't reproduced here. */
|
|
#sw-root .sw-pickup { position: relative; min-height: clamp(460px, 44vw, 600px); overflow: hidden; border-radius: 16px; }
|
|
/* The source photo is square and the section is always wider than tall, so
|
|
object-fit: cover never crops horizontally here — only object-position's Y
|
|
matters. Biased down so the frame keeps the storefront (origin), the
|
|
parked van's open door (pickup point) AND the open pavement the animated
|
|
van actually drives across, instead of a crop that shows only the cargo
|
|
shelves. */
|
|
#sw-root .sw-pickup-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 80%; transform-origin: 45% 78%; will-change: transform; }
|
|
#sw-root .sw-pickup-overlay { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,0.32) 0%, rgba(0,0,0,0.42) 40%, rgba(0,0,0,0.9) 100%); }
|
|
|
|
#sw-root .sw-pickup-ai-badge {
|
|
position: absolute; top: 20px; left: 20px; z-index: 4; display: inline-flex; align-items: center; gap: 9px;
|
|
padding: 8px 14px; border-radius: 999px; background: rgba(10,10,10,0.72); border: 1px solid rgba(255,255,255,0.16);
|
|
color: #fff; font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; backdrop-filter: blur(8px);
|
|
}
|
|
#sw-root .sw-pickup-ai-indicator { position: relative; width: 8px; height: 8px; }
|
|
#sw-root .sw-pickup-ai-check { position: absolute; top: -5px; left: -5px; width: 18px; height: 18px; border-radius: 50%; background: var(--sw-red); color: #fff; display: flex; align-items: center; justify-content: center; opacity: 0; }
|
|
#sw-root .sw-pickup-ai-check svg { width: 10px; height: 10px; stroke-width: 3; }
|
|
#sw-root .sw-pickup-confirm { position: absolute; top: 18px; right: 20px; z-index: 4; display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 999px; background: rgba(10,10,10,0.88); border: 1px solid rgba(226,53,74,0.5); color: #fff; font-weight: 700; font-size: 13px; white-space: nowrap; }
|
|
#sw-root .sw-pickup-confirm svg { width: 16px; height: 16px; color: var(--sw-red-bright); flex-shrink: 0; }
|
|
|
|
/* A real road, not an abstract line: a dark asphalt band (with contact
|
|
shadow) carries a static white dashed lane marking, and the bright red
|
|
GPS-style route draws on top of it as the van advances — same shape
|
|
the reviewer asked for (subtle road texture + nav polyline). All strokes
|
|
use non-scaling-stroke so their screen-pixel width stays constant despite
|
|
the viewBox being stretched non-uniformly (preserveAspectRatio="none") to
|
|
the section's wide, short aspect ratio. */
|
|
#sw-root .sw-pickup-road { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; overflow: visible; }
|
|
#sw-root .sw-pickup-road-shadow { fill: none; stroke: rgba(0,0,0,0.5); stroke-width: 15; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; filter: blur(2px); }
|
|
#sw-root .sw-pickup-road-path { fill: none; stroke: rgba(12,12,14,0.62); stroke-width: 11; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
|
#sw-root .sw-pickup-road-lane { fill: none; stroke: rgba(255,255,255,0.5); stroke-width: 1.6; stroke-linecap: round; stroke-dasharray: 5 8; vector-effect: non-scaling-stroke; }
|
|
/* Nav-style route: the full path stays neutral grey (untravelled), and the
|
|
red progress line is drawn exactly on top of it up to the van's current
|
|
position only — so the road ahead of the van is always visibly "not
|
|
done yet" instead of the whole route reading as complete at once. */
|
|
#sw-root .sw-pickup-road-remaining { fill: none; stroke: rgba(255,255,255,0.4); stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
|
/* No vector-effect here, unlike the other road layers: non-scaling-stroke
|
|
makes Chromium interpret stroke-dasharray/-dashoffset in screen-space
|
|
rather than the userspace units getTotalLength() returns, so the "fully
|
|
drawn" dash only ever covered a fraction of the path (invisible against
|
|
the dark asphalt before the grey remaining-route layer existed to expose
|
|
it). Plain scaling stroke keeps dasharray math and rendering in the same
|
|
coordinate space, so the small width below is in viewBox units, not px. */
|
|
#sw-root .sw-pickup-road-progress { fill: none; stroke: var(--sw-red-bright); stroke-width: 0.7; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 0 3px rgba(226,53,74,0.9)); }
|
|
|
|
#sw-root .sw-pickup-dest { position: absolute; z-index: 2; transform: translate(-50%, -100%); display: flex; flex-direction: column; align-items: center; gap: 2px; color: #fff; text-shadow: 0 2px 6px rgba(0,0,0,0.8); }
|
|
#sw-root .sw-pickup-dest svg { width: 26px; height: 26px; color: var(--sw-red-bright); filter: drop-shadow(0 0 8px rgba(226,53,74,0.7)); }
|
|
#sw-root .sw-pickup-dest span { font-size: 10.5px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; background: rgba(10,10,10,0.7); padding: 2px 8px; border-radius: 999px; }
|
|
|
|
/* The anchor point (left/top, set per-frame in JS) must land where the
|
|
wheels TOUCH the road — their bottom tangent, not the wheel's own center
|
|
— or the road line cuts through the middle of the wheels instead of
|
|
sitting under them. Wheel hubs are at y=50 with r=12 in a 66-tall
|
|
viewBox, so the contact line is y=62, i.e. 93.9% down — the transform
|
|
below (relative to the element's own box) does that lift and the
|
|
horizontal centering, instead of a negative-px margin, since the box is
|
|
now sized in %, not px. Sized to ~7% of the section width — proportional
|
|
to the real van visible in the background photo, not an oversized focal
|
|
prop. */
|
|
#sw-root .sw-pickup-van { position: absolute; top: 0; left: 0; width: 7%; aspect-ratio: 130 / 66; transform: translate(-50%, -93.9%); z-index: 6; }
|
|
/* A dedicated contact shadow, separate from the van body's rotate/bounce
|
|
chain, so it stays flat on the road while the body leans and bobs above
|
|
it — JS modulates opacity/scaleX with speed for a "following" feel.
|
|
Positioned right at the wheel-contact line (93.9%, see above). */
|
|
#sw-root .sw-pickup-van-shadow {
|
|
position: absolute; left: 4%; right: 4%; top: 90%; height: 12%;
|
|
background: radial-gradient(ellipse, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.28) 55%, transparent 78%);
|
|
filter: blur(2.5px); opacity: 0.85; transform-origin: center; z-index: 0;
|
|
}
|
|
/* A trailing streak, not a blur on the sprite itself — sense of speed
|
|
without ever softening the van's own silhouette. Anchored to the
|
|
trailing (left) edge since the route runs left-to-right and the sprite
|
|
noses right to match (see the flip logic in Sec02); fades in only while
|
|
actually moving. */
|
|
#sw-root .sw-pickup-van-speedlines { position: absolute; top: 34%; left: -10%; width: 26%; height: 30%; opacity: 0; transition: opacity 0.25s ease; pointer-events: none; z-index: 0; }
|
|
#sw-root .sw-pickup-van.is-moving .sw-pickup-van-speedlines { opacity: 1; }
|
|
#sw-root .sw-pickup-van-speedlines i { position: absolute; right: 0; height: 2.5px; border-radius: 2px; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.65)); font-style: normal; }
|
|
#sw-root .sw-pickup-van-speedlines i:nth-child(1) { top: 0; width: 100%; }
|
|
#sw-root .sw-pickup-van-speedlines i:nth-child(2) { top: 45%; width: 72%; opacity: 0.75; }
|
|
#sw-root .sw-pickup-van-speedlines i:nth-child(3) { top: 88%; width: 48%; opacity: 0.55; }
|
|
/* Suspension bounce: a small continuous vertical oscillation, only while
|
|
the van is actually moving (toggled in JS from real frame-to-frame
|
|
speed) — stops cleanly at rest instead of looping forever. */
|
|
#sw-root .sw-pickup-van-bounce { position: relative; z-index: 1; width: 100%; height: 100%; }
|
|
#sw-root .sw-pickup-van-bounce.is-moving { animation: swVanBounce 0.38s ease-in-out infinite; }
|
|
@keyframes swVanBounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-1.4px); } }
|
|
/* Idle "alive" state — plain CSS, not JS/scroll-driven, so it plays the
|
|
instant the van's markup paints (even before hydration) and during every
|
|
checkpoint dwell, not just the very first wait at Pickup Request: a
|
|
slow breathing bob plus a headlight blink give the van presence before
|
|
it starts moving, instead of it looking like a static, inert sticker
|
|
parked on the road. */
|
|
#sw-root .sw-pickup-van:not(.is-moving) .sw-pickup-van-bounce { animation: swVanIdleBob 3.2s ease-in-out infinite; }
|
|
@keyframes swVanIdleBob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-0.6px); } }
|
|
#sw-root .sw-pickup-van:not(.is-moving) .sw-van-headlight { animation: swVanHeadlightBlink 2.6s ease-in-out infinite; }
|
|
@keyframes swVanHeadlightBlink { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
|
|
/* Heading changes (set imperatively in JS) ease through this transition
|
|
instead of snapping — the overshoot in the curve reads as the van
|
|
banking into the turn before it settles straight. */
|
|
#sw-root .sw-pickup-van-rot { width: 100%; height: 100%; transition: transform 0.45s cubic-bezier(0.32, 1.45, 0.6, 1); }
|
|
/* Rim-light + contact shadow via filter (not per-shape outlines) so the
|
|
whole silhouette gets a consistent bright edge that separates it from
|
|
both the dark asphalt and the busy photo behind it. */
|
|
#sw-root .sw-van-svg { width: 100%; height: 100%; overflow: visible; filter: drop-shadow(0 0 1.5px rgba(255,255,255,0.95)) drop-shadow(0 0 4px rgba(255,255,255,0.35)) drop-shadow(0 4px 8px rgba(0,0,0,0.75)); }
|
|
/* A brief full-frame flash at "Package Collected" — punctuates the finish
|
|
without adding new persistent UI. */
|
|
#sw-root .sw-pickup-success-glow { position: absolute; inset: 0; z-index: 5; pointer-events: none; opacity: 0; background: radial-gradient(60% 60% at 18% 84%, rgba(226,53,74,0.4) 0%, transparent 70%); }
|
|
|
|
/* The card's own top:0 is pinned 24px above its waypoint (half the 48px icon
|
|
height) so the checkpoint icon's CENTER lands exactly on the path — not the
|
|
card's bottom edge, which would leave the icon floating well above it.
|
|
z-index sits above .sw-pickup-van (6): the van's body reaches upward from
|
|
its wheel-contact point, the same point each checkpoint icon is centered
|
|
on, so the two necessarily share screen space right as the van arrives —
|
|
the checkpoint card stays on top through that moment instead of getting
|
|
covered. */
|
|
#sw-root .sw-pickup-stop {
|
|
position: absolute; transform: translate(-50%, -24px); z-index: 7; width: 148px; padding-top: 58px;
|
|
display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
|
|
opacity: 0.34; transition: opacity 0.45s ease;
|
|
}
|
|
#sw-root .sw-pickup-stop.is-active { opacity: 1; }
|
|
#sw-root .sw-pickup-stop-icwrap { position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 48px; height: 48px; }
|
|
#sw-root .sw-pickup-stop-ic {
|
|
position: relative; z-index: 1; width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center;
|
|
background: rgba(255,255,255,0.09); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.18);
|
|
transition: border-color 0.4s ease, box-shadow 0.4s ease;
|
|
}
|
|
#sw-root .sw-pickup-stop-ic svg { width: 24px; height: 24px; }
|
|
#sw-root .sw-pickup-stop.is-active .sw-pickup-stop-ic { border-color: rgba(226,53,74,0.6); box-shadow: 0 0 24px -6px rgba(192,18,39,0.85); animation: swCheckpointPop 0.5s ease-out; }
|
|
#sw-root .sw-pickup-stop-ripple { position: absolute; top: 0; left: 0; width: 48px; height: 48px; border-radius: 14px; border: 2px solid rgba(226,53,74,0.6); opacity: 0; pointer-events: none; }
|
|
#sw-root .sw-pickup-stop.is-active .sw-pickup-stop-ripple { animation: swCheckpointRipple 0.7s ease-out; }
|
|
#sw-root .sw-pickup-stop-badge {
|
|
position: absolute; top: -5px; right: -5px; z-index: 2; width: 18px; height: 18px; border-radius: 50%;
|
|
background: var(--sw-red); color: #fff; display: flex; align-items: center; justify-content: center;
|
|
opacity: 0; transform: scale(0.4); transition: opacity 0.25s ease, transform 0.25s ease;
|
|
}
|
|
#sw-root .sw-pickup-stop-badge svg { width: 10px; height: 10px; stroke-width: 3; }
|
|
#sw-root .sw-pickup-stop.is-active .sw-pickup-stop-badge { opacity: 1; transform: scale(1); }
|
|
@keyframes swCheckpointPop { 0% { transform: scale(1); } 45% { transform: scale(1.26); } 100% { transform: scale(1); } }
|
|
@keyframes swCheckpointRipple { 0% { transform: scale(0.7); opacity: 0.9; } 100% { transform: scale(1.7); opacity: 0; } }
|
|
#sw-root .sw-pickup-stop-label { color: #fff; font-weight: 700; font-size: 13px; }
|
|
#sw-root .sw-pickup-stop-note { color: rgba(255,255,255,0.55); font-size: 11.5px; line-height: 1.4; }
|
|
|
|
@media (max-width: 700px) {
|
|
#sw-root .sw-pickup-confirm, #sw-root .sw-pickup-road, #sw-root .sw-pickup-van, #sw-root .sw-pickup-dest { display: none; }
|
|
#sw-root .sw-pickup { min-height: 0; display: flex; flex-wrap: wrap; justify-content: center; align-content: flex-start; gap: 16px; padding: 56px 16px 24px; width: 100%; margin: 0; }
|
|
#sw-root .sw-pickup-stop { position: relative; top: auto !important; left: auto !important; transform: none !important; padding-top: 0; width: 44%; opacity: 1 !important; }
|
|
#sw-root .sw-pickup-stop-icwrap { position: relative; margin: 0 auto; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
#sw-root .sw-pickup-stop.is-active .sw-pickup-stop-ic, #sw-root .sw-pickup-stop.is-active .sw-pickup-stop-ripple { animation: none; }
|
|
}
|
|
|
|
/* ================= SECTION 03 — Vehicle AI (split) ================= */
|
|
#sw-root #vehicle-ai { padding-top: clamp(24px, 3vw, 40px) !important; padding-bottom: clamp(24px, 3vw, 40px) !important; }
|
|
#sw-root #vehicle-ai .sw-split { align-items: stretch; gap: clamp(24px, 3.5vw, 48px); }
|
|
#sw-root .sw-veh-stage { position: relative; border-radius: 20px; overflow: hidden; border: 1px solid var(--sw-line); background: #0b0f17; padding: clamp(20px, 2.5vw, 32px) clamp(14px, 2vw, 24px); display: flex; flex-direction: column; justify-content: center; min-height: 100%; }
|
|
#sw-root .sw-veh-bg { position: absolute; inset: 0; z-index: 0; opacity: 0.14; pointer-events: none; }
|
|
#sw-root .sw-veh-bg img { width: 100%; height: 100%; object-fit: cover; filter: brightness(0.85) contrast(1.1); }
|
|
#sw-root .sw-veh-scan { position: absolute; top: 0; bottom: 0; width: 60px; z-index: 2; background: linear-gradient(90deg, transparent, rgba(226,53,74,0.35), transparent); pointer-events: none; }
|
|
/* Decision timeline — Analysing -> Comparing -> Filtering -> Selecting ->
|
|
Dispatch Ready — advanced imperatively (see Sec03's evaluate()) so the
|
|
overall stage of the AI's reasoning is always legible above the
|
|
moment-to-moment status line and card choreography below it. */
|
|
#sw-root .sw-veh-timeline { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
|
|
#sw-root .sw-veh-tl-step { display: flex; align-items: center; gap: 6px; opacity: 0.38; transition: opacity 0.3s ease; }
|
|
#sw-root .sw-veh-tl-step:not(:last-child)::after { content: ''; width: 14px; height: 1px; background: rgba(255,255,255,0.18); margin-left: 6px; }
|
|
#sw-root .sw-veh-tl-dot { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,0.3); transition: background-color 0.3s ease, box-shadow 0.3s ease; }
|
|
#sw-root .sw-veh-tl-label { font-size: 10px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: rgba(255,255,255,0.55); }
|
|
#sw-root .sw-veh-tl-step.is-done { opacity: 0.75; }
|
|
#sw-root .sw-veh-tl-step.is-done .sw-veh-tl-dot { background: rgba(226,53,74,0.65); }
|
|
#sw-root .sw-veh-tl-step.is-active { opacity: 1; }
|
|
#sw-root .sw-veh-tl-step.is-active .sw-veh-tl-dot { background: var(--sw-red-bright); box-shadow: 0 0 8px rgba(226,53,74,0.85); }
|
|
#sw-root .sw-veh-tl-step.is-active .sw-veh-tl-label { color: #fff; }
|
|
#sw-root .sw-veh-status { position: relative; z-index: 1; display: block; text-align: center; color: var(--sw-red-bright); font-weight: 700; font-size: 13.5px; letter-spacing: 0.02em; margin-bottom: clamp(22px, 2.6vw, 32px); min-height: 18px; }
|
|
#sw-root .sw-veh-row { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; }
|
|
#sw-root .sw-veh-card {
|
|
position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 16px 18px; border-radius: 18px;
|
|
background: rgba(255,255,255,0.035); border: 1px solid rgba(255,255,255,0.09); min-width: 106px; cursor: default;
|
|
transition: border-color 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease, filter 0.3s ease;
|
|
}
|
|
/* Rejection: a dark scrim + red X + reason ("Too Small", "Capacity"...)
|
|
pop in over the card and the whole card desaturates — each non-winner
|
|
gets this individually, staggered in evaluate(), so every rejection
|
|
reads as its own deliberate beat instead of five cards dimming at once. */
|
|
#sw-root .sw-veh-card-reject {
|
|
position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
gap: 5px; border-radius: 18px; background: rgba(8,8,9,0.62); opacity: 0; pointer-events: none;
|
|
}
|
|
#sw-root .sw-veh-card.is-rejected { filter: grayscale(0.9); }
|
|
#sw-root .sw-veh-card.is-rejected .sw-veh-card-reject { opacity: 1; animation: swVehRejectPop 0.35s ease-out; }
|
|
#sw-root .sw-veh-card-x { width: 20px; height: 20px; border-radius: 50%; background: rgba(226,53,74,0.2); border: 1px solid rgba(226,53,74,0.55); color: #ff5a6e; display: flex; align-items: center; justify-content: center; }
|
|
#sw-root .sw-veh-card-x svg { width: 10px; height: 10px; stroke-width: 3.4; }
|
|
#sw-root .sw-veh-card-reason { font-size: 9.5px; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; color: #ff8a96; text-align: center; }
|
|
@keyframes swVehRejectPop { 0% { opacity: 0; transform: scale(0.72); } 65% { opacity: 1; transform: scale(1.08); } 100% { opacity: 1; transform: scale(1); } }
|
|
/* Winner: glow -> expand -> settle pulse (handled as GSAP scale tweens on
|
|
the card itself) -> lock, with this border/background/ring as the
|
|
persistent "selected" state once the pulse settles. */
|
|
#sw-root .sw-veh-card.is-winner {
|
|
border-color: rgba(226,53,74,0.55); background: rgba(192,18,39,0.12);
|
|
box-shadow: 0 0 0 1px rgba(226,53,74,0.25), 0 18px 40px -18px rgba(192,18,39,0.6);
|
|
animation: swVehWinnerRing 1.4s ease-out 1;
|
|
}
|
|
@keyframes swVehWinnerRing { 0% { box-shadow: 0 0 0 0 rgba(226,53,74,0.5), 0 18px 40px -18px rgba(192,18,39,0.6); } 100% { box-shadow: 0 0 0 16px rgba(226,53,74,0), 0 18px 40px -18px rgba(192,18,39,0.6); } }
|
|
#sw-root .sw-veh-art { width: 74px; height: 44px; display: block; }
|
|
#sw-root .sw-veh-label { font-size: 13px; font-weight: 700; color: #fff; }
|
|
#sw-root .sw-veh-check {
|
|
display: inline-flex; align-items: center; gap: 4px; font-size: 10px; font-weight: 800; letter-spacing: 0.08em;
|
|
text-transform: uppercase; color: var(--sw-red-bright); opacity: 0; transform: scale(0.6); transition: opacity 0.3s ease, transform 0.3s ease;
|
|
}
|
|
#sw-root .sw-veh-card.is-winner .sw-veh-check { opacity: 1; transform: scale(1); }
|
|
#sw-root .sw-veh-lock { display: flex; }
|
|
#sw-root .sw-veh-lock svg { width: 11px; height: 11px; stroke-width: 2.2; }
|
|
#sw-root .sw-veh-profiles-label { display: block; font-size: 11.5px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: rgba(255,255,255,0.45); margin-bottom: 14px; }
|
|
#sw-root .sw-veh-profiles-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: clamp(22px, 2.6vw, 30px); }
|
|
#sw-root .sw-veh-chip--profile { flex-direction: column; align-items: flex-start; gap: 2px; cursor: pointer; border: none; transition: border-color 0.25s ease, background-color 0.25s ease; }
|
|
#sw-root .sw-veh-chip--profile em { font-style: normal; font-weight: 500; color: rgba(255,255,255,0.45); font-size: 11px; }
|
|
#sw-root .sw-veh-chip--profile.is-active { border: 1px solid rgba(226,53,74,0.5); background: rgba(192,18,39,0.14); color: #fff; }
|
|
#sw-root .sw-veh-factors { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
/* Parameter chips (Weight, Dimensions, Distance...) light up one at a
|
|
time as the AI "checks" each one — glow while checking, a small
|
|
checkmark badge stays once checked — synced to evaluate()'s timeline
|
|
so the left-column chips and the right-column stage tell one story. */
|
|
#sw-root .sw-veh-chip--factor { position: relative; padding-right: 30px; }
|
|
#sw-root .sw-veh-chip--factor.is-checking { border-color: rgba(226,53,74,0.5); background: rgba(192,18,39,0.16); }
|
|
#sw-root .sw-veh-chip--factor.is-checked { border-color: rgba(226,53,74,0.32); }
|
|
#sw-root .sw-veh-chip-check {
|
|
position: absolute; right: 8px; top: 50%; width: 16px; height: 16px; border-radius: 50%; background: var(--sw-red); color: #fff;
|
|
display: flex; align-items: center; justify-content: center; opacity: 0; transform: translateY(-50%) scale(0.4);
|
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
|
}
|
|
#sw-root .sw-veh-chip-check svg { width: 9px; height: 9px; stroke-width: 3.4; }
|
|
#sw-root .sw-veh-chip--factor.is-checked .sw-veh-chip-check { opacity: 1; transform: translateY(-50%) scale(1); }
|
|
@media (max-width: 991px) { #sw-root .sw-veh-row { overflow-x: auto; justify-content: flex-start; padding-bottom: 6px; } }
|
|
|
|
|
|
/* ================= SECTION 04 — AI Decision Engine ================= */
|
|
/* A complete replacement for the old map: an enterprise AI decision
|
|
dashboard. Dark glass cards on the section frame, live count-up metrics,
|
|
score bars, a winner-highlight compare beat, a confirmation banner and a
|
|
small dispatch step — all self-looping. */
|
|
#sw-root .sw-ade {
|
|
position: relative; border-radius: 24px; padding: clamp(18px, 2.6vw, 30px); overflow: hidden;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.015));
|
|
border: 1px solid rgba(255,255,255,0.09);
|
|
box-shadow: 0 30px 70px -40px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.06);
|
|
}
|
|
/* A faint red glow anchored to the top edge so the panel reads as a lit
|
|
control surface, not a flat box. */
|
|
#sw-root .sw-ade::before {
|
|
content: ''; position: absolute; inset: 0; pointer-events: none;
|
|
background: radial-gradient(120% 80% at 50% -12%, rgba(226,53,74,0.12), transparent 58%);
|
|
}
|
|
#sw-root .sw-ade > * { position: relative; z-index: 1; }
|
|
|
|
#sw-root .sw-ade-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
|
#sw-root .sw-ade-brand { display: inline-flex; align-items: center; gap: 9px; font-weight: 800; font-size: clamp(13px, 1.4vw, 15px); letter-spacing: 0.02em; color: #fff; }
|
|
#sw-root .sw-ade-brand-ic { width: 26px; height: 26px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; color: var(--sw-red-bright); background: rgba(226,53,74,0.14); border: 1px solid rgba(226,53,74,0.32); }
|
|
#sw-root .sw-ade-brand-ic svg { width: 15px; height: 15px; }
|
|
#sw-root .sw-ade-live { display: inline-flex; align-items: center; gap: 6px; font-size: 10px; font-weight: 800; letter-spacing: 0.14em; color: rgba(255,255,255,0.6); }
|
|
#sw-root .sw-ade-livedot { width: 7px; height: 7px; border-radius: 50%; background: var(--sw-red-bright); box-shadow: 0 0 0 0 rgba(226,53,74,0.5); animation: swPulse 1.8s ease-out infinite; flex-shrink: 0; }
|
|
#sw-root .sw-ade-livedot--green { background: #22c55e; box-shadow: 0 0 0 0 rgba(34,197,94,0.5); animation: swLiveGreen 1.8s ease-out infinite; }
|
|
@keyframes swLiveGreen { 0% { box-shadow: 0 0 0 0 rgba(34,197,94,0.5); } 70% { box-shadow: 0 0 0 8px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
|
|
|
|
#sw-root .sw-ade-status-row { min-height: 18px; margin-bottom: 16px; }
|
|
#sw-root .sw-ade-status { font-size: 12px; font-weight: 700; letter-spacing: 0.02em; color: var(--sw-red-bright); }
|
|
|
|
#sw-root .sw-ade-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(10px, 1.4vw, 16px); }
|
|
/* Each candidate is a dark glass card. Smooth transitions on every
|
|
property the compare beat touches (opacity/filter/border/shadow/
|
|
transform) so muting a loser and lifting the winner both glide. */
|
|
#sw-root .sw-ade-card {
|
|
display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: 16px;
|
|
background: rgba(255,255,255,0.045); backdrop-filter: blur(14px) saturate(1.2); -webkit-backdrop-filter: blur(14px) saturate(1.2);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
box-shadow: 0 14px 30px -18px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.06);
|
|
transition: opacity 0.5s ease, filter 0.5s ease, border-color 0.5s ease, box-shadow 0.5s ease, transform 0.5s ease, background 0.5s ease;
|
|
}
|
|
#sw-root .sw-ade-card.is-scoring { border-color: rgba(226,53,74,0.32); box-shadow: 0 16px 34px -16px rgba(226,53,74,0.4), inset 0 1px 0 rgba(255,255,255,0.08); }
|
|
#sw-root .sw-ade-card.is-muted { opacity: 0.4; filter: saturate(0.55); }
|
|
#sw-root .sw-ade-card.is-winner {
|
|
border-color: rgba(226,53,74,0.7); transform: translateY(-4px);
|
|
background: linear-gradient(180deg, rgba(226,53,74,0.15), rgba(226,53,74,0.035));
|
|
box-shadow: 0 24px 46px -18px rgba(226,53,74,0.55), 0 0 0 1px rgba(226,53,74,0.3), inset 0 1px 0 rgba(255,255,255,0.1);
|
|
}
|
|
|
|
#sw-root .sw-ade-card-top { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
|
#sw-root .sw-ade-card-name { font-weight: 800; font-size: 14px; color: #fff; }
|
|
#sw-root .sw-ade-traffic { font-size: 9px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; padding: 3px 8px; border-radius: 999px; white-space: nowrap; }
|
|
#sw-root .sw-ade-traffic--light { color: #4ade80; background: rgba(34,197,94,0.14); border: 1px solid rgba(34,197,94,0.3); }
|
|
#sw-root .sw-ade-traffic--moderate { color: #fbbf24; background: rgba(251,191,36,0.14); border: 1px solid rgba(251,191,36,0.3); }
|
|
#sw-root .sw-ade-traffic--heavy { color: #f87171; background: rgba(248,113,113,0.14); border: 1px solid rgba(248,113,113,0.3); }
|
|
|
|
#sw-root .sw-ade-rows { display: flex; flex-direction: column; gap: 7px; }
|
|
#sw-root .sw-ade-row { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
|
#sw-root .sw-ade-row-l { font-size: 10px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(255,255,255,0.42); }
|
|
#sw-root .sw-ade-row-v { font-size: 11px; font-weight: 700; color: rgba(255,255,255,0.7); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
#sw-root .sw-ade-row-v b { font-size: 15px; font-weight: 800; color: #fff; }
|
|
#sw-root .sw-ade-fuel-bar { height: 4px; border-radius: 999px; background: rgba(255,255,255,0.1); overflow: hidden; margin-top: 3px; }
|
|
#sw-root .sw-ade-fuel-bar > i { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, var(--sw-red), var(--sw-red-bright)); }
|
|
|
|
#sw-root .sw-ade-score { display: flex; flex-direction: column; gap: 6px; padding-top: 11px; border-top: 1px solid rgba(255,255,255,0.09); margin-top: auto; }
|
|
#sw-root .sw-ade-score-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 10px; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(255,255,255,0.5); }
|
|
#sw-root .sw-ade-score-val { font-size: 20px; font-weight: 800; color: #fff; letter-spacing: 0; text-transform: none; font-variant-numeric: tabular-nums; transition: color 0.5s ease; }
|
|
#sw-root .sw-ade-card.is-winner .sw-ade-score-val { color: var(--sw-red-bright); }
|
|
#sw-root .sw-ade-score-bar { height: 6px; border-radius: 999px; background: rgba(255,255,255,0.1); overflow: hidden; }
|
|
#sw-root .sw-ade-score-bar > i { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, rgba(255,255,255,0.4), rgba(255,255,255,0.72)); transition: background 0.5s ease; }
|
|
#sw-root .sw-ade-card.is-winner .sw-ade-score-bar > i { background: linear-gradient(90deg, var(--sw-red), var(--sw-red-bright)); box-shadow: 0 0 12px rgba(226,53,74,0.6); }
|
|
|
|
#sw-root .sw-ade-verdict { display: inline-flex; align-items: center; gap: 6px; font-size: 10.5px; font-weight: 800; color: rgba(255,255,255,0.5); transition: color 0.5s ease; }
|
|
#sw-root .sw-ade-verdict-ic { width: 15px; height: 15px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6); flex-shrink: 0; }
|
|
#sw-root .sw-ade-verdict-ic svg { width: 8px; height: 8px; stroke-width: 3.4; }
|
|
#sw-root .sw-ade-verdict-ic--win { background: rgba(226,53,74,0.18); color: var(--sw-red-bright); }
|
|
#sw-root .sw-ade-card.is-winner .sw-ade-verdict { color: var(--sw-red-bright); }
|
|
|
|
/* The AI-analysis connector between the candidate grid and the result. */
|
|
#sw-root .sw-ade-flow { display: flex; align-items: center; justify-content: center; gap: 14px; margin: 20px 0 18px; }
|
|
#sw-root .sw-ade-flow-arrow { flex: 1; max-width: 200px; height: 1px; background: linear-gradient(90deg, transparent, rgba(226,53,74,0.5), transparent); }
|
|
#sw-root .sw-ade-flow-chip { display: inline-flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 800; letter-spacing: 0.04em; color: #fff; padding: 7px 14px; border-radius: 999px; background: rgba(226,53,74,0.12); border: 1px solid rgba(226,53,74,0.3); white-space: nowrap; }
|
|
#sw-root .sw-ade-flow-chip svg { width: 14px; height: 14px; color: var(--sw-red-bright); }
|
|
|
|
/* The confirmation banner — the decision the whole section builds to. */
|
|
#sw-root .sw-ade-result { display: flex; align-items: center; justify-content: center; gap: 16px; flex-wrap: wrap; padding: 16px; border-radius: 16px; background: linear-gradient(180deg, rgba(226,53,74,0.14), rgba(226,53,74,0.035)); border: 1px solid rgba(226,53,74,0.3); box-shadow: 0 18px 40px -22px rgba(226,53,74,0.5), inset 0 1px 0 rgba(255,255,255,0.08); }
|
|
#sw-root .sw-ade-result-badge { display: inline-flex; align-items: center; gap: 9px; font-size: clamp(14px, 1.6vw, 17px); font-weight: 800; color: #fff; }
|
|
#sw-root .sw-ade-result-badge svg { width: 18px; height: 18px; color: var(--sw-red-bright); }
|
|
#sw-root .sw-ade-result-sub { font-size: 12px; font-weight: 700; color: rgba(255,255,255,0.6); font-variant-numeric: tabular-nums; }
|
|
|
|
/* The dispatch beat — arrow → vehicle → delivery started. */
|
|
#sw-root .sw-ade-dispatch { display: flex; align-items: center; gap: 14px; margin-top: 14px; padding: 14px 16px; border-radius: 14px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); }
|
|
#sw-root .sw-ade-dispatch-ic { width: 38px; height: 38px; flex-shrink: 0; border-radius: 11px; display: inline-flex; align-items: center; justify-content: center; color: #fff; background: linear-gradient(180deg, var(--sw-red-bright), #8c0f1f); box-shadow: 0 8px 18px -8px rgba(226,53,74,0.7); }
|
|
#sw-root .sw-ade-dispatch-ic svg { width: 20px; height: 20px; }
|
|
#sw-root .sw-ade-dispatch-body { flex: 1; display: flex; flex-direction: column; gap: 7px; min-width: 0; }
|
|
#sw-root .sw-ade-dispatch-text { font-size: 13px; font-weight: 800; color: #fff; }
|
|
#sw-root .sw-ade-dispatch-bar { height: 6px; border-radius: 999px; background: rgba(255,255,255,0.1); overflow: hidden; }
|
|
#sw-root .sw-ade-dispatch-bar > i { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, var(--sw-red), var(--sw-red-bright)); box-shadow: 0 0 10px rgba(226,53,74,0.6); }
|
|
#sw-root .sw-ade-dispatch-eta { font-size: 11px; font-weight: 800; letter-spacing: 0.03em; color: rgba(255,255,255,0.55); white-space: nowrap; }
|
|
|
|
/* White & Red theme overrides for Sec04 (#routing inside white card) */
|
|
#sw-root .sw-sec--white h2,
|
|
#sw-root .sw-sec--white h2 *,
|
|
#sw-root .sw-sec--white .sw-h,
|
|
#sw-root .sw-sec--white .sw-h *,
|
|
#sw-root .sw-sec--white .gsap-word-outer,
|
|
#sw-root .sw-sec--white .gsap-word-inner,
|
|
#sw-root #routing h2,
|
|
#sw-root #routing h2 *,
|
|
#sw-root #routing .sw-h,
|
|
#sw-root #routing .sw-h *,
|
|
#sw-root #routing .gsap-word-outer,
|
|
#sw-root #routing .gsap-word-inner {
|
|
color: #111827 !important;
|
|
}
|
|
|
|
#sw-root .sw-sec--white h2 .accent,
|
|
#sw-root .sw-sec--white h2 *.accent,
|
|
#sw-root .sw-sec--white .sw-h .accent,
|
|
#sw-root .sw-sec--white .sw-h *.accent,
|
|
#sw-root .sw-sec--white .gsap-word-inner .accent,
|
|
#sw-root #routing h2 .accent,
|
|
#sw-root #routing h2 *.accent,
|
|
#sw-root #routing .sw-h .accent,
|
|
#sw-root #routing .sw-h *.accent,
|
|
#sw-root #routing .gsap-word-inner .accent {
|
|
color: #dc2626 !important;
|
|
}
|
|
|
|
#sw-root .sw-sec--white .sw-lede,
|
|
#sw-root .sw-sec--white .sw-lede *,
|
|
#sw-root #routing .sw-lede,
|
|
#sw-root #routing .sw-lede * {
|
|
color: #4b5563 !important;
|
|
}
|
|
|
|
#sw-root .sw-sec--white .sw-eyebrow,
|
|
#sw-root #routing .sw-eyebrow {
|
|
color: #6b7280 !important;
|
|
}
|
|
|
|
#sw-root #routing .sw-ade {
|
|
background: #f8fafc !important;
|
|
border: 1px solid #e2e8f0 !important;
|
|
box-shadow: 0 16px 40px -18px rgba(0, 0, 0, 0.06) !important;
|
|
}
|
|
#sw-root #routing .sw-ade::before {
|
|
background: radial-gradient(120% 80% at 50% -12%, rgba(220, 38, 38, 0.08), transparent 60%) !important;
|
|
}
|
|
#sw-root #routing .sw-ade-brand,
|
|
#sw-root #routing .sw-ade-brand * {
|
|
color: #0f172a !important;
|
|
}
|
|
#sw-root #routing .sw-ade-live,
|
|
#sw-root #routing .sw-ade-live * {
|
|
color: #475569 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-status {
|
|
color: #dc2626 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-card {
|
|
background: #ffffff !important;
|
|
border: 1px solid #cbd5e1 !important;
|
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04) !important;
|
|
opacity: 1 !important;
|
|
filter: none !important;
|
|
}
|
|
#sw-root #routing .sw-ade-card.is-muted {
|
|
background: #f1f5f9 !important;
|
|
border-color: #e2e8f0 !important;
|
|
opacity: 0.85 !important;
|
|
filter: none !important;
|
|
}
|
|
#sw-root #routing .sw-ade-card-name,
|
|
#sw-root #routing .sw-ade-card-name * {
|
|
color: #0f172a !important;
|
|
}
|
|
#sw-root #routing .sw-ade-row-l,
|
|
#sw-root #routing .sw-ade-row-l * {
|
|
color: #64748b !important;
|
|
}
|
|
#sw-root #routing .sw-ade-row-v,
|
|
#sw-root #routing .sw-ade-row-v * {
|
|
color: #334155 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-row-v b,
|
|
#sw-root #routing .sw-ade-row-v b * {
|
|
color: #0f172a !important;
|
|
}
|
|
#sw-root #routing .sw-ade-fuel-bar {
|
|
background: #e2e8f0 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-score {
|
|
border-top-color: #e2e8f0 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-score-head,
|
|
#sw-root #routing .sw-ade-score-head * {
|
|
color: #64748b !important;
|
|
}
|
|
#sw-root #routing .sw-ade-score-val {
|
|
color: #0f172a !important;
|
|
}
|
|
#sw-root #routing .sw-ade-card.is-winner {
|
|
border: 2px solid #dc2626 !important;
|
|
background: #fff5f5 !important;
|
|
box-shadow: 0 14px 32px -10px rgba(220, 38, 38, 0.3) !important;
|
|
opacity: 1 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-card.is-winner .sw-ade-card-name,
|
|
#sw-root #routing .sw-ade-card.is-winner .sw-ade-card-name * {
|
|
color: #0f172a !important;
|
|
}
|
|
#sw-root #routing .sw-ade-card.is-winner .sw-ade-score-val {
|
|
color: #dc2626 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-verdict,
|
|
#sw-root #routing .sw-ade-verdict * {
|
|
color: #64748b !important;
|
|
}
|
|
#sw-root #routing .sw-ade-verdict-ic {
|
|
background: #e2e8f0 !important;
|
|
color: #475569 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-verdict-ic--win {
|
|
background: #fee2e2 !important;
|
|
color: #dc2626 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-card.is-winner .sw-ade-verdict,
|
|
#sw-root #routing .sw-ade-card.is-winner .sw-ade-verdict * {
|
|
color: #dc2626 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-flow-arrow {
|
|
background: linear-gradient(90deg, transparent, rgba(220,38,38,0.4), transparent) !important;
|
|
}
|
|
#sw-root #routing .sw-ade-flow-chip {
|
|
background: #fee2e2 !important;
|
|
border: 1px solid #fca5a5 !important;
|
|
color: #dc2626 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-flow-chip * {
|
|
color: #dc2626 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-result {
|
|
background: #fef2f2 !important;
|
|
border: 1px solid #fca5a5 !important;
|
|
box-shadow: 0 10px 24px -10px rgba(220, 38, 38, 0.2) !important;
|
|
}
|
|
#sw-root #routing .sw-ade-result-badge,
|
|
#sw-root #routing .sw-ade-result-badge * {
|
|
color: #0f172a !important;
|
|
}
|
|
#sw-root #routing .sw-ade-result-sub,
|
|
#sw-root #routing .sw-ade-result-sub * {
|
|
color: #475569 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-dispatch {
|
|
background: #ffffff !important;
|
|
border: 1px solid #cbd5e1 !important;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,0.04) !important;
|
|
}
|
|
#sw-root #routing .sw-ade-dispatch-text,
|
|
#sw-root #routing .sw-ade-dispatch-text * {
|
|
color: #0f172a !important;
|
|
}
|
|
#sw-root #routing .sw-ade-dispatch-bar {
|
|
background: #e2e8f0 !important;
|
|
}
|
|
#sw-root #routing .sw-ade-dispatch-eta,
|
|
#sw-root #routing .sw-ade-dispatch-eta * {
|
|
color: #64748b !important;
|
|
}
|
|
|
|
@media (max-width: 860px) {
|
|
#sw-root .sw-ade-grid { grid-template-columns: 1fr 1fr; }
|
|
}
|
|
@media (max-width: 520px) {
|
|
#sw-root .sw-ade-grid { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
|
|
/* ================= SECTION 06 — Live Tracking (split) =================
|
|
Premium white iPhone-style live-tracking mockup. The device frame keeps
|
|
realistic proportions (thin bezels, Dynamic Island, soft shadow) and the
|
|
screen is pure white with soft radial lighting. The supplied SVG is a
|
|
self-animating map — its marker follows the route natively, so no JS path
|
|
animation is needed. */
|
|
#sw-root .sw-phone-col { display: flex; justify-content: center; }
|
|
#sw-root .sw-phone {
|
|
width: clamp(268px, 27vw, 326px); aspect-ratio: 300 / 652; flex-shrink: 0;
|
|
border-radius: clamp(44px, 4.4vw, 56px); padding: clamp(9px, 0.9vw, 12px);
|
|
background: linear-gradient(155deg, #24262b 0%, #0c0d10 46%, #050506 100%);
|
|
box-shadow:
|
|
0 70px 120px -46px rgba(0,0,0,0.78),
|
|
0 28px 50px -30px rgba(0,0,0,0.62),
|
|
inset 0 0 0 1.5px rgba(255,255,255,0.08),
|
|
inset 0 1px 1px rgba(255,255,255,0.14);
|
|
}
|
|
#sw-root .sw-phone-screen {
|
|
position: relative; width: 100%; height: 100%; overflow: hidden;
|
|
border-radius: clamp(34px, 3.5vw, 46px);
|
|
display: flex; flex-direction: column;
|
|
background:
|
|
radial-gradient(120% 78% at 50% 0%, #ffffff 0%, #f6f7f9 58%, #eef0f3 100%);
|
|
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05);
|
|
}
|
|
#sw-root .sw-phone-island {
|
|
position: absolute; top: clamp(11px, 1.2vw, 15px); left: 50%; transform: translateX(-50%);
|
|
width: clamp(80px, 28%, 100px); height: clamp(24px, 2.5vw, 30px);
|
|
border-radius: 999px; background: #060607; z-index: 6;
|
|
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06);
|
|
}
|
|
/* iOS status bar — live JS clock at left, static radios at right, flanking
|
|
the Dynamic Island. */
|
|
#sw-root .sw-phone-statusbar {
|
|
position: relative; z-index: 5; flex-shrink: 0;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: clamp(15px, 1.7vw, 19px) clamp(20px, 2.3vw, 26px) clamp(3px, 0.5vw, 6px);
|
|
color: #0a0a0b;
|
|
}
|
|
#sw-root .sw-phone-time {
|
|
font-size: clamp(14px, 1.4vw, 16px); font-weight: 600; letter-spacing: 0.01em;
|
|
font-variant-numeric: tabular-nums; min-width: 3ch;
|
|
}
|
|
#sw-root .sw-phone-status-icons { display: inline-flex; align-items: center; gap: clamp(5px, 0.6vw, 7px); }
|
|
#sw-root .sw-phone-ic { display: block; height: clamp(10px, 1.05vw, 12px); width: auto; color: #0a0a0b; }
|
|
#sw-root .sw-phone-ic-wifi { height: clamp(11px, 1.1vw, 13px); }
|
|
#sw-root .sw-phone-ic-batt { height: clamp(12px, 1.2vw, 14px); }
|
|
#sw-root .sw-phone-5g { font-size: clamp(11px, 1.1vw, 12.5px); font-weight: 600; letter-spacing: 0.01em; }
|
|
/* Glass notification banner. */
|
|
#sw-root .sw-phone-notif {
|
|
flex-shrink: 0; z-index: 3;
|
|
margin: clamp(6px, 0.8vw, 9px) clamp(10px, 1.2vw, 14px) 0;
|
|
padding: clamp(9px, 1.05vw, 12px) clamp(11px, 1.25vw, 14px);
|
|
display: flex; align-items: center; gap: clamp(9px, 1vw, 12px);
|
|
border-radius: clamp(17px, 1.9vw, 22px);
|
|
background: rgba(255,255,255,0.72);
|
|
-webkit-backdrop-filter: saturate(180%) blur(18px); backdrop-filter: saturate(180%) blur(18px);
|
|
border: 1px solid rgba(255,255,255,0.7);
|
|
box-shadow: 0 12px 28px -14px rgba(15,20,30,0.3), 0 2px 6px -2px rgba(15,20,30,0.1), inset 0 1px 0 rgba(255,255,255,0.6);
|
|
}
|
|
#sw-root .sw-phone-notif-ic {
|
|
width: clamp(30px, 3.2vw, 38px); height: clamp(30px, 3.2vw, 38px); flex-shrink: 0;
|
|
border-radius: clamp(8px, 0.9vw, 11px); object-fit: contain; padding: 4px;
|
|
background: #fff; border: 1px solid rgba(0,0,0,0.06);
|
|
box-shadow: 0 2px 5px -2px rgba(15,20,30,0.25);
|
|
}
|
|
#sw-root .sw-phone-notif-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
|
|
#sw-root .sw-phone-notif-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
#sw-root .sw-phone-notif-app { font-size: clamp(11.5px, 1.15vw, 13px); font-weight: 700; color: #0a0a0b; letter-spacing: 0.005em; }
|
|
#sw-root .sw-phone-notif-time { font-size: clamp(10.5px, 1vw, 11.5px); font-weight: 500; color: #9aa1ac; }
|
|
#sw-root .sw-phone-notif-title { font-size: clamp(11.5px, 1.15vw, 13px); font-weight: 600; color: #1c1f24; line-height: 1.2; }
|
|
#sw-root .sw-phone-notif-sub { font-size: clamp(10.5px, 1.05vw, 12px); font-weight: 500; color: #6b7280; line-height: 1.2; }
|
|
#sw-root .sw-phone-map {
|
|
position: relative; flex: 1; min-height: 0; overflow: hidden;
|
|
display: flex; align-items: stretch; justify-content: center;
|
|
padding: clamp(9px, 1.1vw, 13px) clamp(10px, 1.2vw, 14px) clamp(8px, 1vw, 12px);
|
|
}
|
|
#sw-root .sw-phone-track-svg {
|
|
display: block; width: 100%; height: 100%; object-fit: cover;
|
|
border-radius: clamp(15px, 1.7vw, 20px);
|
|
background: linear-gradient(162deg, #f7f9fc 0%, #eceff4 100%);
|
|
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05);
|
|
}
|
|
#sw-root .sw-phone-badge {
|
|
position: absolute; top: clamp(13px, 1.6vw, 18px); left: clamp(15px, 1.7vw, 20px);
|
|
display: inline-flex; align-items: center; gap: 7px; z-index: 3;
|
|
padding: 6px 12px 6px 10px; border-radius: 999px;
|
|
background: rgba(255,255,255,0.94);
|
|
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(0,0,0,0.06);
|
|
color: #0a0a0b; font-size: 11px; font-weight: 800; letter-spacing: 0.09em;
|
|
box-shadow: 0 8px 20px -8px rgba(15,20,30,0.35), 0 2px 6px -2px rgba(15,20,30,0.14);
|
|
}
|
|
#sw-root .sw-phone-badge-dot {
|
|
width: 8px; height: 8px; border-radius: 50%; background: #ff3b30; flex-shrink: 0;
|
|
box-shadow: 0 0 0 3px rgba(255,59,48,0.18), 0 0 8px 1px rgba(255,59,48,0.75);
|
|
}
|
|
#sw-root .sw-phone-card {
|
|
margin: clamp(2px, 0.4vw, 5px) clamp(12px, 1.4vw, 16px) clamp(14px, 1.6vw, 18px);
|
|
padding: clamp(15px, 1.7vw, 19px) clamp(16px, 1.9vw, 20px);
|
|
border-radius: clamp(22px, 2.4vw, 28px);
|
|
background: #ffffff; border: 1px solid rgba(0,0,0,0.07);
|
|
display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
|
|
box-shadow: 0 12px 30px -16px rgba(15,20,30,0.28), inset 0 1px 0 rgba(255,255,255,0.9);
|
|
}
|
|
#sw-root .sw-phone-card-label { color: #6b7280; font-size: clamp(12px, 1.2vw, 13.5px); font-weight: 600; letter-spacing: 0.01em; }
|
|
#sw-root .sw-phone-card-eta { color: #0a0a0b; font-size: clamp(26px, 3vw, 34px); font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
|
|
#sw-root .sw-phone-card-eta em { font-style: normal; font-size: clamp(12px, 1.2vw, 14px); font-weight: 600; color: #9aa1ac; margin-left: 5px; }
|
|
#sw-root .sw-track-cards { display: flex; flex-direction: column; gap: 14px; }
|
|
#sw-root .sw-track-card { display: flex; align-items: center; gap: 14px; padding: 16px 18px; border-radius: 18px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); }
|
|
#sw-root .sw-track-card-ic { width: 42px; height: 42px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: rgba(226,53,74,0.12); border: 1px solid rgba(226,53,74,0.3); color: var(--sw-red-bright); flex-shrink: 0; }
|
|
#sw-root .sw-track-card-ic svg { width: 20px; height: 20px; }
|
|
#sw-root .sw-track-card-label { display: block; color: #fff; font-weight: 700; font-size: 14px; }
|
|
#sw-root .sw-track-card-note { display: block; color: rgba(255,255,255,0.55); font-size: 12.5px; margin-top: 2px; }
|
|
|
|
/* ---- Section 06 rebuild: top heading + sticky phone / feature cards ---- */
|
|
#sw-root .sw-ldi-head { max-width: 1040px; margin-bottom: clamp(40px, 5vw, 64px); }
|
|
#sw-root .sw-ldi-eyebrow { margin-bottom: clamp(16px, 2vw, 22px); }
|
|
#sw-root .sw-ldi-h {
|
|
color: #fff; font-weight: 900; letter-spacing: -0.03em;
|
|
font-size: clamp(34px, 4.5vw, 64px); line-height: 1.15; margin: 0 0 clamp(16px, 2vw, 24px);
|
|
max-width: 1000px;
|
|
}
|
|
#sw-root .sw-ldi-line { display: inline-block; margin-right: 0.28em; }
|
|
#sw-root .sw-ldi-h .accent { color: var(--sw-red-bright); }
|
|
#sw-root .sw-ldi-lede {
|
|
color: var(--sw-text-dim); font-size: clamp(16px, 1.2vw, 19px); line-height: 1.65;
|
|
font-weight: 400; margin: 0; max-width: 780px;
|
|
}
|
|
#sw-root .sw-ldi-grid {
|
|
display: grid; grid-template-columns: minmax(280px, 0.82fr) minmax(0, 1.18fr);
|
|
gap: clamp(36px, 5vw, 80px); align-items: stretch;
|
|
}
|
|
#sw-root .sw-ldi-phone-col { position: relative; display: flex; justify-content: center; }
|
|
#sw-root .sw-ldi-phone-sticky {
|
|
position: sticky; top: clamp(88px, 12vh, 132px); align-self: start;
|
|
display: flex; justify-content: center; width: 100%;
|
|
}
|
|
#sw-root .sw-ldi-cards { display: flex; flex-direction: column; gap: clamp(28px, 3vw, 38px); }
|
|
#sw-root .sw-ldi-card {
|
|
position: relative; border-radius: clamp(22px, 2.4vw, 28px);
|
|
padding: clamp(20px, 2.2vw, 28px);
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.055) 0%, rgba(255,255,255,0.02) 100%);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
box-shadow: 0 24px 50px -30px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.06);
|
|
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
|
|
transition: transform 0.4s cubic-bezier(0.16,1,0.3,1), border-color 0.4s ease, box-shadow 0.4s ease;
|
|
}
|
|
#sw-root .sw-ldi-card::after {
|
|
content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
|
|
background: radial-gradient(120% 80% at 50% 0%, rgba(226,53,74,0.12) 0%, rgba(226,53,74,0) 60%);
|
|
opacity: 0; transition: opacity 0.4s ease;
|
|
}
|
|
#sw-root .sw-ldi-card:hover {
|
|
transform: translateY(-6px);
|
|
border-color: rgba(226,53,74,0.4);
|
|
box-shadow: 0 34px 70px -34px rgba(0,0,0,0.8), 0 0 0 1px rgba(226,53,74,0.2), 0 18px 60px -30px rgba(226,53,74,0.35);
|
|
}
|
|
#sw-root .sw-ldi-card:hover::after { opacity: 1; }
|
|
#sw-root .sw-ldi-card-art {
|
|
position: relative; z-index: 1; width: 100%; margin-bottom: clamp(16px, 1.8vw, 22px);
|
|
border-radius: clamp(16px, 1.8vw, 20px); overflow: hidden;
|
|
}
|
|
#sw-root .sw-ldi-svg { display: block; width: 100%; height: auto; }
|
|
/* Supplied doorven.svg hero — a square SVG shown large and centered on its
|
|
own stage, contained (never cropped or distorted), with generous padding.
|
|
Loaded as <img> so its embedded SMIL animations keep running. */
|
|
#sw-root .sw-ldi-card-art-stage {
|
|
aspect-ratio: 4 / 3; width: 100%; overflow: hidden;
|
|
display: flex; align-items: center; justify-content: center;
|
|
padding: clamp(10px, 2vw, 22px);
|
|
border-radius: inherit;
|
|
background: linear-gradient(180deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.012) 100%);
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
}
|
|
#sw-root .sw-ldi-svg-img {
|
|
display: block; width: 100%; height: 100%;
|
|
object-fit: contain; object-position: center;
|
|
/* The artwork sits inside a large transparent 1080² canvas; zoom past the
|
|
built-in margin so the van itself is the hero (~60% of card height). The
|
|
empty margins overflow and are clipped by the stage. */
|
|
transform: scale(2.15); transform-origin: center;
|
|
}
|
|
#sw-root .sw-ldi-card-title {
|
|
position: relative; z-index: 1; color: #fff; font-weight: 800;
|
|
font-size: clamp(19px, 1.7vw, 24px); letter-spacing: -0.01em; margin: 0 0 8px;
|
|
}
|
|
#sw-root .sw-ldi-card-desc {
|
|
position: relative; z-index: 1; color: var(--sw-text-dim);
|
|
font-size: clamp(13.5px, 1.05vw, 15.5px); line-height: 1.55; margin: 0; max-width: 46ch;
|
|
}
|
|
@media (max-width: 991px) {
|
|
#sw-root .sw-ldi-grid { grid-template-columns: 1fr; gap: clamp(32px, 5vw, 48px); }
|
|
#sw-root .sw-ldi-phone-sticky { position: static; top: auto; }
|
|
}
|
|
@media (max-width: 767px) {
|
|
#sw-root .sw-ldi-cards { gap: 24px; }
|
|
}
|
|
|
|
/* ================= SECTION 07 — Delivery Flow ================= */
|
|
#sw-root .sw-flow { position: relative; min-height: clamp(380px, 30vw, 460px); }
|
|
/* Inset by the same clamp(16px, 3vw, 32px) every section's primary media
|
|
panel uses (see Section 05's canvas inset), instead of bleeding to the
|
|
section's own edge. */
|
|
#sw-root .sw-flow-bg { position: absolute; inset: clamp(16px, 3vw, 32px); border-radius: 16px; overflow: hidden; }
|
|
#sw-root .sw-flow-bg img { width: 100%; height: 100%; object-fit: cover; }
|
|
#sw-root .sw-flow-bg::after { content: ''; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,0.8), rgba(0,0,0,0.93)); }
|
|
#sw-root .sw-flow-rail { position: relative; z-index: 1; display: flex; align-items: flex-start; justify-content: space-between; gap: 6px; padding: clamp(36px, 5vw, 60px) clamp(16px, 3vw, 32px); flex-wrap: wrap; row-gap: 30px; }
|
|
#sw-root .sw-flow-track { position: absolute; left: 4%; right: 4%; top: calc(clamp(36px, 5vw, 60px) + 22px); height: 2px; background: rgba(255,255,255,0.14); z-index: 0; border-radius: 2px; overflow: visible; }
|
|
#sw-root .sw-flow-track-fill { position: absolute; inset: 0; width: calc(var(--fill, 0) * 100%); background: linear-gradient(90deg, var(--sw-red), var(--sw-red-bright)); box-shadow: 0 0 10px rgba(226,53,74,0.7); border-radius: 2px; }
|
|
#sw-root .sw-flow-marker { position: absolute; top: 50%; left: calc(var(--fill, 0) * 100%); width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%; background: #fff; box-shadow: 0 0 14px 4px rgba(226,53,74,0.85); }
|
|
#sw-root .sw-flow-stage { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; width: 148px; opacity: 0.55; transition: opacity 0.4s ease; }
|
|
#sw-root .sw-flow-stage.is-active { opacity: 1; }
|
|
#sw-root .sw-flow-num { font-size: 11px; font-weight: 800; color: rgba(255,255,255,0.35); letter-spacing: 0.08em; }
|
|
#sw-root .sw-flow-ic {
|
|
width: 46px; height: 46px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
|
background: #0a0a0a; border: 1.5px solid rgba(226,53,74,0.4); color: rgba(226,53,74,0.7);
|
|
box-shadow: 0 0 0 5px #000; transition: border-color 0.4s ease, color 0.4s ease, box-shadow 0.4s ease, transform 0.4s ease;
|
|
}
|
|
#sw-root .sw-flow-stage.is-active .sw-flow-ic { border-color: var(--sw-red); color: var(--sw-red-bright); box-shadow: 0 0 0 5px #000, 0 0 24px -4px rgba(192,18,39,0.9); transform: scale(1.08); }
|
|
#sw-root .sw-flow-ic svg { width: 21px; height: 21px; }
|
|
#sw-root .sw-flow-label { color: #fff; font-weight: 700; font-size: 13.5px; }
|
|
#sw-root .sw-flow-note { color: rgba(255,255,255,0.55); font-size: 12px; line-height: 1.4; }
|
|
@media (max-width: 900px) { #sw-root .sw-flow-track { display: none; } }
|
|
|
|
/* ================= SECTION 08 — MileTruth AI (split + R3F) ================= */
|
|
/* One card, not three: the scene fills the frame directly, so there is no
|
|
inner rounded box nested inside the outer rounded box. */
|
|
#sw-root .sw-mt-canvas { position: relative; height: clamp(360px, 40vw, 520px); border-radius: 24px; overflow: hidden; border: 1px solid var(--sw-line); }
|
|
#sw-root .sw-mt-canvas .mtn-scene { border-radius: 0; height: 100%; min-height: 0; }
|
|
/* Grid, not wrapping flex — the arrows are real grid items in fixed tracks,
|
|
so a step can never drop to a second row and strand its arrow behind it. */
|
|
#sw-root .sw-mt-flow { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr) auto) minmax(0, 1fr); align-items: start; gap: 6px; margin-bottom: clamp(24px, 2.8vw, 32px); }
|
|
#sw-root .sw-mt-step { display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: 0; text-align: center; }
|
|
#sw-root .sw-mt-flow .sw-veh-arrow { align-self: start; margin-top: 13px; }
|
|
#sw-root .sw-mt-step-note { font-size: 11px; font-weight: 500; color: var(--sw-text-dim); line-height: 1.35; }
|
|
/* Four steps abreast need ~548px: "Recommendations" alone is 114px and
|
|
cannot hyphenate. The copy column doesn't narrow with the viewport
|
|
(the split flips to one column at 991px, which makes it wider again),
|
|
so this keys off the column's own width rather than the screen's. */
|
|
#sw-root .sw-mt-copy { container-type: inline-size; }
|
|
@container (max-width: 548px) {
|
|
#sw-root .sw-mt-flow { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 10px; }
|
|
#sw-root .sw-mt-flow .sw-veh-arrow { display: none; }
|
|
#sw-root .sw-mt-step { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "ic label" "ic note"; column-gap: 10px; row-gap: 2px; text-align: left; }
|
|
#sw-root .sw-mt-step-ic { grid-area: ic; align-self: center; width: 42px; height: 42px; border-radius: 12px; }
|
|
#sw-root .sw-mt-step-label { grid-area: label; align-self: end; }
|
|
#sw-root .sw-mt-step-note { grid-area: note; align-self: start; }
|
|
}
|
|
@container (max-width: 360px) {
|
|
#sw-root .sw-mt-flow { grid-template-columns: 1fr; }
|
|
}
|
|
#sw-root .sw-mt-step-ic { width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: var(--sw-red-bright); }
|
|
#sw-root .sw-mt-step-ic svg { width: 22px; height: 22px; }
|
|
#sw-root .sw-mt-step-label { font-size: 12.5px; font-weight: 700; color: #fff; }
|
|
#sw-root .sw-mt-feats { display: flex; gap: 10px; flex-wrap: wrap; border-top: 1px solid rgba(255,255,255,0.08); padding-top: clamp(20px, 2.5vw, 28px); }
|
|
#sw-root .sw-mt-feat { display: inline-flex; align-items: center; gap: 8px; padding: 9px 15px; border-radius: 999px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); font-size: 12.5px; font-weight: 600; }
|
|
#sw-root .sw-mt-feat-ic { color: var(--sw-red-bright); display: flex; }
|
|
#sw-root .sw-mt-feat-ic svg { width: 15px; height: 15px; }
|
|
|
|
/* ================= SECTION 09 — Ops Dashboard (White & Red Theme) ================= */
|
|
#sw-root .sw-bg--white {
|
|
background: #ffffff;
|
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
}
|
|
#sw-root .sw-bg--white::before {
|
|
content: ''; position: absolute; inset: 0;
|
|
background:
|
|
radial-gradient(65% 55% at 50% 0%, rgba(226, 53, 74, 0.08) 0%, transparent 70%),
|
|
radial-gradient(45% 45% at 90% 90%, rgba(192, 18, 39, 0.04) 0%, transparent 70%);
|
|
pointer-events: none;
|
|
}
|
|
|
|
#sw-root #ops-dashboard .sw-eyebrow--slash {
|
|
color: #dc2626 !important;
|
|
font-weight: 700 !important;
|
|
}
|
|
#sw-root #ops-dashboard .sw-h {
|
|
color: #111827 !important;
|
|
}
|
|
#sw-root #ops-dashboard .sw-h .accent {
|
|
color: #dc2626 !important;
|
|
}
|
|
#sw-root #ops-dashboard .sw-lede {
|
|
color: #4b5563 !important;
|
|
}
|
|
|
|
#sw-root #ops-dashboard .sw-dash {
|
|
background: #ffffff;
|
|
border: 1px solid rgba(220, 38, 38, 0.14);
|
|
box-shadow: 0 20px 40px -15px rgba(220, 38, 38, 0.07), 0 10px 25px -10px rgba(0, 0, 0, 0.05);
|
|
}
|
|
#sw-root #ops-dashboard .sw-dash-chrome {
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
|
}
|
|
#sw-root #ops-dashboard .sw-dash-brand {
|
|
color: #111827;
|
|
}
|
|
#sw-root #ops-dashboard .sw-dash-live {
|
|
color: #6b7280;
|
|
}
|
|
#sw-root #ops-dashboard .sw-livedot {
|
|
background: #dc2626;
|
|
box-shadow: 0 0 10px rgba(220, 38, 38, 0.5);
|
|
}
|
|
|
|
#sw-root #ops-dashboard .sw-kpi-tile {
|
|
background: #fef2f2;
|
|
border: 1px solid rgba(220, 38, 38, 0.12);
|
|
transition: transform 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
|
}
|
|
#sw-root #ops-dashboard .sw-kpi-tile:hover {
|
|
transform: translateY(-2px);
|
|
border-color: rgba(220, 38, 38, 0.3);
|
|
box-shadow: 0 8px 20px -6px rgba(220, 38, 38, 0.15);
|
|
}
|
|
#sw-root #ops-dashboard .sw-kpi-tile-ic {
|
|
color: #dc2626;
|
|
}
|
|
#sw-root #ops-dashboard .sw-kpi-val {
|
|
color: #111827;
|
|
}
|
|
#sw-root #ops-dashboard .sw-kpi-label {
|
|
color: #6b7280;
|
|
}
|
|
|
|
#sw-root #ops-dashboard .sw-dash-panel {
|
|
background: #ffffff;
|
|
border: 1px solid rgba(0, 0, 0, 0.07);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
|
}
|
|
#sw-root #ops-dashboard .sw-dash-panel-h {
|
|
color: #4b5563;
|
|
}
|
|
|
|
#sw-root #ops-dashboard .sw-ring-track {
|
|
stroke: rgba(0, 0, 0, 0.08);
|
|
}
|
|
#sw-root #ops-dashboard .sw-ring-center b {
|
|
color: #111827;
|
|
}
|
|
#sw-root #ops-dashboard .sw-ring-center span {
|
|
color: #6b7280;
|
|
}
|
|
#sw-root #ops-dashboard .sw-ring-legend span {
|
|
color: #374151;
|
|
}
|
|
|
|
#sw-root #ops-dashboard .sw-bar {
|
|
background: linear-gradient(180deg, #ef4444, #dc2626);
|
|
}
|
|
|
|
#sw-root #ops-dashboard .sw-fleet-id {
|
|
color: #111827;
|
|
}
|
|
#sw-root #ops-dashboard .sw-fleet-status {
|
|
color: #4b5563;
|
|
}
|
|
#sw-root #ops-dashboard .sw-fleet-eta {
|
|
color: #6b7280;
|
|
}
|
|
#sw-root #ops-dashboard .sw-fleet-dot--moving {
|
|
background: #2563eb;
|
|
box-shadow: 0 0 8px rgba(37, 99, 235, 0.35);
|
|
}
|
|
#sw-root #ops-dashboard .sw-fleet-dot--idle {
|
|
background: #9ca3af;
|
|
box-shadow: none;
|
|
}
|
|
|
|
#sw-root #ops-dashboard .sw-feed-row {
|
|
background: #fdf2f2;
|
|
border: 1px solid rgba(220, 38, 38, 0.08);
|
|
color: #1f2937;
|
|
}
|
|
|
|
#sw-root .sw-dash-chrome { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-bottom: clamp(20px, 2.4vw, 28px); margin-bottom: clamp(20px, 2.4vw, 28px); border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
#sw-root .sw-dash-brand { display: inline-flex; align-items: center; gap: 9px; color: #fff; font-size: 14px; font-weight: 700; }
|
|
#sw-root .sw-dash-live { color: rgba(255,255,255,0.45); font-size: 12px; font-weight: 600; }
|
|
#sw-root .sw-dash-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: clamp(24px, 2.8vw, 32px); }
|
|
#sw-root .sw-kpi-tile { display: flex; flex-direction: column; gap: 6px; padding: 18px 20px; border-radius: 16px; background: rgba(255,255,255,0.035); border: 1px solid rgba(255,255,255,0.08); }
|
|
#sw-root .sw-kpi-tile-ic { color: var(--sw-red-bright); }
|
|
#sw-root .sw-kpi-tile-ic svg { width: 18px; height: 18px; }
|
|
#sw-root .sw-kpi-val { color: #fff; font-weight: 800; font-size: clamp(22px, 2.4vw, 28px); letter-spacing: -0.01em; }
|
|
#sw-root .sw-kpi-label { color: rgba(255,255,255,0.55); font-size: 12px; font-weight: 600; }
|
|
#sw-root .sw-dash-body { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
|
#sw-root .sw-dash-panel { background: rgba(255,255,255,0.025); border: 1px solid rgba(255,255,255,0.07); border-radius: 20px; padding: clamp(20px, 2.4vw, 26px); display: flex; flex-direction: column; gap: 16px; }
|
|
#sw-root .sw-dash-panel--wide { grid-column: span 2; }
|
|
#sw-root .sw-dash-panel-h { font-size: 11.5px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: rgba(255,255,255,0.45); }
|
|
#sw-root .sw-ring-wrap { position: relative; width: 128px; height: 128px; margin: 0 auto; }
|
|
#sw-root .sw-ring { width: 100%; height: 100%; }
|
|
#sw-root .sw-ring-track { fill: none; stroke: rgba(255,255,255,0.06); stroke-width: 3.2; }
|
|
#sw-root .sw-ring-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
|
#sw-root .sw-ring-center b { color: #fff; font-size: 17px; font-weight: 800; }
|
|
#sw-root .sw-ring-center span { color: rgba(255,255,255,0.5); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
#sw-root .sw-ring-legend { display: flex; flex-direction: column; gap: 8px; }
|
|
#sw-root .sw-ring-legend span { display: flex; align-items: center; gap: 8px; font-size: 12px; color: rgba(255,255,255,0.65); font-weight: 600; }
|
|
#sw-root .sw-ring-legend i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
#sw-root .sw-bars { display: flex; align-items: flex-end; gap: 10px; height: 128px; }
|
|
#sw-root .sw-bar { flex: 1; border-radius: 6px 6px 2px 2px; background: linear-gradient(180deg, var(--sw-red-bright), var(--sw-red)); opacity: 0.9; transform-origin: bottom; display: block; }
|
|
#sw-root .sw-heat { display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; }
|
|
#sw-root .sw-heat-cell { aspect-ratio: 1; border-radius: 5px; }
|
|
#sw-root .sw-heat-cell.is-hot { box-shadow: 0 0 14px rgba(226,53,74,0.35); }
|
|
#sw-root .sw-fleet-list { display: flex; flex-direction: column; gap: 10px; }
|
|
#sw-root .sw-fleet-row { display: grid; grid-template-columns: 10px 1fr auto auto; align-items: center; gap: 10px; min-height: 24px; font-size: 12.5px; }
|
|
#sw-root .sw-fleet-dot { width: 8px; height: 8px; border-radius: 50%; animation: swFleetPulse 2.4s ease-in-out infinite; }
|
|
#sw-root .sw-fleet-dot--moving { background: #f4f4f6; box-shadow: 0 0 8px rgba(255,255,255,0.45); }
|
|
#sw-root .sw-fleet-dot--loading { background: #f1b84b; box-shadow: 0 0 8px rgba(241,184,75,0.48); animation-duration: 1.7s; }
|
|
#sw-root .sw-fleet-dot--delayed { background: var(--sw-red-bright); box-shadow: 0 0 9px rgba(226,53,74,0.7); animation-duration: 1.4s; }
|
|
#sw-root .sw-fleet-dot--idle { background: rgba(255,255,255,0.35); box-shadow: none; animation-duration: 3.2s; }
|
|
#sw-root .sw-fleet-id { color: #fff; font-weight: 700; }
|
|
#sw-root .sw-fleet-status { color: rgba(255,255,255,0.6); }
|
|
#sw-root .sw-fleet-eta { color: rgba(255,255,255,0.4); font-variant-numeric: tabular-nums; }
|
|
#sw-root .sw-feed { display: grid; grid-template-rows: repeat(4, 38px); gap: 10px; height: 182px; overflow: hidden; }
|
|
#sw-root .sw-feed-slot { position: relative; min-height: 38px; }
|
|
#sw-root .sw-feed-row { position: absolute; inset: 0; display: flex; align-items: center; gap: 10px; font-size: 13px; color: rgba(255,255,255,0.75); padding: 10px 14px; border-radius: 12px; background: rgba(255,255,255,0.03); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
@keyframes swFleetPulse {
|
|
0%, 100% { transform: scale(1); opacity: 0.72; }
|
|
50% { transform: scale(1.25); opacity: 1; }
|
|
}
|
|
@media (max-width: 1200px) { #sw-root .sw-dash-kpis { grid-template-columns: repeat(2, 1fr); } #sw-root .sw-dash-body { grid-template-columns: 1fr 1fr; } #sw-root .sw-dash-panel--wide, #sw-root .sw-dash-panel--fleet { grid-column: span 2; } }
|
|
|
|
/* ================= SECTION 10 — Why Doormile ================= */
|
|
#sw-root .sw-kpi10-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(16px, 2vw, 22px); }
|
|
#sw-root .sw-tilt { will-change: transform; }
|
|
#sw-root .sw-kpi10-card {
|
|
position: relative; overflow: hidden; display: flex; flex-direction: column; gap: 10px; padding: clamp(24px, 2.8vw, 32px); border-radius: 22px;
|
|
background: rgba(255,255,255,0.035); border: 1px solid rgba(255,255,255,0.09);
|
|
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
|
}
|
|
#sw-root .sw-kpi10-card:hover { border-color: rgba(226,53,74,0.45); box-shadow: 0 24px 60px -30px rgba(192,18,39,0.55); }
|
|
#sw-root .sw-kpi10-shine { position: absolute; inset: -40%; background: linear-gradient(115deg, transparent 40%, rgba(255,255,255,0.05) 50%, transparent 60%); opacity: 0; transition: opacity 0.4s ease; pointer-events: none; }
|
|
#sw-root .sw-kpi10-card:hover .sw-kpi10-shine { opacity: 1; }
|
|
#sw-root .sw-kpi10-ic { color: var(--sw-red-bright); position: relative; }
|
|
#sw-root .sw-kpi10-ic svg { width: 26px; height: 26px; }
|
|
#sw-root .sw-kpi10-val { color: #fff; font-size: clamp(24px, 2.6vw, 32px); font-weight: 800; letter-spacing: -0.01em; position: relative; }
|
|
#sw-root .sw-kpi10-label { color: rgba(255,255,255,0.6); font-size: 13px; font-weight: 600; position: relative; }
|
|
@media (max-width: 860px) { #sw-root .sw-kpi10-grid { grid-template-columns: repeat(2, 1fr); } }
|
|
@media (max-width: 520px) { #sw-root .sw-kpi10-grid { grid-template-columns: 1fr; } }
|
|
|
|
/* ================= Mobile: dedicated per-section rework ================= */
|
|
@media (max-width: 1024px) {
|
|
#sw-root .sw-sec { padding: 72px 0; margin-top: 32px; }
|
|
#sw-root .sw-in { width: calc(100% - 20px); }
|
|
#sw-root .sw-bg { left: 10px; right: 10px; }
|
|
#sw-root { margin-bottom: 32px; }
|
|
}
|
|
@media (max-width: 991px) {
|
|
#sw-root .sw-split, #sw-root .sw-split--reverse { grid-template-columns: 1fr; gap: 32px; }
|
|
#sw-root .sw-split--reverse .sw-mt-canvas-col { order: -1; }
|
|
#sw-root .sw-veh-stage { order: -1; }
|
|
#sw-root .sw-dash-body { grid-template-columns: 1fr; }
|
|
#sw-root .sw-dash-panel--wide, #sw-root .sw-dash-panel--fleet { grid-column: span 1 !important; }
|
|
}
|
|
@media (max-width: 767px) {
|
|
#sw-root { margin-bottom: 24px; --sw-in-pad: 16px; }
|
|
#sw-root .sw-sec { padding: 56px 0; margin-top: 24px; }
|
|
#sw-root .sw-panel, #sw-root .sw-edge, #sw-root .sw-ade, #sw-root .sw-veh-stage, #sw-root .sw-bg { border-radius: 20px; }
|
|
#sw-root .sw-panel { padding: 16px; }
|
|
#sw-root .sw-ade { padding: 16px; }
|
|
#sw-root .sw-dash { overflow: hidden; width: 100%; max-width: 100%; box-sizing: border-box; }
|
|
#sw-root .sw-dash-kpis { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
|
#sw-root .sw-kpi-tile { padding: 12px 14px; gap: 4px; }
|
|
#sw-root .sw-kpi-val { font-size: clamp(18px, 4.5vw, 24px); }
|
|
#sw-root .sw-kpi-label { font-size: 11px; }
|
|
#sw-root .sw-dash-body { grid-template-columns: 1fr !important; gap: 12px; }
|
|
#sw-root .sw-dash-panel, #sw-root .sw-dash-panel--wide, #sw-root .sw-dash-panel--fleet { grid-column: span 1 !important; padding: 16px 14px; gap: 14px; max-width: 100%; box-sizing: border-box; overflow: hidden; }
|
|
#sw-root .sw-bars { gap: 6px; max-width: 100%; }
|
|
#sw-root .sw-fleet-row { grid-template-columns: 8px 1fr auto auto; gap: 6px; font-size: 11.5px; }
|
|
#sw-root .sw-fleet-id, #sw-root .sw-fleet-status { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
#sw-root .sw-fleet-eta { white-space: nowrap; }
|
|
#sw-root .sw-feed-row { font-size: 12px; padding: 8px 10px; max-width: 100%; box-sizing: border-box; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
#sw-root .sw-h { font-size: clamp(26px, 8vw, 34px) !important; }
|
|
#sw-root .sw-lede { max-width: none; }
|
|
/* The map fills via object-fit:cover, so the phone can keep realistic
|
|
tall proportions on mobile without the old square-art overflow hack. */
|
|
#sw-root .sw-phone { width: clamp(258px, 80vw, 320px); aspect-ratio: 300 / 624; }
|
|
#sw-root .sw-phone-map { padding: clamp(8px, 2.4vw, 12px) clamp(9px, 2.6vw, 13px) clamp(8px, 2.2vw, 11px); }
|
|
}
|
|
@media (max-width: 640px) {
|
|
#sw-root .sw-mt-canvas { height: clamp(300px, 62vw, 380px); }
|
|
}
|
|
@media (max-width: 480px) {
|
|
#sw-root .sw-dash-chrome { flex-wrap: wrap; gap: 6px; padding-bottom: 12px; margin-bottom: 16px; }
|
|
#sw-root .sw-dash-kpis { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
|
#sw-root .sw-kpi-tile { padding: 10px 12px; }
|
|
#sw-root .sw-ring-wrap { width: 108px; height: 108px; }
|
|
#sw-root .sw-heat { gap: 3px; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
#sw-root .sw-jnode-hero-ring, #sw-root .sw-livedot, #sw-root .sw-phone-badge-dot, #sw-root .sw-ade-livedot { animation: none !important; }
|
|
}
|
|
`;
|