fix blog page
This commit is contained in:
122
src/components/blog/BlogSearch.tsx
Normal file
122
src/components/blog/BlogSearch.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { blogPosts } from "@/data/blog";
|
||||
|
||||
/**
|
||||
* Client-side blog search. The site is a static export, so there is no search
|
||||
* server — we filter the known posts in the browser and link straight to the
|
||||
* matching /blog/[slug] routes.
|
||||
*/
|
||||
export default function BlogSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return blogPosts
|
||||
.filter(
|
||||
(p) =>
|
||||
p.title.toLowerCase().includes(q) ||
|
||||
p.category.toLowerCase().includes(q) ||
|
||||
p.excerpt.toLowerCase().includes(q)
|
||||
)
|
||||
.slice(0, 6);
|
||||
}, [query]);
|
||||
|
||||
// Close the results panel on outside click.
|
||||
useEffect(() => {
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
return () => document.removeEventListener("mousedown", onDocClick);
|
||||
}, []);
|
||||
|
||||
const showPanel = open && query.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="dm-blog-search" ref={containerRef}>
|
||||
<form
|
||||
role="search"
|
||||
className="dm-blog-search-form"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<label htmlFor="dm-blog-search-input" className="dm-sr-only">
|
||||
Search articles
|
||||
</label>
|
||||
<input
|
||||
id="dm-blog-search-input"
|
||||
type="search"
|
||||
className="dm-blog-search-input"
|
||||
placeholder="Search articles…"
|
||||
value={query}
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
aria-expanded={showPanel}
|
||||
aria-controls="dm-blog-search-results"
|
||||
/>
|
||||
<span className="dm-blog-search-icon" aria-hidden="true">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</span>
|
||||
</form>
|
||||
|
||||
{showPanel && (
|
||||
<div
|
||||
id="dm-blog-search-results"
|
||||
className="dm-blog-search-results"
|
||||
role="listbox"
|
||||
>
|
||||
{results.length === 0 ? (
|
||||
<p className="dm-blog-search-empty">
|
||||
No articles match “{query.trim()}”.
|
||||
</p>
|
||||
) : (
|
||||
<ul>
|
||||
{results.map((p) => (
|
||||
<li key={p.slug} role="option" aria-selected="false">
|
||||
<Link
|
||||
href={`/blog/${p.slug}`}
|
||||
className="dm-blog-search-result"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<span className="dm-blog-search-result-cat">
|
||||
{p.category}
|
||||
</span>
|
||||
<span className="dm-blog-search-result-title">
|
||||
{p.title}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/components/blog/BlogSidebar.tsx
Normal file
100
src/components/blog/BlogSidebar.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
getRecentPosts,
|
||||
getCategories,
|
||||
formatDate,
|
||||
type BlogPost,
|
||||
} from "@/data/blog";
|
||||
import BlogSearch from "./BlogSearch";
|
||||
|
||||
/**
|
||||
* Sticky single-post sidebar: Search, Recent Posts, Categories and a CTA card.
|
||||
* Styling lives in SingleBlog's scoped <style> block (dm-blog-* classes).
|
||||
*/
|
||||
export default function BlogSidebar({ current }: { current?: BlogPost }) {
|
||||
const recent = getRecentPosts(5)
|
||||
.filter((p) => p.slug !== current?.slug)
|
||||
.slice(0, 4);
|
||||
const categories = getCategories();
|
||||
|
||||
return (
|
||||
<aside className="dm-blog-sidebar" aria-label="Blog sidebar">
|
||||
{/* Search */}
|
||||
<section className="dm-blog-widget">
|
||||
<h2 className="dm-blog-widget-title">Search</h2>
|
||||
<BlogSearch />
|
||||
</section>
|
||||
|
||||
{/* Recent Posts */}
|
||||
<section className="dm-blog-widget">
|
||||
<h2 className="dm-blog-widget-title">Recent Posts</h2>
|
||||
<ul className="dm-blog-recent">
|
||||
{recent.map((p) => (
|
||||
<li key={p.slug}>
|
||||
<Link href={`/blog/${p.slug}`} className="dm-blog-recent-item">
|
||||
<span className="dm-blog-recent-thumb">
|
||||
<Image
|
||||
src={p.image}
|
||||
alt={p.title}
|
||||
fill
|
||||
sizes="62px"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
</span>
|
||||
<span className="dm-blog-recent-meta">
|
||||
<span className="dm-blog-recent-title">{p.title}</span>
|
||||
<time dateTime={p.date} className="dm-blog-recent-date">
|
||||
{formatDate(p.date)}
|
||||
</time>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Categories */}
|
||||
<section className="dm-blog-widget">
|
||||
<h2 className="dm-blog-widget-title">Categories</h2>
|
||||
<ul className="dm-blog-categories">
|
||||
{categories.map((c) => (
|
||||
<li key={c.name}>
|
||||
<Link href="/blog" className="dm-blog-category-item">
|
||||
<span>{c.name}</span>
|
||||
<span className="dm-blog-category-count">{c.count}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* CTA Card */}
|
||||
<section className="dm-blog-widget dm-blog-cta-card">
|
||||
<h2 className="dm-blog-cta-title">Ready to optimise your fleet?</h2>
|
||||
<p className="dm-blog-cta-text">
|
||||
See how MileTruth™ AI cuts distance, vehicles and emissions — without
|
||||
missing an SLA.
|
||||
</p>
|
||||
<Link href="/contact" className="dm-blog-cta-btn">
|
||||
Contact Us
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</Link>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -480,8 +480,15 @@ export default function Header() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{/* Inline <style> block — 1:1 translation of header.php lines 600-627 */}
|
||||
{/* Inline <style> block — 1:1 translation of header.php lines 600-627.
|
||||
suppressHydrationWarning: this is a static, deterministic CSS string,
|
||||
but as a client component its dangerouslySetInnerHTML is diffed during
|
||||
hydration. A stale prebuilt out/ or a CSS-injecting browser extension
|
||||
can make the server HTML differ from the client bundle, which React
|
||||
refuses to patch — suppressing avoids a false console error for a node
|
||||
whose content never depends on render-time state. */}
|
||||
<style
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
#masthead .elementor-element.elementor-element-466de1b {
|
||||
@@ -512,8 +519,10 @@ export default function Header() {
|
||||
Force position:fixed once scrolled past 50px so the header stays in viewport. */
|
||||
#masthead .elementor-element.elementor-element-466de1b.dm-header-scrolled {
|
||||
position: fixed !important;
|
||||
background: #4b4b4baa !important;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18) !important;
|
||||
background: rgba(26, 26, 26, 0.92) !important;
|
||||
-webkit-backdrop-filter: blur(14px) !important;
|
||||
backdrop-filter: blur(14px) !important;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22) !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ function makeRouteCurve(i: number): THREE.CatmullRomCurve3 {
|
||||
* route is invalid, the brain recalculates, a charging station rises and a new
|
||||
* green optimized route lights up.
|
||||
*/
|
||||
function Routes({ progress }: Props) {
|
||||
function Routes({ progress, isMobile = false }: Props) {
|
||||
const eased = useRef(0);
|
||||
|
||||
const tubeMats = useRef<(THREE.MeshBasicMaterial | null)[]>([]);
|
||||
@@ -295,6 +295,10 @@ function Routes({ progress }: Props) {
|
||||
{labelPos.map((pos, i) => {
|
||||
const isWinner = i === WINNER;
|
||||
const isReject = i === REJECT_INDEX;
|
||||
// Mobile clarity: hide ALL floating strategy labels (Multi-Trip, EV-Aware,
|
||||
// Balanced, Best, Time-Aware …) so the small canvas shows only the routing
|
||||
// nodes/network — these screen-space labels overlap badly at 320–390px.
|
||||
if (isMobile) return null;
|
||||
const dotColor = isWinner || isReject ? C.red : ROUTE_COLORS[i];
|
||||
return (
|
||||
<Html key={`lbl${i}`} position={[pos.x, pos.y, pos.z]} center zIndexRange={[30, 0]} style={{ pointerEvents: "none" }}>
|
||||
@@ -342,13 +346,16 @@ function Routes({ progress }: Props) {
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* Recharge hub label (the "Kitchen / Recharge" in the EV paradox) */}
|
||||
<Html position={[stationPos.x, 1.7, stationPos.z]} center zIndexRange={[30, 0]} style={{ pointerEvents: "none" }}>
|
||||
<div ref={stationLabelRef} style={{ ...labelBase, border: "1px solid rgba(34,197,94,0.65)", boxShadow: "0 0 18px rgba(34,197,94,0.45)" }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: C.green, boxShadow: `0 0 8px ${C.green}` }} />
|
||||
Recharge Hub
|
||||
</div>
|
||||
</Html>
|
||||
{/* Recharge hub label (the "Kitchen / Recharge" in the EV paradox).
|
||||
Hidden on mobile to keep the small canvas free of overlapping overlays. */}
|
||||
{!isMobile && (
|
||||
<Html position={[stationPos.x, 1.7, stationPos.z]} center zIndexRange={[30, 0]} style={{ pointerEvents: "none" }}>
|
||||
<div ref={stationLabelRef} style={{ ...labelBase, border: "1px solid rgba(34,197,94,0.65)", boxShadow: "0 0 18px rgba(34,197,94,0.45)" }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: C.green, boxShadow: `0 0 8px ${C.green}` }} />
|
||||
Recharge Hub
|
||||
</div>
|
||||
</Html>
|
||||
)}
|
||||
|
||||
{/* EV scooter */}
|
||||
<group ref={scooter} visible={false}>
|
||||
|
||||
@@ -39,7 +39,10 @@ type CameraFraming = {
|
||||
const FRAMING: Record<"desktop" | "tablet" | "mobile", CameraFraming> = {
|
||||
desktop: { radiusStart: 17, radiusEnd: 13, heightStart: 9, heightEnd: 6.5, lookAtY: 2.4, fov: 50 },
|
||||
tablet: { radiusStart: 19, radiusEnd: 15, heightStart: 9.5, heightEnd: 7, lookAtY: 2.6, fov: 54 },
|
||||
mobile: { radiusStart: 22, radiusEnd: 18, heightStart: 11, heightEnd: 8, lookAtY: 3, fov: 62 },
|
||||
// Mobile: pulled ~33% closer (radius 18→12) and lower, centred on the depot
|
||||
// with a tighter fov so the route hub fills the small frame and the empty grid
|
||||
// around it is cropped out — the depot → routes → vehicles story reads at 320px.
|
||||
mobile: { radiusStart: 16, radiusEnd: 12, heightStart: 8.5, heightEnd: 6, lookAtY: 2.3, fov: 58 },
|
||||
};
|
||||
|
||||
/** Slow cinematic camera move from a high chaotic view to a settled framing. */
|
||||
|
||||
@@ -1,64 +1,11 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ScrollReveal } from "@/animations/Reveal";
|
||||
import { blogPosts } from "@/data/blog";
|
||||
|
||||
export default function BlogGrid() {
|
||||
const blogs = [
|
||||
{
|
||||
title: "How AI Is Transforming Last-Mile EV Delivery",
|
||||
excerpt: "Machine learning and real-time data are reshaping how fleets plan, dispatch, and adapt — making every kilometre smarter than the last.",
|
||||
category: "Technology",
|
||||
image: "/images/blog-post-pic-17.png",
|
||||
},
|
||||
{
|
||||
title: "The EV Paradox: Solving Range Anxiety for Urban Fleets",
|
||||
excerpt: "Electric vehicles promise sustainability, but battery constraints introduce a new routing challenge. Here's how MileTruth™ AI solves it before dispatch.",
|
||||
category: "EV Fleet",
|
||||
image: "/images/ev-paradox.png",
|
||||
},
|
||||
{
|
||||
title: "42% Less Distance: Insights from Our Hyderabad Hub",
|
||||
excerpt: "A detailed look at how Doormile's MileTruth routing engine delivered measurable efficiency gains — fewer vehicles, less fuel, and zero SLA misses.",
|
||||
category: "Case Study",
|
||||
image: "/images/blog-post-pic-15.png",
|
||||
},
|
||||
{
|
||||
title: "MileTruth™ AI — 10 Stages to Smarter Dispatch",
|
||||
excerpt: "From order ingestion to final route output in under 45ms — a technical walkthrough of the ten-stage pipeline at the heart of our routing engine.",
|
||||
category: "MileTruth",
|
||||
image: "/images/blog-post-pic-31.png",
|
||||
},
|
||||
{
|
||||
title: "Why Mathematical Precision Beats Heuristics in Routing",
|
||||
excerpt: "Most routing tools guess. We calculate. Powered by Google OR-Tools, MileTruth evaluates six parallel strategy universes to select the optimal route every time.",
|
||||
category: "Technology",
|
||||
image: "/images/blog-post-pic-14.jpeg",
|
||||
},
|
||||
{
|
||||
title: "Fleet Reduction Without Compromising Delivery Volume",
|
||||
excerpt: "Deploying 37% fewer vehicles while handling the same order volumes isn't a trade-off — it's the result of smarter routing intelligence applied at every dispatch.",
|
||||
category: "Fleet Management",
|
||||
image: "/images/blog-post-pic-8.jpeg",
|
||||
},
|
||||
{
|
||||
title: "Building a Greener City: The Future of Urban Logistics",
|
||||
excerpt: "Cities are demanding cleaner delivery. We explore how AI-powered EV fleets and optimised routing create a path to zero-emission last-mile logistics at city scale.",
|
||||
category: "Sustainability",
|
||||
image: "/images/blog-post-pic-6.jpeg",
|
||||
},
|
||||
{
|
||||
title: "How Doormile Maintains 99.9% SLA Compliance at Scale",
|
||||
excerpt: "Hitting SLA targets 99.9% of the time isn't luck — it's the product of ETA pre-validation, real-time rebalancing, and a routing engine built with delivery reliability as its first constraint.",
|
||||
category: "Operations",
|
||||
image: "/images/last-mile-approach.jpg",
|
||||
},
|
||||
{
|
||||
title: "Battery Simulation: The Secret to EV Route Pre-Validation",
|
||||
excerpt: "Before a single rider leaves the hub, MileTruth™ simulates every route against real charge capacity — eliminating mid-route failures and protecting your fulfillment rate.",
|
||||
category: "EV Fleet",
|
||||
image: "/images/blog-post-pic-3.jpeg",
|
||||
},
|
||||
];
|
||||
const blogs = blogPosts;
|
||||
|
||||
return (
|
||||
<div className="elementor-element elementor-element-c70681e e-flex e-con-boxed cut-corner-no sticky-container-off e-con e-parent" data-id="c70681e" data-element_type="container" data-e-type="container">
|
||||
@@ -135,9 +82,18 @@ export default function BlogGrid() {
|
||||
font-family: var(--font-manrope), sans-serif !important;
|
||||
}
|
||||
|
||||
/* Bottom block pinned to the card base — keeps Read More + image at the
|
||||
same vertical position across cards with different text lengths. */
|
||||
.custom-blog-bottom {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
margin-top: auto !important;
|
||||
}
|
||||
|
||||
.custom-blog-readmore {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
align-self: flex-start !important;
|
||||
gap: 6px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 800 !important;
|
||||
@@ -162,7 +118,6 @@ export default function BlogGrid() {
|
||||
aspect-ratio: 4 / 3 !important;
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden !important;
|
||||
margin-top: auto !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
@@ -191,8 +146,8 @@ export default function BlogGrid() {
|
||||
<div className="custom-blog-grid">
|
||||
|
||||
{blogs.map((blog, i) => (
|
||||
<ScrollReveal key={i} delay={(i % 3) * 0.08} duration={0.8} yOffset={35}>
|
||||
<div className="custom-blog-card">
|
||||
<ScrollReveal key={blog.slug} delay={(i % 3) * 0.08} duration={0.8} yOffset={35}>
|
||||
<Link href={`/blog/${blog.slug}`} className="custom-blog-card" style={{ textDecoration: "none" }}>
|
||||
{/* Text Block at Top */}
|
||||
<div className="flex flex-col">
|
||||
{/* Bold Title */}
|
||||
@@ -206,21 +161,46 @@ export default function BlogGrid() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Image at Bottom */}
|
||||
<div className="custom-blog-img-container">
|
||||
<Image
|
||||
src={blog.image}
|
||||
alt={blog.title}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
{/* Category Badge overlay */}
|
||||
<span className="custom-blog-badge">
|
||||
{blog.category}
|
||||
{/* Bottom block: Read more + image, pinned to the card
|
||||
base so Read More aligns across every card regardless
|
||||
of title / excerpt length. */}
|
||||
<div className="custom-blog-bottom">
|
||||
{/* Read more affordance (whole card is the link) */}
|
||||
<span className="custom-blog-readmore">
|
||||
Read More
|
||||
<svg
|
||||
className="custom-blog-readmore-arrow"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Image */}
|
||||
<div className="custom-blog-img-container">
|
||||
<Image
|
||||
src={blog.image}
|
||||
alt={blog.title}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
{/* Category Badge overlay */}
|
||||
<span className="custom-blog-badge">
|
||||
{blog.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
|
||||
|
||||
276
src/components/sections/BlogPostFooter.tsx
Normal file
276
src/components/sections/BlogPostFooter.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { ScrollReveal } from "@/animations/Reveal";
|
||||
import { getAdjacentPosts, getRelatedPosts } from "@/data/blog";
|
||||
|
||||
export default function BlogPostFooter({ slug }: { slug: string }) {
|
||||
const { prev, next } = getAdjacentPosts(slug);
|
||||
const related = getRelatedPosts(slug, 3);
|
||||
|
||||
return (
|
||||
<section className="dm-blog-footer" aria-label="More articles">
|
||||
<style dangerouslySetInnerHTML={{ __html: STYLES }} />
|
||||
|
||||
<div className="dm-blog-footer-inner">
|
||||
{/* Previous / Next */}
|
||||
{(prev || next) && (
|
||||
<nav className="dm-prevnext" aria-label="Article navigation">
|
||||
{prev ? (
|
||||
<Link href={`/blog/${prev.slug}`} className="dm-prevnext-card dm-prevnext-prev">
|
||||
<span className="dm-prevnext-thumb">
|
||||
<Image src={prev.image} alt={prev.title} fill sizes="80px" style={{ objectFit: "cover" }} />
|
||||
</span>
|
||||
<span className="dm-prevnext-text">
|
||||
<span className="dm-prevnext-label">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<line x1="19" y1="12" x2="5" y2="12" /><polyline points="12 19 5 12 12 5" />
|
||||
</svg>
|
||||
Previous
|
||||
</span>
|
||||
<span className="dm-prevnext-cat">{prev.category}</span>
|
||||
<span className="dm-prevnext-title">{prev.title}</span>
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="dm-prevnext-placeholder" />
|
||||
)}
|
||||
{next ? (
|
||||
<Link href={`/blog/${next.slug}`} className="dm-prevnext-card dm-prevnext-next">
|
||||
<span className="dm-prevnext-text">
|
||||
<span className="dm-prevnext-label">
|
||||
Next
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="dm-prevnext-cat">{next.category}</span>
|
||||
<span className="dm-prevnext-title">{next.title}</span>
|
||||
</span>
|
||||
<span className="dm-prevnext-thumb">
|
||||
<Image src={next.image} alt={next.title} fill sizes="80px" style={{ objectFit: "cover" }} />
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="dm-prevnext-placeholder" />
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Related Articles */}
|
||||
{related.length > 0 && (
|
||||
<div className="dm-related">
|
||||
<h2 className="dm-related-heading">Related Articles</h2>
|
||||
<div className="dm-related-grid">
|
||||
{related.map((post, i) => (
|
||||
<ScrollReveal key={post.slug} delay={i * 0.08} duration={0.7} yOffset={30}>
|
||||
<Link href={`/blog/${post.slug}`} className="dm-related-card">
|
||||
<div className="dm-related-img">
|
||||
<Image
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
fill
|
||||
sizes="(max-width: 700px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
<span className="dm-related-badge">{post.category}</span>
|
||||
</div>
|
||||
<div className="dm-related-body">
|
||||
<h3 className="dm-related-card-title">{post.title}</h3>
|
||||
<p className="dm-related-card-excerpt">{post.excerpt}</p>
|
||||
<span className="dm-related-readmore">
|
||||
Read More
|
||||
<svg className="dm-related-readmore-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact CTA banner */}
|
||||
<div className="dm-blog-contact-cta">
|
||||
<div className="dm-blog-contact-cta-content">
|
||||
<span className="dm-blog-contact-eyebrow">Let's talk logistics</span>
|
||||
<h2 className="dm-blog-contact-title">
|
||||
Ready to move smarter with Doormile?
|
||||
</h2>
|
||||
<p className="dm-blog-contact-sub">
|
||||
Tell us about your fleet and routes — we'll show you where the
|
||||
distance, vehicles and emissions are hiding.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/contact" className="dm-blog-contact-btn">
|
||||
Get in Touch
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const STYLES = `
|
||||
.dm-blog-footer {
|
||||
font-family: var(--font-manrope), sans-serif; --dm-red: #c01227; --dm-red-hover: #e31d32;
|
||||
/* The global theme applies 72px top/bottom section padding — strip it so the
|
||||
inner container is the single source of vertical rhythm (no double gap). */
|
||||
padding: 0 !important;
|
||||
}
|
||||
/* Neutralize the global theme's 120/80/60px UPPERCASE heading rules */
|
||||
.dm-blog-footer :where(h1, h2, h3, h4, h5, h6) {
|
||||
font-family: var(--font-manrope), sans-serif !important;
|
||||
text-transform: none !important;
|
||||
font-style: normal !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
/* Neutralize the theme's .elementor-kit-5 a (red color + underline) */
|
||||
.dm-blog-footer a { text-decoration: none !important; }
|
||||
/* Shared content container — mirrors SingleBlog's .dm-blog-wrap (same 1280px
|
||||
max-width + 20→40px horizontal padding) so Prev/Next, Related and the CTA
|
||||
align to the exact same left/right edges as the article body above.
|
||||
Vertical rhythm: ~64px from the article end to the Prev/Next divider, then a
|
||||
consistent ~64–72px section→section gap (no 120px+ voids). */
|
||||
.dm-blog-footer-inner {
|
||||
max-width: 1280px; margin: 0 auto;
|
||||
/* Compact vertical rhythm on an 8px system. Top padding sets the
|
||||
article→Prev/Next gap (~24–32px); the inter-section gap sets the
|
||||
Prev/Next→Related gap (~32–48px). No large arbitrary voids. */
|
||||
/* Minimal bottom padding — the global site footer already contributes its
|
||||
own 20px top inset, so the CTA banner sits close to it without a void. */
|
||||
padding: clamp(24px, 3vw, 32px) clamp(20px, 4vw, 40px) clamp(8px, 1.5vw, 16px);
|
||||
display: flex; flex-direction: column; gap: clamp(32px, 4vw, 48px);
|
||||
}
|
||||
|
||||
/* Prev / Next */
|
||||
.dm-prevnext {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
|
||||
/* Halved from 40px: tight divider→cards spacing without crowding. */
|
||||
padding-top: clamp(16px, 2vw, 24px); border-top: 1px solid rgba(15,23,42,0.08);
|
||||
}
|
||||
@media (max-width: 640px) { .dm-prevnext { grid-template-columns: 1fr; } }
|
||||
.dm-prevnext-placeholder { display: block; }
|
||||
.dm-prevnext-card {
|
||||
display: flex; gap: 16px; align-items: center; padding: 16px;
|
||||
background: #fff; border: 1px solid rgba(15,23,42,0.09); border-radius: 22px;
|
||||
text-decoration: none; transition: transform .3s ease, box-shadow .3s ease, border-color .3s ease;
|
||||
}
|
||||
.dm-prevnext-card:hover {
|
||||
transform: translateY(-4px); border-color: rgba(192,18,39,0.2);
|
||||
box-shadow: 0 16px 34px rgba(192,18,39,0.10);
|
||||
}
|
||||
.dm-prevnext-thumb {
|
||||
position: relative; flex: 0 0 80px; width: 80px; height: 80px;
|
||||
border-radius: 16px; overflow: hidden; background: #f1f5f9;
|
||||
}
|
||||
.dm-prevnext-text { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||
.dm-prevnext-next { text-align: right; }
|
||||
.dm-prevnext-next .dm-prevnext-text { align-items: flex-end; }
|
||||
.dm-prevnext-label {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 1px; color: var(--dm-red);
|
||||
}
|
||||
.dm-prevnext-cat { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.dm-prevnext-title {
|
||||
font-size: 15.5px; font-weight: 700; color: #1e293b; line-height: 1.4;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
transition: color .2s ease;
|
||||
}
|
||||
.dm-prevnext-card:hover .dm-prevnext-title { color: var(--dm-red); }
|
||||
|
||||
/* Related */
|
||||
.dm-related-heading {
|
||||
font-size: clamp(22px, 2.2vw, 28px) !important; font-weight: 850 !important; letter-spacing: -.4px !important;
|
||||
line-height: 1.25 !important; color: #0f172a !important; margin: 0 0 24px;
|
||||
}
|
||||
.dm-related-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 28px;
|
||||
}
|
||||
@media (max-width: 1024px) { .dm-related-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 700px) { .dm-related-grid { grid-template-columns: 1fr; gap: 24px; } }
|
||||
|
||||
.dm-related-card {
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
background: #fff; border: 1px solid rgba(15,23,42,0.09); border-radius: 22px;
|
||||
overflow: hidden; box-shadow: 0 4px 24px rgba(15,23,42,0.05); text-decoration: none;
|
||||
transition: transform .4s cubic-bezier(0.2,0.8,0.2,1), box-shadow .4s ease, border-color .4s ease;
|
||||
}
|
||||
.dm-related-card:hover {
|
||||
transform: translateY(-8px); box-shadow: 0 22px 44px rgba(192,18,39,0.13);
|
||||
border-color: rgba(192,18,39,0.2);
|
||||
}
|
||||
.dm-related-img {
|
||||
position: relative; width: 100%; aspect-ratio: 16 / 10; overflow: hidden; background: #f1f5f9;
|
||||
}
|
||||
.dm-related-img img { transition: transform .5s cubic-bezier(0.2,0.8,0.2,1); }
|
||||
.dm-related-card:hover .dm-related-img img { transform: scale(1.05); }
|
||||
.dm-related-badge {
|
||||
position: absolute; top: 14px; left: 14px; z-index: 5; background: var(--dm-red); color: #fff;
|
||||
font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: 1.2px;
|
||||
padding: 5px 11px; border-radius: 8px; box-shadow: 0 4px 12px rgba(192,18,39,0.25);
|
||||
}
|
||||
.dm-related-body { display: flex; flex-direction: column; flex: 1; padding: 22px; }
|
||||
.dm-related-card-title {
|
||||
font-size: 17px !important; font-weight: 800 !important; color: #1e293b !important; line-height: 1.4 !important;
|
||||
letter-spacing: -.2px !important; margin: 0 0 10px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
transition: color .2s ease;
|
||||
}
|
||||
.dm-related-card:hover .dm-related-card-title { color: var(--dm-red); }
|
||||
.dm-related-card-excerpt {
|
||||
font-size: 13.5px; font-weight: 500; color: #64748b; line-height: 1.6; margin: 0 0 18px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.dm-related-readmore {
|
||||
margin-top: auto; display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 12.5px; font-weight: 800; color: var(--dm-red);
|
||||
text-transform: uppercase; letter-spacing: .6px;
|
||||
}
|
||||
.dm-related-readmore-arrow { transition: transform .3s cubic-bezier(0.2,0.8,0.2,1); }
|
||||
.dm-related-card:hover .dm-related-readmore-arrow { transform: translateX(5px); }
|
||||
|
||||
/* Contact CTA banner */
|
||||
.dm-blog-contact-cta {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d1417 100%);
|
||||
border-radius: 30px; padding: clamp(32px, 4vw, 56px);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.dm-blog-contact-cta::after {
|
||||
content: ""; position: absolute; right: -80px; top: -80px; width: 300px; height: 300px;
|
||||
background: radial-gradient(circle, rgba(192,18,39,0.40), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.dm-blog-contact-cta-content { position: relative; z-index: 1; max-width: 640px; }
|
||||
.dm-blog-contact-eyebrow {
|
||||
display: inline-block; font-size: 12px; font-weight: 800; text-transform: uppercase;
|
||||
letter-spacing: 1.4px; color: #ff8088; margin-bottom: 14px;
|
||||
}
|
||||
.dm-blog-contact-title {
|
||||
font-size: clamp(22px, 2.2vw, 28px) !important; font-weight: 800 !important; line-height: 1.25 !important;
|
||||
letter-spacing: -.3px !important; color: #ffffff !important; margin: 0 0 12px; text-wrap: balance;
|
||||
}
|
||||
.dm-blog-contact-sub {
|
||||
font-size: 15.5px; line-height: 1.65; color: #e2e2e2; margin: 0; font-weight: 450;
|
||||
}
|
||||
.dm-blog-contact-btn {
|
||||
position: relative; z-index: 1; flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
|
||||
background: var(--dm-red); color: #fff !important; font-size: 15px; font-weight: 700;
|
||||
padding: 16px 32px; border-radius: 16px; text-decoration: none;
|
||||
box-shadow: 0 10px 26px rgba(192,18,39,0.34);
|
||||
transition: background .2s ease, transform .2s ease;
|
||||
}
|
||||
.dm-blog-contact-btn:hover { background: var(--dm-red-hover); transform: translateY(-2px); }
|
||||
@media (max-width: 720px) {
|
||||
.dm-blog-contact-cta { flex-direction: column; align-items: flex-start; gap: 26px; }
|
||||
.dm-blog-contact-btn { width: 100%; }
|
||||
}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -315,8 +315,8 @@ export default function HowItWorksHero() {
|
||||
<div className="slider-footer slider-footer-position-after slider-footer-width-full slider-footer-view-inside">
|
||||
<div className="slider-footer-content">
|
||||
<div className="slider-pagination" style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: "10px" }}>
|
||||
<div className="slider-progress-wrapper" style={{ marginRight: "15px", display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
||||
<div style={{ marginBottom: "4px" }}>
|
||||
<div className="slider-progress-wrapper" style={{ marginRight: "35px", display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
||||
<div style={{ fontSize: "16px", fontWeight: 600, color: "#FFFFFF", marginBottom: "4px" }}>
|
||||
<span className="slider-progress-current">{activeSlide === 0 ? "01" : "02"}</span>
|
||||
{" / "}
|
||||
<span className="slider-progress-all" style={{ opacity: 0.6 }}>02</span>
|
||||
@@ -333,7 +333,7 @@ export default function HowItWorksHero() {
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="owl-dots owl-dots-6c7cbcb" style={{ display: "flex", gap: "8px" }}>
|
||||
<div className="owl-dots owl-dots-6c7cbcb" style={{ display: "none" }}>
|
||||
<button
|
||||
type="button"
|
||||
role="button"
|
||||
|
||||
472
src/components/sections/SingleBlog.tsx
Normal file
472
src/components/sections/SingleBlog.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { ScrollReveal } from "@/animations/Reveal";
|
||||
import BlogSidebar from "@/components/blog/BlogSidebar";
|
||||
import {
|
||||
type BlogPost,
|
||||
type ContentBlock,
|
||||
formatDate,
|
||||
estimateReadingTime,
|
||||
} from "@/data/blog";
|
||||
|
||||
function ContentRenderer({ block }: { block: ContentBlock }) {
|
||||
switch (block.type) {
|
||||
case "paragraph":
|
||||
return <p className="dm-article-p">{block.text}</p>;
|
||||
case "heading":
|
||||
return block.level === 2 ? (
|
||||
<h2 className="dm-article-h2">{block.text}</h2>
|
||||
) : (
|
||||
<h3 className="dm-article-h3">{block.text}</h3>
|
||||
);
|
||||
case "list":
|
||||
return block.ordered ? (
|
||||
<ol className="dm-article-ol">
|
||||
{block.items.map((it, i) => (
|
||||
<li key={i}>{it}</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<ul className="dm-article-ul">
|
||||
{block.items.map((it, i) => (
|
||||
<li key={i}>{it}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "quote":
|
||||
return (
|
||||
<blockquote className="dm-article-quote">
|
||||
<p>{block.text}</p>
|
||||
{block.cite && <cite>— {block.cite}</cite>}
|
||||
</blockquote>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<figure className="dm-article-figure">
|
||||
<span className="dm-article-figure-img">
|
||||
<Image
|
||||
src={block.src}
|
||||
alt={block.alt}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 760px"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
</span>
|
||||
{block.caption && <figcaption>{block.caption}</figcaption>}
|
||||
</figure>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SingleBlog({ post }: { post: BlogPost }) {
|
||||
const readingTime = estimateReadingTime(post);
|
||||
|
||||
// Banner background: a subtle dark overlay over the article's featured image,
|
||||
// light enough to let the photograph remain the primary visual element.
|
||||
const bannerStyle = {
|
||||
backgroundImage: `url(${post.image})`,
|
||||
"--hero-overlay":
|
||||
"linear-gradient(180deg, rgba(0,0,0,0.38) 0%, rgba(0,0,0,0.46) 55%, rgba(0,0,0,0.60) 100%)",
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<article className="dm-single-blog">
|
||||
<style dangerouslySetInnerHTML={{ __html: STYLES }} />
|
||||
|
||||
{/* ── Full-width page banner (image + badge + title only) ──────── */}
|
||||
<div className="custom-standard-hero-container">
|
||||
<div className="custom-standard-hero-card dm-banner-card" style={bannerStyle}>
|
||||
<div className="e-con-inner dm-banner-inner">
|
||||
<span className="dm-banner-category">{post.category}</span>
|
||||
<h1 className="dm-banner-title">{post.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Article meta bar (breadcrumb + author / date / reading time) ── */}
|
||||
<div className="dm-blog-wrap">
|
||||
<div className="dm-meta-bar">
|
||||
<nav className="dm-meta-breadcrumb" aria-label="Breadcrumb">
|
||||
<ol>
|
||||
<li>
|
||||
<Link href="/">Home</Link>
|
||||
</li>
|
||||
<li aria-hidden="true" className="dm-meta-sep">/</li>
|
||||
<li>
|
||||
<Link href="/blog">Blog</Link>
|
||||
</li>
|
||||
<li aria-hidden="true" className="dm-meta-sep">/</li>
|
||||
<li aria-current="page" className="dm-meta-current">
|
||||
{post.title}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div className="dm-meta-items">
|
||||
<span className="dm-meta-item dm-meta-author">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
{post.author}
|
||||
</span>
|
||||
<span className="dm-meta-item">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<time dateTime={post.date}>{formatDate(post.date)}</time>
|
||||
</span>
|
||||
<span className="dm-meta-item">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" /><polyline points="12 7 12 12 15 14" />
|
||||
</svg>
|
||||
{readingTime} min read
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content + sidebar ──────────────────────────────────────── */}
|
||||
<div className="dm-blog-layout">
|
||||
<div className="dm-blog-main">
|
||||
<p className="dm-blog-intro">{post.intro}</p>
|
||||
|
||||
<div className="dm-article-body">
|
||||
{post.content.map((block, i) => (
|
||||
<ScrollReveal key={i} delay={0} duration={0.6} yOffset={20}>
|
||||
<ContentRenderer block={block} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dm-blog-aside-wrap">
|
||||
<BlogSidebar current={post} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const STYLES = `
|
||||
.dm-single-blog {
|
||||
--dm-red: #c01227;
|
||||
--dm-red-hover: #e31d32;
|
||||
--dm-radius-card: 22px;
|
||||
--dm-radius-img: 20px;
|
||||
--dm-radius-badge: 8px;
|
||||
--dm-shadow-card: 0 4px 24px rgba(15, 23, 42, 0.05);
|
||||
--dm-border: 1px solid rgba(15, 23, 42, 0.09);
|
||||
--dm-space-p: 24px;
|
||||
--dm-space-h: 32px;
|
||||
--dm-space-img: 32px;
|
||||
--dm-space-quote: 40px;
|
||||
--dm-sticky-top: 138px;
|
||||
--dm-measure: min(1100px, 100%);
|
||||
font-family: var(--font-manrope), sans-serif;
|
||||
}
|
||||
|
||||
/* Heading normalization — beat the global theme's .elementor-kit-5 h1–h6
|
||||
(120/80/60px UPPERCASE) rules with !important on our own classes. */
|
||||
.dm-single-blog :where(h1, h2, h3, h4, h5, h6) {
|
||||
font-family: var(--font-manrope), sans-serif !important;
|
||||
text-transform: none !important;
|
||||
font-style: normal !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
/* Neutralize the theme's .elementor-kit-5 a (red color + underline,
|
||||
specificity 0-1-1) so blog links keep our colors and never get underlined. */
|
||||
.dm-single-blog a { text-decoration: none !important; }
|
||||
|
||||
/* ── Page banner — tall (homepage-scale); only badge + title inside ── */
|
||||
/* Compound selector (specificity 20) + !important beats the global 800px
|
||||
single-class height rules so the blog banner can use viewport heights. */
|
||||
.custom-standard-hero-card.dm-banner-card {
|
||||
height: 90vh !important;
|
||||
min-height: 85vh !important;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.custom-standard-hero-card.dm-banner-card { height: 80vh !important; min-height: 75vh !important; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.custom-standard-hero-card.dm-banner-card { height: 78vh !important; min-height: 72vh !important; }
|
||||
}
|
||||
|
||||
.dm-banner-inner {
|
||||
position: relative; width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
text-align: center; gap: clamp(22px, 2.6vw, 34px);
|
||||
padding: clamp(48px, 8vh, 96px) clamp(20px, 5vw, 48px);
|
||||
}
|
||||
|
||||
.dm-banner-category {
|
||||
display: inline-block; background: var(--dm-red); color: #fff;
|
||||
font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 1.6px;
|
||||
padding: 9px 18px; border-radius: 999px; box-shadow: 0 8px 22px rgba(192,18,39,0.45);
|
||||
}
|
||||
.dm-banner-title {
|
||||
font-family: var(--font-manrope), sans-serif !important;
|
||||
font-size: clamp(34px, 5vw, 60px) !important; font-weight: 850 !important;
|
||||
line-height: 1.16 !important; letter-spacing: -1.2px !important;
|
||||
color: #ffffff !important; margin: 0; max-width: 820px;
|
||||
text-wrap: balance; text-shadow: 0 2px 30px rgba(0,0,0,0.38);
|
||||
}
|
||||
@media (max-width: 1024px) { .dm-banner-title { font-size: clamp(32px, 6vw, 48px) !important; max-width: 90%; } }
|
||||
@media (max-width: 600px) { .dm-banner-title { font-size: clamp(28px, 8vw, 38px) !important; max-width: 90%; } }
|
||||
|
||||
/* ── Content wrap — begins immediately below the banner ── */
|
||||
/* Shared content container: the SAME max-width + horizontal padding is used
|
||||
by BlogPostFooter (.dm-blog-footer-inner) so the article body, headings,
|
||||
images, Prev/Next, Related Articles and the CTA banner all align to one
|
||||
grid with identical left/right edges. Keep both in sync. */
|
||||
.dm-blog-wrap {
|
||||
max-width: 1280px; margin: 0 auto;
|
||||
/* 20px mobile padding floor → 40px on desktop; matches the footer container. */
|
||||
padding: clamp(14px, 2vw, 26px) clamp(20px, 4vw, 40px) 0;
|
||||
}
|
||||
|
||||
/* ── Article meta bar (directly under the hero) ── */
|
||||
.dm-meta-bar {
|
||||
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px 24px;
|
||||
padding: clamp(20px, 2.6vw, 30px) 0; margin-bottom: clamp(26px, 3vw, 44px);
|
||||
border-bottom: 1px solid rgba(15,23,42,0.10);
|
||||
}
|
||||
.dm-meta-breadcrumb ol {
|
||||
list-style: none; display: flex; flex-wrap: wrap; align-items: center;
|
||||
gap: 8px; margin: 0; padding: 0; font-size: 13px; font-weight: 600; color: #64748b;
|
||||
}
|
||||
.dm-meta-breadcrumb a { color: #64748b !important; text-decoration: none; transition: color .2s ease; }
|
||||
.dm-meta-breadcrumb a:hover { color: var(--dm-red) !important; }
|
||||
.dm-meta-sep { color: #cbd5e1; }
|
||||
.dm-meta-current {
|
||||
color: #0f172a; font-weight: 700;
|
||||
max-width: min(40ch, 46vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 600px) { .dm-meta-current { max-width: 56vw; } }
|
||||
.dm-meta-items { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; }
|
||||
.dm-meta-item {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 13.5px; font-weight: 600; color: #475569;
|
||||
}
|
||||
.dm-meta-item svg { color: var(--dm-red); flex: 0 0 auto; }
|
||||
.dm-meta-author { color: #0f172a; font-weight: 700; }
|
||||
@media (max-width: 600px) { .dm-meta-bar { gap: 12px; } }
|
||||
.dm-blog-layout {
|
||||
display: grid; grid-template-columns: minmax(0,1fr) 320px;
|
||||
/* Tighter gap gives the reading column more room next to the 320px sidebar. */
|
||||
gap: clamp(28px, 3vw, 56px); align-items: start;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.dm-blog-layout { grid-template-columns: 1fr; gap: 48px; }
|
||||
/* Single-column (tablet/mobile): cap the reading column and centre it so the
|
||||
article never sits left-aligned with a large empty right gutter. */
|
||||
.dm-blog-main { max-width: 900px; margin-inline: auto; }
|
||||
}
|
||||
.dm-blog-main { min-width: 0; }
|
||||
|
||||
/* ── Intro lead ── */
|
||||
.dm-blog-intro {
|
||||
max-width: var(--dm-measure);
|
||||
font-size: clamp(18px, 1.5vw, 20px); line-height: 1.65; font-weight: 500;
|
||||
color: #475569; margin: 0 0 clamp(26px, 3vw, 38px); padding-left: 20px;
|
||||
border-left: 3px solid var(--dm-red);
|
||||
}
|
||||
|
||||
/* ── Article body ── */
|
||||
.dm-article-body { max-width: var(--dm-measure); }
|
||||
.dm-article-p {
|
||||
font-size: 18px !important; line-height: 1.8 !important; color: #334155; font-weight: 450;
|
||||
margin: 0 0 var(--dm-space-p);
|
||||
}
|
||||
.dm-article-h2 {
|
||||
font-size: clamp(23px, 2vw, 30px) !important; font-weight: 800 !important; letter-spacing: -.3px !important;
|
||||
color: #0f172a !important; line-height: 1.3 !important; margin: 48px 0 var(--dm-space-h);
|
||||
text-wrap: balance;
|
||||
}
|
||||
/* Each article block is wrapped in its OWN ScrollReveal <div>, so a bare
|
||||
:first-child rule matched every heading (each is the only child of its
|
||||
wrapper) and zeroed its top margin — collapsing the gap above every
|
||||
section heading. Scope the reset to only the article body's first block. */
|
||||
.dm-article-body > :first-child :where(.dm-article-h2, .dm-article-h3),
|
||||
.dm-article-body > .dm-article-h2:first-child,
|
||||
.dm-article-body > .dm-article-h3:first-child { margin-top: 0; }
|
||||
.dm-article-h3 {
|
||||
font-size: clamp(19px, 1.5vw, 23px) !important; font-weight: 800 !important; letter-spacing: -.2px !important;
|
||||
color: #1e293b !important; line-height: 1.34 !important; margin: 40px 0 18px;
|
||||
text-wrap: balance;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.dm-article-p { font-size: 16px !important; }
|
||||
.dm-article-ul li, .dm-article-ol li { font-size: 15.5px; }
|
||||
.dm-article-h2 { font-size: clamp(22px, 6vw, 28px) !important; margin-top: 38px; }
|
||||
.dm-article-h3 { font-size: clamp(18px, 5vw, 22px) !important; }
|
||||
}
|
||||
|
||||
.dm-article-ul, .dm-article-ol {
|
||||
/* Top margin (was 0) separates the list from the paragraph above it;
|
||||
matching bottom margin keeps it clear of the next heading/paragraph.
|
||||
Slightly larger than --dm-space-p so the list reads as its own block. */
|
||||
margin: clamp(26px, 2.4vw, 32px) 0 clamp(26px, 2.4vw, 32px);
|
||||
padding-left: 2px; list-style: none;
|
||||
display: flex; flex-direction: column; gap: 15px;
|
||||
}
|
||||
/* Prefixed with .dm-article-body so these beat the theme's global
|
||||
".logico-front-end ul li:before" fontello-glyph bullet (specificity 0,1,3),
|
||||
which otherwise replaces our clean red dot with a misaligned checkmark glyph
|
||||
and adds its own 1.7em indent. */
|
||||
.dm-article-body .dm-article-ul li, .dm-article-body .dm-article-ol li {
|
||||
position: relative; padding-left: 34px;
|
||||
font-size: 17px; line-height: 1.65; color: #334155; font-weight: 450;
|
||||
}
|
||||
.dm-article-body .dm-article-ul li::before {
|
||||
content: ""; position: absolute; left: 7px; top: 10px;
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--dm-red);
|
||||
/* kill any inherited fontello glyph from the global rule */
|
||||
font-size: 0; line-height: 0;
|
||||
}
|
||||
.dm-article-ol { counter-reset: dm-li; }
|
||||
.dm-article-body .dm-article-ol li { counter-increment: dm-li; }
|
||||
.dm-article-body .dm-article-ol li::before {
|
||||
content: counter(dm-li); position: absolute; left: 0; top: 0;
|
||||
width: 24px; height: 24px; border-radius: 7px;
|
||||
background: rgba(192,18,39,0.10); color: var(--dm-red);
|
||||
font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Quote ── */
|
||||
.dm-article-quote {
|
||||
position: relative; margin: var(--dm-space-quote) 0; padding: 30px 32px 30px 64px;
|
||||
background: linear-gradient(135deg, #fbf2f3 0%, #fdf7f8 100%);
|
||||
border-left: 4px solid var(--dm-red); border-radius: 0 18px 18px 0;
|
||||
box-shadow: 0 8px 28px rgba(192, 18, 39, 0.06);
|
||||
}
|
||||
.dm-article-quote::before {
|
||||
content: "\\201C"; position: absolute; left: 22px; top: 8px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 64px; line-height: 1; color: rgba(192, 18, 39, 0.28); font-weight: 700;
|
||||
}
|
||||
.dm-article-quote p {
|
||||
font-size: clamp(19px, 2vw, 23px); line-height: 1.5; font-weight: 600;
|
||||
font-style: italic; color: #1e293b; margin: 0;
|
||||
}
|
||||
.dm-article-quote cite {
|
||||
display: block; margin-top: 16px; font-size: 13px; font-weight: 700;
|
||||
font-style: normal; color: var(--dm-red); text-transform: uppercase; letter-spacing: .6px;
|
||||
}
|
||||
@media (max-width: 600px) { .dm-article-quote { padding: 26px 22px; } .dm-article-quote::before { display: none; } }
|
||||
|
||||
/* ── Images ── */
|
||||
.dm-article-figure { margin: var(--dm-space-img) 0; }
|
||||
.dm-article-figure-img {
|
||||
position: relative; display: block; width: 100%; aspect-ratio: 16 / 9;
|
||||
border-radius: var(--dm-radius-img); overflow: hidden; box-shadow: 0 14px 34px rgba(15,23,42,0.10);
|
||||
}
|
||||
.dm-article-figure figcaption {
|
||||
margin-top: 14px; font-size: 13.5px; color: #94a3b8; font-weight: 500;
|
||||
text-align: center; font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.dm-blog-aside-wrap { position: relative; }
|
||||
.dm-blog-sidebar {
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
position: sticky; top: var(--dm-sticky-top);
|
||||
}
|
||||
@media (max-width: 1024px) { .dm-blog-sidebar { position: static; gap: 20px; } }
|
||||
|
||||
.dm-blog-widget {
|
||||
background: #fff; border: var(--dm-border); border-radius: var(--dm-radius-card);
|
||||
padding: 20px; box-shadow: var(--dm-shadow-card);
|
||||
}
|
||||
.dm-blog-widget-title {
|
||||
font-size: 13px !important; font-weight: 700 !important; text-transform: uppercase !important;
|
||||
letter-spacing: .8px !important; line-height: 1.35 !important; color: #0f172a !important;
|
||||
margin: 0 0 15px; padding: 0 0 12px;
|
||||
border-bottom: 1px solid rgba(15,23,42,0.08);
|
||||
overflow: visible; white-space: normal; word-break: normal;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.dm-sr-only {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
|
||||
}
|
||||
.dm-blog-search { position: relative; }
|
||||
.dm-blog-search-form { position: relative; }
|
||||
.dm-blog-search-input {
|
||||
width: 100%; height: 46px; border: 1.5px solid #e2e8f0; border-radius: 12px;
|
||||
padding: 0 42px 0 15px; font-size: 14px; font-family: inherit; color: #0f172a;
|
||||
background: #f8fafc; transition: border-color .2s ease, box-shadow .2s ease, background .2s ease;
|
||||
}
|
||||
.dm-blog-search-input::placeholder { color: #9c9c9c; }
|
||||
.dm-blog-search-input:focus {
|
||||
outline: none; border-color: var(--dm-red); background: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(192,18,39,0.11);
|
||||
}
|
||||
.dm-blog-search-icon {
|
||||
position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
|
||||
color: #94a3b8; pointer-events: none; display: flex;
|
||||
}
|
||||
.dm-blog-search-results {
|
||||
position: absolute; z-index: 20; top: calc(100% + 8px); left: 0; right: 0;
|
||||
background: #fff; border: 1px solid rgba(15,23,42,0.10); border-radius: 14px;
|
||||
box-shadow: 0 18px 42px rgba(15,23,42,0.14); overflow: hidden;
|
||||
}
|
||||
.dm-blog-search-results ul { list-style: none; margin: 0; padding: 6px; }
|
||||
.dm-blog-search-result {
|
||||
display: flex; flex-direction: column; gap: 3px; padding: 10px 12px;
|
||||
border-radius: 10px; text-decoration: none; transition: background .15s ease;
|
||||
}
|
||||
.dm-blog-search-result:hover { background: #f8fafc; }
|
||||
.dm-blog-search-result-cat {
|
||||
font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .8px; color: var(--dm-red);
|
||||
}
|
||||
.dm-blog-search-result-title { font-size: 13.5px; font-weight: 600; color: #1e293b; line-height: 1.35; }
|
||||
.dm-blog-search-empty { margin: 0; padding: 14px 12px; font-size: 13px; color: #64748b; }
|
||||
|
||||
/* Recent posts */
|
||||
.dm-blog-recent { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 15px; }
|
||||
.dm-blog-recent-item { display: flex; gap: 13px; align-items: center; text-decoration: none; }
|
||||
.dm-blog-recent-thumb {
|
||||
position: relative; flex: 0 0 62px; width: 62px; height: 62px;
|
||||
border-radius: 13px; overflow: hidden; background: #f1f5f9;
|
||||
}
|
||||
.dm-blog-recent-meta { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.dm-blog-recent-title {
|
||||
font-size: 13.5px; font-weight: 700; color: #1e293b; line-height: 1.35;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
transition: color .2s ease;
|
||||
}
|
||||
.dm-blog-recent-item:hover .dm-blog-recent-title { color: var(--dm-red); }
|
||||
.dm-blog-recent-date { font-size: 11.5px; font-weight: 600; color: #94a3b8; }
|
||||
|
||||
/* Categories */
|
||||
.dm-blog-categories { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
||||
.dm-blog-category-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
padding: 11px 2px; text-decoration: none; font-size: 14px; font-weight: 600; color: #334155 !important;
|
||||
border-bottom: 1px solid rgba(15,23,42,0.06); transition: color .2s ease, padding-left .2s ease;
|
||||
}
|
||||
.dm-blog-categories li:last-child .dm-blog-category-item { border-bottom: none; }
|
||||
.dm-blog-category-item:hover { color: var(--dm-red); padding-left: 6px; }
|
||||
.dm-blog-category-item > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dm-blog-category-count {
|
||||
flex: 0 0 auto; font-size: 11px; font-weight: 800; color: #94a3b8; background: #f1f5f9;
|
||||
min-width: 24px; height: 22px; border-radius: 7px; display: inline-flex;
|
||||
align-items: center; justify-content: center; padding: 0 7px;
|
||||
}
|
||||
|
||||
/* CTA card */
|
||||
.dm-blog-cta-card { background: #1f1f1f; border-color: #1f1f1f; }
|
||||
.dm-blog-cta-title { font-size: 18px !important; font-weight: 800 !important; color: #fff !important; line-height: 1.32 !important; margin: 0 0 10px; letter-spacing: -.2px !important; }
|
||||
.dm-blog-cta-text { font-size: 13.5px; line-height: 1.6; color: #c7c7c7; margin: 0 0 20px; font-weight: 450; }
|
||||
.dm-blog-cta-btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: var(--dm-red); color: #fff !important; font-size: 13.5px; font-weight: 700;
|
||||
padding: 12px 22px; border-radius: 12px; text-decoration: none;
|
||||
transition: background .2s ease, transform .2s ease;
|
||||
}
|
||||
.dm-blog-cta-btn:hover { background: var(--dm-red-hover); transform: translateY(-2px); }
|
||||
`;
|
||||
@@ -284,8 +284,8 @@ export default function SolutionsHero() {
|
||||
<div className="slider-footer slider-footer-position-after slider-footer-width-full slider-footer-view-inside">
|
||||
<div className="slider-footer-content">
|
||||
<div className="slider-pagination" style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: "10px" }}>
|
||||
<div className="slider-progress-wrapper" style={{ marginRight: "15px", display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
||||
<div style={{ marginBottom: "4px" }}>
|
||||
<div className="slider-progress-wrapper" style={{ marginRight: "35px", display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
||||
<div style={{ fontSize: "16px", fontWeight: 600, color: "#FFFFFF", marginBottom: "4px" }}>
|
||||
<span className="slider-progress-current">{activeSlide === 0 ? "01" : "02"}</span>
|
||||
{" / "}
|
||||
<span className="slider-progress-all" style={{ opacity: 0.6 }}>02</span>
|
||||
@@ -302,7 +302,7 @@ export default function SolutionsHero() {
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="owl-dots owl-dots-6c7cbcb" style={{ display: "flex", gap: "8px" }}>
|
||||
<div className="owl-dots owl-dots-6c7cbcb" style={{ display: "none" }}>
|
||||
<button
|
||||
type="button"
|
||||
role="button"
|
||||
|
||||
@@ -1,267 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import OptimizationSection from "../optimization/OptimizationSection";
|
||||
import React from "react";
|
||||
import EVSection, { EVStat, EVBadge, EVSlide, EVCardsTheme } from "./EVSection";
|
||||
import WorkflowScene from "./WorkflowScene";
|
||||
|
||||
/* Cyan / electric-blue — matches the Optimization Engine scene palette. */
|
||||
const THEME: EVCardsTheme = {
|
||||
accent: "#00E5FF",
|
||||
accent2: "#3B82F6",
|
||||
glow: "rgba(0,229,255,0.22)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow 1 — Performance (hybrid split-screen).
|
||||
*
|
||||
* Keeps the premium EVSection chrome (banner → floating card → dark section →
|
||||
* stat bar) but converts the body into a split layout:
|
||||
* • Left — the PRODUCTION Optimization Engine Three.js scene (the same
|
||||
* OptimizationCanvas used by OptimizationSection: depot, trucks,
|
||||
* route optimization, shaders, particles). One instance, mounted
|
||||
* compactly instead of as a multi-viewport pinned scroll.
|
||||
* • Right — lightweight auto-rotating cards (4s / 600ms fade+slide).
|
||||
*
|
||||
* This preserves the 3D storytelling while dramatically cutting page height.
|
||||
*/
|
||||
const SLIDES: EVSlide[] = [
|
||||
{
|
||||
status: "Optimization Running",
|
||||
title: "Route Optimization",
|
||||
value: 42,
|
||||
suffix: "%",
|
||||
metricLabel: "Distance Saved",
|
||||
kpis: ["Route optimization active", "37% fewer vehicles required", "SLA compliance 99.9%"],
|
||||
desc: "AI selects the most efficient delivery paths across every zone, cutting unnecessary travel and fuel and battery consumption.",
|
||||
},
|
||||
{
|
||||
status: "Fleet Balancing",
|
||||
title: "Distance Reduction",
|
||||
value: 37,
|
||||
suffix: "%",
|
||||
metricLabel: "Fewer Vehicles",
|
||||
kpis: ["Load balancing engaged", "Same volume, leaner fleet", "Lower maintenance & staffing"],
|
||||
desc: "Intelligent load balancing fulfils the same order volume with a leaner, better-utilised fleet — fewer miles, fewer vehicles.",
|
||||
},
|
||||
{
|
||||
status: "Dispatch Active",
|
||||
title: "Fleet Efficiency",
|
||||
value: 31,
|
||||
suffix: "%",
|
||||
metricLabel: "Lower Operating Cost",
|
||||
kpis: ["Higher fleet utilisation", "Predictable operations", "Reduced fuel & overhead"],
|
||||
desc: "Smart grouping and dispatch keep operations smooth and predictable while reducing maintenance and staffing cost.",
|
||||
},
|
||||
{
|
||||
status: "SLA Safe",
|
||||
title: "SLA Performance",
|
||||
value: 99.9,
|
||||
decimals: 1,
|
||||
suffix: "%",
|
||||
metricLabel: "On-Time Delivery",
|
||||
kpis: ["Real-time route correction", "Consistent delivery windows", "100% order fulfilment"],
|
||||
desc: "Real-time routing keeps deliveries on time across all zones, sustaining high customer satisfaction and SLA performance.",
|
||||
},
|
||||
];
|
||||
|
||||
const BADGES: EVBadge[] = [
|
||||
{ value: "-42%", label: "DISTANCE SAVED" },
|
||||
{ value: "-37%", label: "FEWER VEHICLES" },
|
||||
];
|
||||
|
||||
const STATS: EVStat[] = [
|
||||
{ value: 42, suffix: "%", label: "Distance Saved" },
|
||||
{ value: 28, suffix: "%", label: "Faster Routes" },
|
||||
{ value: 31, suffix: "%", label: "Lower Cost" },
|
||||
{ value: 99.9, decimals: 1, suffix: "%", label: "On-Time" },
|
||||
];
|
||||
|
||||
export default function Workflow1() {
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [inView, setInView] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const slides = [
|
||||
{
|
||||
title: "PERFORMANCE",
|
||||
text: "Our AI-powered routing system reduces unnecessary travel by selecting the most efficient delivery paths across the city. This helps lower fuel and battery consumption while improving delivery speed and operational efficiency. Businesses can complete more deliveries in less time with significantly reduced logistics costs."
|
||||
},
|
||||
{
|
||||
title: "PERFORMANCE",
|
||||
text: "The optimization engine intelligently groups and balances deliveries, allowing the same order volume to be fulfilled with fewer vehicles. This improves fleet utilization, reduces maintenance and staffing costs, and increases overall delivery efficiency. Even with fewer vehicles, the platform maintains smooth and reliable operations."
|
||||
},
|
||||
{
|
||||
title: "PERFORMANCE",
|
||||
text: "Real-time route optimization ensures predictable and on-time deliveries across all delivery zones. By reducing delays and improving route planning, businesses can maintain high customer satisfaction and strong SLA performance. The system delivers lower operational costs while consistently maintaining 100% order fulfillment."
|
||||
}
|
||||
];
|
||||
|
||||
// Always begin on slide 1 (01/03) on mount. Scrolling away and back does NOT reset
|
||||
// (the component stays mounted) — only a fresh page load / route change back to
|
||||
// MileTruth re-mounts and restarts at slide 1.
|
||||
useEffect(() => {
|
||||
setActiveSlide(0);
|
||||
}, []);
|
||||
|
||||
// Autoplay is gated on visibility: it starts only once the slider card scrolls into
|
||||
// view (not on page load) and stops when it leaves — without touching activeSlide,
|
||||
// so returning to the section resumes from wherever it was, never snapping to slide 1.
|
||||
useEffect(() => {
|
||||
const el = cardRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => setInView(entry.isIntersecting),
|
||||
{ threshold: 0.35 }
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
// Auto-advance every 10s, looping — but only while the card is in view and the user
|
||||
// isn't hovering it. Keyed on activeSlide so a manual jump restarts the 10s dwell.
|
||||
useEffect(() => {
|
||||
if (!inView || paused) return;
|
||||
const id = setTimeout(() => {
|
||||
setActiveSlide((prev) => (prev + 1) % slides.length);
|
||||
}, 10000);
|
||||
return () => clearTimeout(id);
|
||||
}, [activeSlide, inView, paused, slides.length]);
|
||||
|
||||
return (
|
||||
<section className="dm-wf1" aria-label="Workflow 1 — Impact of Optimisation & Performance">
|
||||
|
||||
{/* ── Top sub-section: the full interactive "Impact of Optimisation" experience ── */}
|
||||
<OptimizationSection />
|
||||
|
||||
{/* ── Bottom sub-section: Performance content, flush + colour-matched to the
|
||||
optimisation section above so the whole workflow reads as one container ── */}
|
||||
<div className="dm-wf1-card" ref={cardRef} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||
{/* Left Column: Overlapping Chevron Graphic */}
|
||||
<div className="dm-workflow-left">
|
||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||
<path
|
||||
d="M 30,20 C 22,20 16,26 16,34 L 78,85 C 81,88 81,92 78,95 L 16,146 C 16,154 22,160 30,160 L 130,160 C 138,160 145,154 148,146 L 204,95 C 207,92 207,88 204,85 L 148,34 C 145,26 138,20 130,20 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M 110,100 C 102,100 96,106 96,114 L 158,165 C 161,168 161,172 158,175 L 96,226 C 96,234 102,240 110,240 L 210,240 C 218,240 225,234 228,226 L 284,175 C 287,172 287,168 284,165 L 228,114 C 225,106 218,100 210,100 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.85"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Quotes & Text Content */}
|
||||
<div className="dm-workflow-right">
|
||||
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-quote">
|
||||
<rect x="2" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
<rect x="16" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
</svg>
|
||||
|
||||
<h3 className="dm-workflow-title">{slides[activeSlide].title}</h3>
|
||||
|
||||
<div className="dm-workflow-text-container">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.p
|
||||
key={activeSlide}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.7, ease: "easeInOut" }}
|
||||
className="dm-workflow-text"
|
||||
>
|
||||
{slides[activeSlide].text}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="dm-workflow-nav">
|
||||
<span className="dm-workflow-counter">0{activeSlide + 1}/03</span>
|
||||
<div className="dm-workflow-bars">
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
className={`dm-workflow-bar ${index === activeSlide ? "is-active" : ""}`}
|
||||
onClick={() => setActiveSlide(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style dangerouslySetInnerHTML={{ __html: styles }} />
|
||||
</section>
|
||||
<EVSection
|
||||
ariaLabel="Workflow 1 — Performance"
|
||||
gapTop
|
||||
gapBottom
|
||||
bannerImage="/images/home3-slide-1.jpg"
|
||||
cardTitle="OPTIMIZE EVERY MILE"
|
||||
cardSubtitle="Cut travel distance, reduce operating cost, and improve fleet productivity across every route."
|
||||
eyebrow="/ Performance /"
|
||||
titleLead="SMARTER ROUTES. "
|
||||
titleAccent="LOWER COSTS."
|
||||
mediaSlot={<WorkflowScene variant="optimization" ariaLabel="Live route optimization engine" />}
|
||||
slides={SLIDES}
|
||||
cardsHeading="Performance Insight"
|
||||
cardsTheme={THEME}
|
||||
badges={BADGES}
|
||||
stats={STATS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
/* ============================================================
|
||||
Workflow 1 = ONE container:
|
||||
├─ Impact of Optimisation (full interactive OptimizationSection)
|
||||
└─ Performance (content card, flush + colour-matched)
|
||||
The Performance card is pulled up to butt against the optimisation
|
||||
card's flat bottom and shares its dark-navy surface, so the two
|
||||
read as a single continuous container with no gap / no break.
|
||||
============================================================ */
|
||||
.dm-wf1 {
|
||||
position: relative;
|
||||
margin: 0 auto 0;
|
||||
}
|
||||
|
||||
/* Cancel the global "section { padding: 6rem 0 }" (consolidated into /public/css/site.css): both
|
||||
this wrapper and the nested .dm-opt are sections, so that 96px top+bottom stacked
|
||||
into large empty bands above / between the workflows. These are full-bleed pinned
|
||||
experiences whose cards butt together via their own insets — no section padding. */
|
||||
.dm-wf1, .dm-wf1 .dm-opt { padding-top: 0; padding-bottom: 0; }
|
||||
|
||||
/* Performance card — aligned to the optimisation card (20px side insets),
|
||||
navy-matched, flat top, rounded bottom, pulled up to close the seam. */
|
||||
.dm-wf1-card {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin: 0 20px 0;
|
||||
background: linear-gradient(180deg, #030a18 0%, #06101f 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-top: none;
|
||||
border-radius: 0 0 28px 28px;
|
||||
/* No shadow: this card is flush under the optimisation card and merges with it as one
|
||||
continuous container — a shadow here would re-introduce a dark band at the seam. */
|
||||
box-shadow: none;
|
||||
padding: 36px 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dm-workflow-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 440px;
|
||||
}
|
||||
.dm-workflow-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 8px 24px rgba(0,0,0,0.3));
|
||||
}
|
||||
|
||||
.dm-workflow-right {
|
||||
flex: 1.2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
.dm-workflow-quote { margin-bottom: 5px; }
|
||||
|
||||
.dm-workflow-title {
|
||||
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif;
|
||||
font-size: 38px;
|
||||
font-weight: 700;
|
||||
color: #F8FAFC !important;
|
||||
letter-spacing: -0.015em;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dm-workflow-text-container { min-height: 150px; width: 100%; }
|
||||
.dm-workflow-text {
|
||||
font-family: var(--font-manrope), system-ui, sans-serif;
|
||||
font-size: 21px;
|
||||
line-height: 1.75;
|
||||
letter-spacing: 0.01em;
|
||||
color: #A3A3A3;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.dm-workflow-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
align-self: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.dm-workflow-counter {
|
||||
font-family: var(--font-space-grotesk), sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #737373;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.dm-workflow-bars { display: flex; gap: 8px; }
|
||||
.dm-workflow-bar {
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.dm-workflow-bar.is-active { background: #C01227; }
|
||||
.dm-workflow-bar:hover { background: rgba(255, 255, 255, 0.35); }
|
||||
.dm-workflow-bar.is-active:hover { background: #C01227; }
|
||||
|
||||
/* ── Responsive — keep insets/radius aligned to the optimisation card ── */
|
||||
@media (max-width: 1024px) {
|
||||
.dm-wf1-card {
|
||||
padding: 44px 44px;
|
||||
gap: 44px;
|
||||
}
|
||||
.dm-workflow-title { font-size: 32px; }
|
||||
.dm-workflow-text { font-size: 19px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-wf1-card {
|
||||
margin: 0 10px 0;
|
||||
border-radius: 0 0 20px 20px;
|
||||
padding: 36px 28px;
|
||||
gap: 36px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dm-workflow-left { max-width: 280px; }
|
||||
.dm-workflow-right { width: 100%; }
|
||||
.dm-workflow-title { font-size: 28px; }
|
||||
.dm-workflow-text { font-size: 17px; }
|
||||
.dm-workflow-text-container { min-height: auto; }
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,269 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import LogisticsBrainSection from "../logisticsbrain/LogisticsBrainSection";
|
||||
import React from "react";
|
||||
import EVSection, { EVStat, EVBadge, EVSlide, EVCardsTheme } from "./EVSection";
|
||||
import WorkflowScene from "./WorkflowScene";
|
||||
|
||||
/* Red / crimson / orange — matches the Routing Engine (logistics brain) scene. */
|
||||
const THEME: EVCardsTheme = {
|
||||
accent: "#E2354A",
|
||||
accent2: "#F59E0B",
|
||||
glow: "rgba(226,53,74,0.24)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow 2 — Innovation (hybrid split-screen).
|
||||
*
|
||||
* Keeps the premium EVSection chrome but converts the body into a split layout:
|
||||
* • Left — the PRODUCTION Routing Engine Three.js scene (the same
|
||||
* LogisticsBrainCanvas used by LogisticsBrainSection: city nodes,
|
||||
* buildings, multi-route generation, constraint evaluation,
|
||||
* network/brain animation). One instance, mounted compactly.
|
||||
* • Right — lightweight auto-rotating cards (4s / 600ms fade+slide).
|
||||
*
|
||||
* Preserves the 3D storytelling while dramatically cutting page height.
|
||||
*/
|
||||
const SLIDES: EVSlide[] = [
|
||||
{
|
||||
status: "Generating Routes",
|
||||
title: "Generate Routes",
|
||||
value: 6,
|
||||
suffix: " plans",
|
||||
metricLabel: "Route Plans Generated",
|
||||
kpis: ["Parallel strategies explored", "59 orders in scope", "Real-time combinations"],
|
||||
desc: "The Parallel Universe Engine evaluates many routing strategies at once for every dispatch window, exploring route combinations in real time.",
|
||||
},
|
||||
{
|
||||
status: "Constraints Passed",
|
||||
title: "Check Constraints",
|
||||
value: 5,
|
||||
metricLabel: "Constraints Evaluated",
|
||||
kpis: ["Battery aware", "Capacity & distance checked", "Powered by Google OR-Tools"],
|
||||
desc: "Battery, distance, capacity and time are first-class inputs — battery-aware simulation solves the EV routing challenge.",
|
||||
},
|
||||
{
|
||||
status: "Scoring Routes",
|
||||
title: "Score & Compare",
|
||||
value: 12,
|
||||
suffix: "+",
|
||||
metricLabel: "Strategies Compared",
|
||||
kpis: ["Ranked by total cost", "SLA protected", "Real-time ETA validation"],
|
||||
desc: "Every plan is benchmarked in parallel and ranked by total cost, with sub-45ms inference at production scale.",
|
||||
},
|
||||
{
|
||||
status: "Delivery Ready",
|
||||
title: "Select Best Plan",
|
||||
value: 45,
|
||||
suffix: "ms",
|
||||
metricLabel: "Decision Latency",
|
||||
kpis: ["Late plans rejected", "Best plan locked in", "Dispatched to the fleet"],
|
||||
desc: "Late plans are rejected automatically and the highest-performing, SLA-first plan is locked in and dispatched.",
|
||||
},
|
||||
];
|
||||
|
||||
const BADGES: EVBadge[] = [
|
||||
{ value: "45ms", label: "INFERENCE" },
|
||||
{ value: "100%", label: "SLA-FIRST" },
|
||||
];
|
||||
|
||||
const STATS: EVStat[] = [
|
||||
{ value: 45, suffix: "ms", label: "Inference" },
|
||||
{ value: 12, suffix: "+", label: "Strategies" },
|
||||
{ value: 99.9, decimals: 1, suffix: "%", label: "SLA Met" },
|
||||
{ value: 24, suffix: "/7", label: "Adaptive" },
|
||||
];
|
||||
|
||||
export default function Workflow2() {
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [inView, setInView] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const slides = [
|
||||
{
|
||||
title: "INNOVATION",
|
||||
text: "Our Parallel Universe Engine simultaneously evaluates multiple routing strategies to identify the most efficient delivery plan for every dispatch window. By simulating different route combinations in real time, the system ensures faster, smarter, and more cost-effective logistics decisions. This enables businesses to maintain high operational accuracy while adapting dynamically to changing delivery conditions."
|
||||
},
|
||||
{
|
||||
title: "INNOVATION",
|
||||
text: "The platform solves the EV routing challenge through intelligent battery-aware simulations and advanced optimization logic powered by Google OR-Tools. It balances delivery efficiency, charging constraints, and SLA priorities to maximize fleet performance without compromising reliability. This creates a scalable and future-ready logistics system designed for both traditional and EV fleets."
|
||||
},
|
||||
{
|
||||
title: "INNOVATION",
|
||||
text: "With sub-45ms inference latency and real-time ETA validation, the engine delivers instant routing decisions with exceptional precision. Multiple strategy universes are benchmarked in parallel to consistently select the best-performing route configuration. The result is highly reliable, SLA-first delivery operations with improved customer experience and operational consistency."
|
||||
}
|
||||
];
|
||||
|
||||
// Always begin on slide 1 (01/03) on mount. Scrolling away and back does NOT reset
|
||||
// (the component stays mounted) — only a fresh page load / route change back to
|
||||
// MileTruth re-mounts and restarts at slide 1.
|
||||
useEffect(() => {
|
||||
setActiveSlide(0);
|
||||
}, []);
|
||||
|
||||
// Autoplay is gated on visibility: it starts only once the slider card scrolls into
|
||||
// view (not on page load) and stops when it leaves — without touching activeSlide,
|
||||
// so returning to the section resumes from wherever it was, never snapping to slide 1.
|
||||
useEffect(() => {
|
||||
const el = cardRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => setInView(entry.isIntersecting),
|
||||
{ threshold: 0.35 }
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
// Auto-advance every 10s, looping — but only while the card is in view and the user
|
||||
// isn't hovering it. Keyed on activeSlide so a manual jump restarts the 10s dwell.
|
||||
useEffect(() => {
|
||||
if (!inView || paused) return;
|
||||
const id = setTimeout(() => {
|
||||
setActiveSlide((prev) => (prev + 1) % slides.length);
|
||||
}, 10000);
|
||||
return () => clearTimeout(id);
|
||||
}, [activeSlide, inView, paused, slides.length]);
|
||||
|
||||
return (
|
||||
<section className="dm-wf2" aria-label="Workflow 2 — How Our Logistics Brain Works & Innovation">
|
||||
|
||||
{/* ── Top sub-section: the complete "How Our Logistics Brain Works" experience ── */}
|
||||
<LogisticsBrainSection connected />
|
||||
|
||||
{/* ── Bottom sub-section: Innovation content, flush + colour-matched to the
|
||||
logistics-brain card above so the whole workflow reads as one container ── */}
|
||||
<div className="dm-wf2-card" ref={cardRef} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||
{/* Left Column: Overlapping Chevron Graphic */}
|
||||
<div className="dm-workflow-left">
|
||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||
<path
|
||||
d="M 30,20 C 22,20 16,26 16,34 L 78,85 C 81,88 81,92 78,95 L 16,146 C 16,154 22,160 30,160 L 130,160 C 138,160 145,154 148,146 L 204,95 C 207,92 207,88 204,85 L 148,34 C 145,26 138,20 130,20 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M 110,100 C 102,100 96,106 96,114 L 158,165 C 161,168 161,172 158,175 L 96,226 C 96,234 102,240 110,240 L 210,240 C 218,240 225,234 228,226 L 284,175 C 287,172 287,168 284,165 L 228,114 C 225,106 218,100 210,100 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.85"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Quotes & Text Content */}
|
||||
<div className="dm-workflow-right">
|
||||
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-quote">
|
||||
<rect x="2" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
<rect x="16" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
</svg>
|
||||
|
||||
<h3 className="dm-workflow-title">{slides[activeSlide].title}</h3>
|
||||
|
||||
<div className="dm-workflow-text-container">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.p
|
||||
key={activeSlide}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.7, ease: "easeInOut" }}
|
||||
className="dm-workflow-text"
|
||||
>
|
||||
{slides[activeSlide].text}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="dm-workflow-nav">
|
||||
<span className="dm-workflow-counter">0{activeSlide + 1}/03</span>
|
||||
<div className="dm-workflow-bars">
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
className={`dm-workflow-bar ${index === activeSlide ? "is-active" : ""}`}
|
||||
onClick={() => setActiveSlide(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style dangerouslySetInnerHTML={{ __html: styles }} />
|
||||
</section>
|
||||
<EVSection
|
||||
ariaLabel="Workflow 2 — Innovation"
|
||||
gapBottom
|
||||
bannerImage="/images/mid-mile-approach.jpg"
|
||||
cardTitle="CHOOSE THE BEST PLAN"
|
||||
cardSubtitle="Analyze thousands of route possibilities and automatically select the most efficient delivery strategy."
|
||||
eyebrow="/ Innovation /"
|
||||
titleLead="MANY STRATEGIES. "
|
||||
titleAccent="ONE BEST PLAN."
|
||||
mediaSlot={<WorkflowScene variant="logistics" ariaLabel="Live multi-route logistics brain" />}
|
||||
slides={SLIDES}
|
||||
cardsHeading="AI Decision Engine"
|
||||
cardsTheme={THEME}
|
||||
badges={BADGES}
|
||||
stats={STATS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
/* ============================================================
|
||||
Workflow 2 = ONE container:
|
||||
├─ How Our Logistics Brain Works (full LogisticsBrainSection)
|
||||
└─ Innovation (content card, flush + colour-matched)
|
||||
The Innovation card is pulled up to butt against the logistics-brain
|
||||
card's flat bottom and shares its dark red/black surface, so the two
|
||||
read as a single continuous container with no gap / no break — the
|
||||
same connected storytelling structure used in Workflow 1
|
||||
(Impact of Optimisation → Performance).
|
||||
============================================================ */
|
||||
.dm-wf2 {
|
||||
position: relative;
|
||||
margin: 0 auto 0;
|
||||
}
|
||||
|
||||
/* Cancel the global "section { padding: 6rem 0 }" (consolidated into /public/css/site.css): both
|
||||
this wrapper and the nested .dm-lb are sections, so that 96px top+bottom stacked
|
||||
into large empty bands above / between the workflows. These are full-bleed pinned
|
||||
experiences whose cards butt together via their own insets — no section padding. */
|
||||
.dm-wf2, .dm-wf2 .dm-lb { padding-top: 0; padding-bottom: 0; }
|
||||
|
||||
/* Innovation card — aligned to the logistics-brain card (20px side insets),
|
||||
red/black-matched, flat top, rounded bottom, pulled up to close the seam. */
|
||||
.dm-wf2-card {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin: 0 20px 0;
|
||||
background: radial-gradient(120% 100% at 50% 0%, #12090c 0%, #0a070a 55%, #060507 100%);
|
||||
border: 1px solid rgba(192, 18, 39, 0.16);
|
||||
border-top: none;
|
||||
border-radius: 0 0 28px 28px;
|
||||
/* No shadow: this card is flush under the logistics-brain card and merges with it as one
|
||||
continuous container — a shadow here would re-introduce a dark band at the seam. */
|
||||
box-shadow: none;
|
||||
padding: 36px 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dm-workflow-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 440px;
|
||||
}
|
||||
.dm-workflow-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 8px 24px rgba(0,0,0,0.3));
|
||||
}
|
||||
|
||||
.dm-workflow-right {
|
||||
flex: 1.2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
.dm-workflow-quote { margin-bottom: 5px; }
|
||||
|
||||
.dm-workflow-title {
|
||||
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif;
|
||||
font-size: 38px;
|
||||
font-weight: 700;
|
||||
color: #F8FAFC !important;
|
||||
letter-spacing: -0.015em;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dm-workflow-text-container { min-height: 150px; width: 100%; }
|
||||
.dm-workflow-text {
|
||||
font-family: var(--font-manrope), system-ui, sans-serif;
|
||||
font-size: 21px;
|
||||
line-height: 1.75;
|
||||
letter-spacing: 0.01em;
|
||||
color: #A3A3A3;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.dm-workflow-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
align-self: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.dm-workflow-counter {
|
||||
font-family: var(--font-space-grotesk), sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #737373;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.dm-workflow-bars { display: flex; gap: 8px; }
|
||||
.dm-workflow-bar {
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.dm-workflow-bar.is-active { background: #C01227; }
|
||||
.dm-workflow-bar:hover { background: rgba(255, 255, 255, 0.35); }
|
||||
.dm-workflow-bar.is-active:hover { background: #C01227; }
|
||||
|
||||
/* ── Responsive — keep insets/radius aligned to the logistics-brain card ── */
|
||||
@media (max-width: 1024px) {
|
||||
.dm-wf2-card {
|
||||
padding: 44px 44px;
|
||||
gap: 44px;
|
||||
}
|
||||
.dm-workflow-title { font-size: 32px; }
|
||||
.dm-workflow-text { font-size: 19px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-wf2-card {
|
||||
margin: 0 10px 0;
|
||||
border-radius: 0 0 20px 20px;
|
||||
padding: 36px 28px;
|
||||
gap: 36px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dm-workflow-left { max-width: 280px; }
|
||||
.dm-workflow-right { width: 100%; }
|
||||
.dm-workflow-title { font-size: 28px; }
|
||||
.dm-workflow-text { font-size: 17px; }
|
||||
.dm-workflow-text-container { min-height: auto; }
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -248,18 +248,35 @@ const styles = `
|
||||
.dm-workflow-text { font-size: 19px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
/* Mobile: compact card so it never exceeds ~500px (was ~850px from the full
|
||||
desktop chevron + long paragraph). Smaller chevron, tighter spacing and a
|
||||
line-clamped paragraph keep the workflow state readable without a long scroll. */
|
||||
.dm-wf3-card {
|
||||
/* Bottom gap separates this last workflow card from the contact section below. */
|
||||
margin: 0 10px 16px;
|
||||
border-radius: 0 0 20px 20px;
|
||||
padding: 36px 28px;
|
||||
gap: 36px;
|
||||
padding: 26px 22px;
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dm-workflow-left { max-width: 280px; }
|
||||
.dm-workflow-right { width: 100%; }
|
||||
.dm-workflow-title { font-size: 28px; }
|
||||
.dm-workflow-text { font-size: 17px; }
|
||||
.dm-workflow-left { max-width: 128px; }
|
||||
.dm-workflow-right { width: 100%; gap: 12px; }
|
||||
.dm-workflow-quote { margin-bottom: 2px; }
|
||||
.dm-workflow-title { font-size: 22px; }
|
||||
.dm-workflow-text-container { min-height: auto; }
|
||||
.dm-workflow-text {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dm-workflow-nav { margin-top: 4px; }
|
||||
}
|
||||
@media (max-width: 390px) {
|
||||
.dm-workflow-left { max-width: 108px; }
|
||||
.dm-workflow-title { font-size: 20px; }
|
||||
.dm-workflow-text { font-size: 14px; -webkit-line-clamp: 4; }
|
||||
}
|
||||
`;
|
||||
|
||||
180
src/components/sections/WorkflowScene.tsx
Normal file
180
src/components/sections/WorkflowScene.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
/* ============================================================
|
||||
WorkflowScene — compact, self-driven mount of the PRODUCTION
|
||||
Three.js scenes (OptimizationCanvas / LogisticsBrainCanvas).
|
||||
|
||||
These are the EXACT same scene components used by the full
|
||||
OptimizationSection / LogisticsBrainSection experiences — same
|
||||
shaders, assets, particles, camera rig and animation system.
|
||||
The only difference here is how `progress` is driven: instead
|
||||
of a multi-viewport pinned scroll (which made the workflows
|
||||
extremely tall), the scene idles in its "settled" narrative
|
||||
band with a gentle autonomous oscillation so it stays alive
|
||||
inside the split-screen media panel.
|
||||
|
||||
Exactly ONE canvas instance is created per workflow — no
|
||||
duplicate scenes, no duplicate render loops. The render loop
|
||||
is paused (frameloop="never") whenever the panel is off-screen.
|
||||
============================================================ */
|
||||
|
||||
// Client-only, code-split so the heavy 3D bundle never blocks first paint.
|
||||
const OptimizationCanvas = dynamic(() => import("../optimization/OptimizationCanvas"), { ssr: false });
|
||||
const LogisticsBrainCanvas = dynamic(() => import("../logisticsbrain/LogisticsBrainCanvas"), { ssr: false });
|
||||
|
||||
type Variant = "optimization" | "logistics";
|
||||
|
||||
/** Per-scene background (matches each Canvas's own <color attach="background">)
|
||||
* so there is no flash before the WebGL context paints. */
|
||||
const SCENE_BG: Record<Variant, string> = {
|
||||
optimization: "#020617",
|
||||
logistics: "#08080c",
|
||||
};
|
||||
|
||||
/** Settled narrative band each scene idles within. The optimization scene reads
|
||||
* best inside its post-optimize band (mirrors the production mobile idle); the
|
||||
* logistics scene around its score/network phase where city + routes + brain
|
||||
* are all alive. Held at a single static value under reduced-motion. */
|
||||
const IDLE: Record<Variant, { center: number; amp: number; speed: number; still: number }> = {
|
||||
optimization: { center: 0.78, amp: 0.14, speed: 0.5, still: 0.85 },
|
||||
logistics: { center: 0.5, amp: 0.13, speed: 0.4, still: 0.52 },
|
||||
};
|
||||
|
||||
export default function WorkflowScene({
|
||||
variant,
|
||||
ariaLabel,
|
||||
}: {
|
||||
variant: Variant;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef(IDLE[variant].still);
|
||||
|
||||
const [mountScene, setMountScene] = useState(false);
|
||||
const [sceneActive, setSceneActive] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isTablet, setIsTablet] = useState(false);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
// Responsive + reduced-motion flags (same breakpoints as the production sections).
|
||||
useEffect(() => {
|
||||
const mqMobile = window.matchMedia("(max-width: 767px)");
|
||||
const mqTablet = window.matchMedia("(min-width: 768px) and (max-width: 1024px)");
|
||||
const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const sync = () => {
|
||||
setIsMobile(mqMobile.matches);
|
||||
setIsTablet(mqTablet.matches);
|
||||
setReduced(mqReduce.matches);
|
||||
};
|
||||
sync();
|
||||
mqMobile.addEventListener("change", sync);
|
||||
mqTablet.addEventListener("change", sync);
|
||||
mqReduce.addEventListener("change", sync);
|
||||
return () => {
|
||||
mqMobile.removeEventListener("change", sync);
|
||||
mqTablet.removeEventListener("change", sync);
|
||||
mqReduce.removeEventListener("change", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mount the heavy scene a little before it enters; keep the render loop gated
|
||||
// to actual visibility so it never burns frames off-screen.
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const mountIo = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
setMountScene(true);
|
||||
setSceneActive(true);
|
||||
mountIo.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "60% 0px" },
|
||||
);
|
||||
const activeIo = new IntersectionObserver(
|
||||
(entries) => setSceneActive(entries.some((e) => e.isIntersecting)),
|
||||
{ rootMargin: "10% 0px" },
|
||||
);
|
||||
mountIo.observe(el);
|
||||
activeIo.observe(el);
|
||||
return () => {
|
||||
mountIo.disconnect();
|
||||
activeIo.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Drive `progress` autonomously: a slow sine within the settled band keeps the
|
||||
// hologram breathing. Runs only while visible; held static under reduced-motion.
|
||||
useEffect(() => {
|
||||
const cfg = IDLE[variant];
|
||||
if (reduced) {
|
||||
progressRef.current = cfg.still;
|
||||
return;
|
||||
}
|
||||
if (!sceneActive) return;
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
const t = performance.now() / 1000;
|
||||
progressRef.current = cfg.center + Math.sin(t * cfg.speed) * cfg.amp;
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [variant, reduced, sceneActive]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="wf-scene"
|
||||
role="img"
|
||||
aria-label={ariaLabel ?? "DoorMile 3D logistics scene"}
|
||||
style={{ background: SCENE_BG[variant] }}
|
||||
>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
.wf-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* Compact, fixed-ratio media panel — replaces the multi-viewport pinned
|
||||
scroll experience so the workflow is dramatically shorter. */
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wf-scene__canvas { position: absolute; inset: 0; }
|
||||
.wf-scene__canvas canvas { display: block; width: 100% !important; height: 100% !important; }
|
||||
@media (max-width: 991px) {
|
||||
.wf-scene { aspect-ratio: 16 / 10; }
|
||||
}
|
||||
/* Phones: shorter scene panel (~17% less tall than the old 4/3) so the
|
||||
card + KPI bar are reached sooner. The closer mobile camera framing
|
||||
keeps the depot/routes/vehicles readable in the reduced height. */
|
||||
@media (max-width: 480px) {
|
||||
.wf-scene { aspect-ratio: 16 / 10; }
|
||||
}
|
||||
`}} />
|
||||
<div className="wf-scene__canvas">
|
||||
{mountScene &&
|
||||
(variant === "optimization" ? (
|
||||
<OptimizationCanvas
|
||||
progress={progressRef}
|
||||
reduced={reduced}
|
||||
isMobile={isMobile}
|
||||
isTablet={isTablet}
|
||||
active={sceneActive}
|
||||
/>
|
||||
) : (
|
||||
<LogisticsBrainCanvas
|
||||
progress={progressRef}
|
||||
reduced={reduced}
|
||||
isMobile={isMobile}
|
||||
active={sceneActive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -418,8 +418,13 @@ const styles = `
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-st { height: 420vh; }
|
||||
/* Full-width, bottom-anchored story card. Bound its height to the viewport and
|
||||
let it scroll internally so a tall stage card (Command Center / Winner) can
|
||||
never be clipped off the top of a short phone screen — the active workflow
|
||||
state always stays fully visible. */
|
||||
.dm-st-card-story { left: 0 !important; right: 0 !important; margin: 0 auto; width: calc(100% - 28px);
|
||||
bottom: clamp(18px, 4vh, 40px); padding: 15px 16px; }
|
||||
bottom: clamp(18px, 4vh, 40px); padding: 15px 16px;
|
||||
max-height: 52vh; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dm-st-arrow { animation: none !important; }
|
||||
|
||||
Reference in New Issue
Block a user