fix blog page
This commit is contained in:
116
src/app/blog/[slug]/page.tsx
Normal file
116
src/app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import SingleBlog from "@/components/sections/SingleBlog";
|
||||
import BlogPostFooter from "@/components/sections/BlogPostFooter";
|
||||
import { getPostBySlug, getAllSlugs, SITE_URL } from "@/data/blog";
|
||||
|
||||
type Params = { slug: string };
|
||||
|
||||
// Required for `output: "export"` — prerender every post at build time.
|
||||
export function generateStaticParams(): Params[] {
|
||||
return getAllSlugs().map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
return { title: "Article Not Found – Doormile" };
|
||||
}
|
||||
|
||||
const url = `${SITE_URL}/blog/${post.slug}`;
|
||||
const image = `${SITE_URL}${post.image}`;
|
||||
|
||||
return {
|
||||
title: `${post.title} – Doormile`,
|
||||
description: post.excerpt,
|
||||
keywords: [post.category, "last-mile logistics", "EV fleet", "MileTruth", "route optimisation"],
|
||||
authors: [{ name: post.author }],
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
url,
|
||||
siteName: "Doormile",
|
||||
images: [{ url: image, alt: post.title }],
|
||||
publishedTime: new Date(`${post.date}T00:00:00Z`).toISOString(),
|
||||
authors: [post.author],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
images: [image],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySlug(slug);
|
||||
|
||||
if (!post) notFound();
|
||||
|
||||
const url = `${SITE_URL}/blog/${post.slug}`;
|
||||
|
||||
const articleSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: post.title,
|
||||
description: post.excerpt,
|
||||
image: [`${SITE_URL}${post.image}`],
|
||||
datePublished: new Date(`${post.date}T00:00:00Z`).toISOString(),
|
||||
dateModified: new Date(`${post.date}T00:00:00Z`).toISOString(),
|
||||
author: { "@type": "Organization", name: post.author, url: SITE_URL },
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Doormile",
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: `${SITE_URL}/images/cropped-image-2.png`,
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: { "@type": "WebPage", "@id": url },
|
||||
articleSection: post.category,
|
||||
};
|
||||
|
||||
const breadcrumbSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Home", item: SITE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Blog", item: `${SITE_URL}/blog` },
|
||||
{ "@type": "ListItem", position: 3, name: post.title, item: url },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-wrapper content-wrapper-may-contain-elementor-code content-wrapper-sidebar-position-none">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
|
||||
/>
|
||||
<div className="content">
|
||||
<div className="content-inner">
|
||||
<SingleBlog post={post} />
|
||||
<BlogPostFooter slug={post.slug} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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; }
|
||||
|
||||
531
src/data/blog.ts
Normal file
531
src/data/blog.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* Central blog data module — single source of truth for the listing page,
|
||||
* the /blog/[slug] route, the sidebar, related posts and prev/next nav.
|
||||
*
|
||||
* The site is a static export (next.config.ts → output: "export"), so all of
|
||||
* this is plain data resolved at build time. No CMS / no runtime fetching.
|
||||
*/
|
||||
|
||||
export const SITE_URL = "https://www.doormile.com";
|
||||
|
||||
export type ContentBlock =
|
||||
| { type: "paragraph"; text: string }
|
||||
| { type: "heading"; level: 2 | 3; text: string }
|
||||
| { type: "list"; ordered?: boolean; items: string[] }
|
||||
| { type: "quote"; text: string; cite?: string }
|
||||
| { type: "image"; src: string; alt: string; caption?: string };
|
||||
|
||||
export interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
category: string;
|
||||
image: string;
|
||||
date: string; // ISO (YYYY-MM-DD)
|
||||
author: string;
|
||||
intro: string;
|
||||
content: ContentBlock[];
|
||||
}
|
||||
|
||||
const AUTHOR = "Doormile Team";
|
||||
|
||||
/**
|
||||
* Shared rich-content template for non-flagship posts. Seeded with the post's
|
||||
* own title / category so every element type (h2, h3, lists, quote, image)
|
||||
* renders and every route is complete. Flagship posts below override `content`
|
||||
* with fully authored copy.
|
||||
*/
|
||||
function templateContent(post: {
|
||||
title: string;
|
||||
category: string;
|
||||
image: string;
|
||||
}): ContentBlock[] {
|
||||
return [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: `In last-mile logistics, the difference between a good day and a missed SLA is rarely a single dramatic failure — it is the quiet accumulation of small inefficiencies. ${post.title} looks at how Doormile turns those margins into measurable advantage, and why a precision-first approach consistently outperforms guesswork on the road.`,
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 2,
|
||||
text: "Why this matters for modern fleets",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Every additional kilometre carries cost: fuel or charge, rider hours, vehicle wear, and the risk of a late delivery. When routing decisions are made on intuition or static rules, those costs compound across hundreds of stops. Treating the route as a solvable optimisation problem — not a best guess — is what separates scalable operations from ones that simply add more vehicles.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
items: [
|
||||
"Fewer vehicles deployed for the same delivery volume",
|
||||
"Lower cost-per-drop through tighter, smarter sequencing",
|
||||
"Predictable ETAs that protect customer trust and SLA targets",
|
||||
"A cleaner, lower-emission footprint per parcel delivered",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 3,
|
||||
text: "From data to decision",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: `Doormile's MileTruth™ engine ingests orders, constraints and live conditions, then evaluates the routing problem across parallel strategy universes before committing to a plan. The result is a dispatch decision grounded in mathematics rather than heuristics — validated before a single rider leaves the hub.`,
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
src: post.image,
|
||||
alt: post.title,
|
||||
caption: `${post.category} — operational intelligence applied at the point of dispatch.`,
|
||||
},
|
||||
{
|
||||
type: "quote",
|
||||
text: "We don't guess the route. We calculate it — and we prove it works before the wheels start turning.",
|
||||
cite: "Doormile Operations",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 2,
|
||||
text: "Putting it into practice",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The teams that benefit most treat routing intelligence as core infrastructure, not an afterthought. Start by measuring your current cost-per-drop and SLA adherence, then let a precision engine reveal where distance, time and capacity are being lost. The gains are rarely theoretical — they show up directly in the next dispatch cycle.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
ordered: true,
|
||||
items: [
|
||||
"Benchmark today's distance, fleet size and on-time rate.",
|
||||
"Feed real constraints — capacity, windows, charge — into the engine.",
|
||||
"Validate routes against real-world conditions before dispatch.",
|
||||
"Measure the delta, then scale the approach across hubs.",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Smarter routing is not about working harder on the road — it is about making the right decision before the journey begins. That is the foundation every Doormile deployment is built on.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
interface SeedPost {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
category: string;
|
||||
image: string;
|
||||
date: string;
|
||||
intro: string;
|
||||
content?: ContentBlock[]; // present only for flagship posts
|
||||
}
|
||||
|
||||
const seeds: SeedPost[] = [
|
||||
// ── Flagship 1 ───────────────────────────────────────────────────────────
|
||||
{
|
||||
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",
|
||||
date: "2025-10-02",
|
||||
intro:
|
||||
"The last mile has always been logistics' most expensive and least predictable stretch. Add electric vehicles to the mix and the problem sharpens: now every route must respect not just time and capacity, but battery range. Artificial intelligence is what turns that constraint into an advantage.",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "For decades, last-mile delivery was planned the way it was a generation ago — dispatchers, spreadsheets, and hard-won intuition. That approach scales poorly, and it breaks entirely when you electrify the fleet. EVs introduce a moving constraint that no static plan can absorb: a vehicle's remaining range changes with load, terrain, traffic and temperature, all at once.",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 2,
|
||||
text: "The shift from rules to learning",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Traditional routing tools rely on fixed rules: nearest-stop-first, fixed zones, manual overrides. They are fast to set up and brittle in practice. Machine-learning-driven systems instead learn from outcomes — every completed delivery, every delay, every charge cycle becomes training signal that sharpens the next decision.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
items: [
|
||||
"Demand forecasting that anticipates volume spikes before they hit the hub",
|
||||
"Travel-time models trained on the city's real traffic, not generic averages",
|
||||
"Battery-draw prediction tuned to each vehicle class and load profile",
|
||||
"Continuous feedback that improves accuracy with every dispatch",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 3,
|
||||
text: "Real-time adaptation",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The real unlock is not planning — it is replanning. When a road closes, an order is added, or a vehicle's charge drops faster than expected, an AI-driven system re-optimises in milliseconds and reroutes the affected vehicles without a human in the loop. The plan stays optimal even as reality refuses to hold still.",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
src: "/images/ev-paradox.png",
|
||||
alt: "Electric delivery vehicle routing visualisation",
|
||||
caption:
|
||||
"AI continuously re-evaluates range, load and traffic to keep every EV route feasible.",
|
||||
},
|
||||
{
|
||||
type: "quote",
|
||||
text: "An electric fleet is only as good as the intelligence that routes it. The battery sets the limit — the algorithm decides whether you ever reach it.",
|
||||
cite: "Doormile Engineering",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 2,
|
||||
text: "What it means for operators",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "For fleet operators, the payoff is concrete: fewer vehicles covering the same ground, near-zero range-related failures, and ETAs accurate enough to commit to. AI does not replace the operator — it removes the guesswork, so the operator can run a larger, cleaner, more reliable fleet with the same team.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
ordered: true,
|
||||
items: [
|
||||
"Capture real operational data — deliveries, delays, charge cycles.",
|
||||
"Let models learn your city's actual travel and demand patterns.",
|
||||
"Validate every route against live battery capacity before dispatch.",
|
||||
"Re-optimise continuously as conditions change through the day.",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The fleets pulling ahead are not the ones with the most vehicles — they are the ones with the smartest kilometre. That is the promise AI brings to last-mile EV delivery, and it is already on the road.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Flagship 2 ───────────────────────────────────────────────────────────
|
||||
{
|
||||
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",
|
||||
date: "2025-09-18",
|
||||
intro:
|
||||
"Numbers settle arguments. When we deployed MileTruth™ at our Hyderabad hub, the goal was simple: prove that precision routing changes the economics of last-mile delivery. The result — a 42% reduction in total distance travelled — did exactly that.",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Hyderabad is a demanding test bed: dense urban cores, sprawling new suburbs, unpredictable traffic and tight delivery windows. If a routing approach works here, it works almost anywhere. We ran it side by side against the hub's existing manual-plus-rules dispatch process over a sustained period, holding order volume constant.",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 2,
|
||||
text: "The baseline",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Before MileTruth, the hub planned routes the conventional way — zones drawn by experience, sequences set by dispatchers, adjustments made on the fly. It worked, but it left distance on the table every single day, and that distance translated directly into fuel, hours and vehicles.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
items: [
|
||||
"Zone-based allocation that ignored cross-zone efficiencies",
|
||||
"Manual sequencing that couldn't evaluate every alternative",
|
||||
"No pre-validation of ETAs against real travel times",
|
||||
"Reactive rather than predictive handling of disruptions",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 3,
|
||||
text: "What changed",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "MileTruth treated the day's deliveries as one large optimisation problem rather than a set of independent zones. It evaluated routing strategies in parallel, selected the optimal plan against real constraints, and validated every ETA before dispatch. The same orders, the same city — a fundamentally tighter plan.",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
src: "/images/last-mile-approach.jpg",
|
||||
alt: "Hyderabad delivery hub routing analysis",
|
||||
caption:
|
||||
"Consolidating the day's deliveries into a single optimisation removed redundant cross-town travel.",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 2,
|
||||
text: "The results",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
items: [
|
||||
"42% reduction in total distance travelled across the hub",
|
||||
"37% fewer vehicles required for the same delivery volume",
|
||||
"Zero SLA misses across the measured deployment window",
|
||||
"Proportional drop in fuel cost and per-parcel emissions",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "quote",
|
||||
text: "Fewer vehicles, less fuel, zero missed SLAs — and not by working the team harder. By making a better decision before the wheels turned.",
|
||||
cite: "Hyderabad Hub Operations",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 3,
|
||||
text: "Why it generalises",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "The Hyderabad gains were not a quirk of one city. The inefficiencies MileTruth removed — redundant travel, conservative sequencing, unvalidated ETAs — exist in nearly every manual operation. The engine simply makes them visible, then eliminates them. That is why the same approach now anchors deployments well beyond this hub.",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "A 42% cut in distance is not a rounding error — it is a structural change in what the operation costs to run. And it came from intelligence, not additional resources.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Flagship 3 ───────────────────────────────────────────────────────────
|
||||
{
|
||||
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",
|
||||
date: "2025-09-05",
|
||||
intro:
|
||||
"Behind every Doormile dispatch is a pipeline that turns raw orders into a validated, optimal route in under 45 milliseconds. This is how MileTruth™ does it — ten stages, each one removing a source of error before the next begins.",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Speed and correctness are usually a trade-off. MileTruth is engineered to deliver both: a routing decision fast enough to feel instant, yet rigorous enough to commit a fleet to. The secret is a staged pipeline where each step has a single responsibility and hands clean, validated data to the next.",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 2,
|
||||
text: "The ten stages",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
ordered: true,
|
||||
items: [
|
||||
"Ingestion — orders, constraints and fleet state are normalised on arrival.",
|
||||
"Validation — addresses, time windows and capacities are checked and geocoded.",
|
||||
"Demand modelling — volume and service-time estimates are attached to each stop.",
|
||||
"Travel-time estimation — real-world, time-of-day travel matrices are built.",
|
||||
"Constraint assembly — capacity, range, windows and rules are encoded.",
|
||||
"Strategy generation — multiple routing universes are explored in parallel.",
|
||||
"Optimisation — the solver searches for the minimum-cost feasible plan.",
|
||||
"Battery / range validation — EV routes are checked against real charge capacity.",
|
||||
"ETA pre-validation — promised times are verified before any commitment.",
|
||||
"Output — the final, validated route is emitted to dispatch.",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 3,
|
||||
text: "Why staging matters",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Collapsing these steps into one monolithic calculation is how most tools accumulate hidden errors. By isolating each concern, MileTruth catches a bad address before it reaches the solver, and an infeasible battery plan before it reaches a rider. Each stage is independently testable, observable and fast.",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
src: "/images/blog-post-pic-31.png",
|
||||
alt: "MileTruth routing pipeline diagram",
|
||||
caption:
|
||||
"Ten focused stages turn raw orders into a validated route in well under 45 milliseconds.",
|
||||
},
|
||||
{
|
||||
type: "quote",
|
||||
text: "Each stage exists to delete a category of mistake. By the time a route reaches dispatch, the questionable decisions have already been ruled out.",
|
||||
cite: "MileTruth Engineering",
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
level: 2,
|
||||
text: "Parallel strategy universes",
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Stage six is where MileTruth diverges from conventional routers. Rather than committing to one heuristic, it generates several distinct routing strategies simultaneously — each a complete candidate plan — and lets the optimiser select the best. Powered by a mathematical solver, it evaluates trade-offs no dispatcher could hold in their head.",
|
||||
},
|
||||
{
|
||||
type: "list",
|
||||
items: [
|
||||
"Multiple candidate plans evaluated, not a single best guess",
|
||||
"Mathematical optimisation instead of fixed heuristics",
|
||||
"Range and ETA validated inside the loop, not bolted on after",
|
||||
"Sub-45ms output that keeps dispatch genuinely real-time",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
text: "Ten stages, one outcome: a route you can trust enough to commit a fleet to — calculated, validated, and delivered before a dispatcher could finish reading the order list.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Template-backed posts ────────────────────────────────────────────────
|
||||
{
|
||||
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",
|
||||
date: "2025-08-21",
|
||||
intro:
|
||||
"Electric fleets promise cleaner cities and lower running costs — but they trade one problem for another. Range becomes a hard constraint on every route, and range anxiety becomes an operational risk. Solving it before dispatch is the whole game.",
|
||||
},
|
||||
{
|
||||
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",
|
||||
date: "2025-08-07",
|
||||
intro:
|
||||
"Heuristics are fast to build and easy to trust — until they quietly cost you a vehicle a day. Mathematical optimisation asks more of the engine and gives more back: provably better routes, every dispatch, at scale.",
|
||||
},
|
||||
{
|
||||
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",
|
||||
date: "2025-07-24",
|
||||
intro:
|
||||
"Cutting your fleet usually means cutting capacity — unless the kilometres you remove were never necessary in the first place. Smarter routing reclaims that wasted distance and turns it into headroom.",
|
||||
},
|
||||
{
|
||||
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",
|
||||
date: "2025-07-10",
|
||||
intro:
|
||||
"Zero-emission delivery is no longer a marketing line — it is becoming a regulatory expectation. The path there runs through two changes at once: electrifying the fleet, and routing it intelligently enough to make electrification viable.",
|
||||
},
|
||||
{
|
||||
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",
|
||||
date: "2025-06-26",
|
||||
intro:
|
||||
"An SLA you hit 99.9% of the time is not an average you got lucky on — it is a system designed so that missing is the exception, not the risk. Reliability, it turns out, is an engineering decision made long before dispatch.",
|
||||
},
|
||||
{
|
||||
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",
|
||||
date: "2025-06-12",
|
||||
intro:
|
||||
"A stranded EV is not just a late delivery — it is a vehicle out of service, a customer let down, and a recovery cost. Simulating the route against real charge capacity before dispatch is how you make sure it never happens.",
|
||||
},
|
||||
];
|
||||
|
||||
function slugify(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/™/g, "")
|
||||
.replace(/&/g, " and ")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export const blogPosts: BlogPost[] = seeds.map((s) => ({
|
||||
slug: slugify(s.title),
|
||||
title: s.title,
|
||||
excerpt: s.excerpt,
|
||||
category: s.category,
|
||||
image: s.image,
|
||||
date: s.date,
|
||||
author: AUTHOR,
|
||||
intro: s.intro,
|
||||
content:
|
||||
s.content ??
|
||||
templateContent({ title: s.title, category: s.category, image: s.image }),
|
||||
}));
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getPostBySlug(slug: string): BlogPost | undefined {
|
||||
return blogPosts.find((p) => p.slug === slug);
|
||||
}
|
||||
|
||||
export function getAllSlugs(): string[] {
|
||||
return blogPosts.map((p) => p.slug);
|
||||
}
|
||||
|
||||
/** Same-category posts first (most recent), then fill by recency. Excludes self. */
|
||||
export function getRelatedPosts(slug: string, n = 3): BlogPost[] {
|
||||
const current = getPostBySlug(slug);
|
||||
if (!current) return blogPosts.slice(0, n);
|
||||
const others = blogPosts.filter((p) => p.slug !== slug);
|
||||
const byDateDesc = (a: BlogPost, b: BlogPost) => b.date.localeCompare(a.date);
|
||||
const sameCat = others
|
||||
.filter((p) => p.category === current.category)
|
||||
.sort(byDateDesc);
|
||||
const rest = others
|
||||
.filter((p) => p.category !== current.category)
|
||||
.sort(byDateDesc);
|
||||
return [...sameCat, ...rest].slice(0, n);
|
||||
}
|
||||
|
||||
/** Previous / next by array order (publication sequence). */
|
||||
export function getAdjacentPosts(slug: string): {
|
||||
prev: BlogPost | null;
|
||||
next: BlogPost | null;
|
||||
} {
|
||||
const i = blogPosts.findIndex((p) => p.slug === slug);
|
||||
if (i === -1) return { prev: null, next: null };
|
||||
return {
|
||||
prev: i > 0 ? blogPosts[i - 1] : null,
|
||||
next: i < blogPosts.length - 1 ? blogPosts[i + 1] : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRecentPosts(n = 4): BlogPost[] {
|
||||
return [...blogPosts].sort((a, b) => b.date.localeCompare(a.date)).slice(0, n);
|
||||
}
|
||||
|
||||
export function getCategories(): { name: string; count: number }[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const p of blogPosts) {
|
||||
counts.set(p.category, (counts.get(p.category) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(counts, ([name, count]) => ({ name, count })).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
// Parse as UTC to keep static output deterministic across build environments.
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
export function estimateReadingTime(post: BlogPost): number {
|
||||
const wordsFromBlocks = post.content.reduce((sum, b) => {
|
||||
if (b.type === "paragraph" || b.type === "quote") {
|
||||
return sum + b.text.trim().split(/\s+/).length;
|
||||
}
|
||||
if (b.type === "heading") return sum + b.text.trim().split(/\s+/).length;
|
||||
if (b.type === "list") {
|
||||
return sum + b.items.join(" ").trim().split(/\s+/).length;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
const total = wordsFromBlocks + post.intro.trim().split(/\s+/).length;
|
||||
return Math.max(1, Math.round(total / 200));
|
||||
}
|
||||
Reference in New Issue
Block a user