Compare commits

2 Commits

Author SHA1 Message Date
d639415d89 fix mobile responsive 2026-07-08 19:13:36 +05:30
f7bab1fe70 update doormilewings card image update 2026-07-07 21:43:02 +05:30
25 changed files with 1688 additions and 547 deletions

BIN
public/images/card 1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
public/images/card 2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
public/images/card 3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
public/images/card4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
public/images/card5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
public/images/card6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
public/videos/fleet.mp4 Normal file

Binary file not shown.

View File

@@ -153,16 +153,21 @@ body {
#masthead .elementor-element.elementor-element-0b7bf6f > .elementor-widget-container {
padding: 0 42px !important;
border-radius: 22px !important;
height: 58px !important;
display: flex !important;
align-items: center !important;
}
#masthead .elementor-element.elementor-element-0b7bf6f .header-menu-container .main-menu > li > a {
padding-top: 15px !important;
padding-bottom: 16px !important;
padding-top: 6px !important;
padding-bottom: 6px !important;
padding-left: 14px !important;
padding-right: 14px !important;
font-size: 15px !important;
line-height: 1.2 !important;
white-space: nowrap !important;
min-height: auto !important;
height: auto !important;
}
#masthead .header-menu-container .main-menu {
@@ -966,18 +971,30 @@ body {
/* ============================================================
SHARED PAGE CONTENT CONTAINER
Mirrors the site's e-con-boxed > e-con-inner system: 1480px
max-width centred with fluid horizontal padding identical to the
Industry Solutions / OurTeam override above. Apply this class to
any section's inner container div to guarantee that content edges
line up with every other page section (Home, About, Solutions…).
Matches the Hero/Header/Footer's real rendered OUTER edge, not the
Footer's narrower inner text column. The Hero card
(.owl-stage-outer, via .wings-hero-page.elementor-element-741f56c)
and the Footer's outer band (.elementor-element-b29b8fc) both sit a
fixed, uncapped 20px in from the viewport edge at every desktop
width (verified 1280px-2400px — no max-width ever kicks in). Apply
this class to any Wings-page section's content wrapper to guarantee
its cards/timeline/grid hug those same edges instead of sitting
narrower and more centered.
============================================================ */
.dm-section-container {
max-width: 1480px;
width: 100%;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
width: 100%;
padding-left: 20px;
padding-right: 20px;
}
@media (max-width: 1024px) {
.dm-section-container {
padding-left: var(--dm-mobile-gutter, 10px);
padding-right: var(--dm-mobile-gutter, 10px);
}
}
/* Doormile Wings hero frame: the generic vendor CSS assigns 32px padding to
@@ -1197,3 +1214,29 @@ body {
margin-top: var(--space-section) !important;
}
}
/* Global Desktop Hero Formatting (Matches live site)
.miletruth-hero-container is deliberately excluded: unlike the other
hero wrappers it IS the visual card itself (background-image + a
flat bottom edge that the stats bar overlaps by -25px), not a
wrapper around a separate inner card, so it carries its own
correctly-tuned margin-top/padding/border-radius in MileTruthHero.tsx.
Including it here clobbered that with the wrapper values, collapsing
its top gap to 8px and its internal padding to 20px. */
@media (min-width: 1025px) {
/* Give every hero wrapper a consistent gap from the top and sides */
.elementor-element.elementor-element-741f56c,
.custom-standard-hero-container {
padding: 20px 24px !important;
margin-top: 8px !important;
}
/* Ensure all inner dark hero cards have elegant rounded corners */
.elementor-element.elementor-element-741f56c .owl-carousel.owl-theme .content-item,
.elementor-element.elementor-element-741f56c .content-item,
.custom-standard-hero-card,
.miletruth-hero .slide-content {
border-radius: 24px !important;
overflow: hidden !important;
}
}

View File

@@ -797,11 +797,13 @@ export default function Header() {
grid-template-columns: 1fr auto 1fr !important;
align-items: center !important;
column-gap: clamp(16px, 2vw, 32px) !important;
/* Equal left/right safe areas pull the logo and Contact
button inward off the viewport edges. Because the inset
is symmetric, the 1fr side columns shrink equally and the
/* Fixed 20px safe area on both sides — matches the Hero
card and Footer's outer band exactly (both use a flat
20px inset, uncapped, at every desktop width), so the
header aligns with the rest of the page instead of
drifting wider on large screens. Symmetric, so the
centred nav does not shift. */
padding-inline: clamp(24px, 3vw, 48px) !important;
padding-inline: 20px !important;
/* Consistent top/bottom breathing room inside the bar; with
align-items:center every section stays vertically centred. */
padding-block: clamp(8px, 1vw, 16px) !important;
@@ -833,9 +835,13 @@ export default function Header() {
align-items: center !important;
}
/* navbar-right: Contact button, aligned to the right edge. */
/* navbar-right: Contact button, aligned to the right edge.
The vendor kit's own --margin-right:3% (site.css) would add
extra drift off the grid's 20px safe area — zero it so the
button's true right edge lands flush with that 20px inset. */
#masthead .elementor-element.elementor-element-f961133 {
justify-self: end !important;
margin-right: 0 !important;
}
}

View File

@@ -68,7 +68,7 @@ export default function MileTruthHero() {
flex-direction: column;
justify-content: center;
border-radius: 25px 25px 0 0;
margin-top: 20px;
margin-top: 28px;
padding: 100px 0;
}
.miletruth-hero-container::before {

View File

@@ -0,0 +1,62 @@
/* "The Doormile Way" — eyebrow + hairline divider + a huge display/graphic
headline (transparent fill, thin red stroke), composed as a poster-scale
typographic element rather than a plain large heading. Font, scale, and
line length are deliberately tuned to the reference composition, not
derived from a generic fluid-fill formula. */
.header {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.kicker {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.eyebrow {
margin: 0 0 18px 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 2.5px;
text-transform: uppercase;
color: rgba(23, 20, 15, 0.6);
}
.divider {
width: 100%;
height: 1px;
margin: 0 0 40px 0;
border: none;
background-color: rgba(23, 20, 15, 0.16);
}
.headingWrap {
width: 100%;
}
/* !important beats the sitewide `.elementor-kit-5 h2` rule
(public/css/site.css:48-56, specificity 0-1-1 > this single class). */
.heading {
margin: 0;
max-width: 1220px;
font-family: var(--font-syne), "Syne", sans-serif !important;
font-weight: 800 !important;
font-style: normal !important;
letter-spacing: -0.03em !important;
line-height: 0.86 !important;
text-transform: none !important;
color: transparent !important;
-webkit-text-stroke: 2px #c01227;
font-size: clamp(72px, 14vw, 240px) !important;
}
@media (max-width: 640px) {
.heading {
-webkit-text-stroke-width: 1.5px;
}
}

View File

@@ -2,6 +2,7 @@
import React from "react";
import { ScrollReveal, StaggerChildren } from "@/animations/Reveal";
import styles from "./TheDoormileWay.module.css";
export default function TheDoormileWay() {
return (
@@ -10,29 +11,15 @@ export default function TheDoormileWay() {
<div className="elementor-element elementor-element-88745f4 e-flex e-con-boxed cut-corner-no sticky-container-off e-con e-parent" data-id="88745f4" data-element_type="container" data-e-type="container">
<div className="e-con-inner">
<div className="elementor-element elementor-element-343b363 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="343b363" data-element_type="container" data-e-type="container">
<ScrollReveal delay={0.05} duration={0.7} yOffset={20}>
<div className="elementor-element elementor-element-7afb238 elementor-widget elementor-widget-logico_heading" data-id="7afb238" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
<div className="elementor-widget-container">
<div className="logico-title">/ Doormile Approach /</div>
</div>
</div>
</ScrollReveal>
<ScrollReveal delay={0.15} duration={0.85} yOffset={30}>
<div className="elementor-element elementor-element-1cc335a elementor-widget elementor-widget-logico_heading" data-id="1cc335a" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
<div className="elementor-widget-container">
<div
className="logico-title"
style={{
WebkitTextStroke: "4px #c01227",
color: "#fff",
fontWeight: 800,
}}
>
The Doormile Way
</div>
</div>
</div>
</ScrollReveal>
<div className={styles.header}>
<ScrollReveal delay={0.05} duration={0.7} yOffset={20} className={styles.kicker}>
<p className={styles.eyebrow}>/ Doormile Approach /</p>
<hr className={styles.divider} />
</ScrollReveal>
<ScrollReveal delay={0.15} duration={0.85} yOffset={30} className={styles.headingWrap}>
<h2 className={styles.heading}>The Doormile Way</h2>
</ScrollReveal>
</div>
<div className="elementor-element elementor-element-4c3e632 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="4c3e632" data-element_type="container" data-e-type="container">
<ScrollReveal delay={0.2} duration={0.8} yOffset={25}>
<div className="elementor-element elementor-element-5ba6bbf e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="5ba6bbf" data-element_type="container" data-e-type="container">

View File

@@ -54,7 +54,7 @@ export default function WingsFeatureGrid() {
__html: `
.wfg-section {
background: #ffffff;
padding: var(--space-section-lg, 80px) clamp(20px, 5vw, 40px);
padding: var(--space-section-lg, 80px) 0;
}
.wfg-header {
@@ -82,9 +82,12 @@ export default function WingsFeatureGrid() {
margin: 0;
}
.wfg-grid {
max-width: 1280px;
.wfg-inner {
max-width: 1380px;
margin: 0 auto;
}
.wfg-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
@@ -139,26 +142,35 @@ export default function WingsFeatureGrid() {
@media (max-width: 600px) {
.wfg-grid { grid-template-columns: 1fr; }
/* var(--space-section-lg) floors at a fixed 52px on any phone
width (clamp() has no further breakpoint-based step-down), which
reads as noticeably heavier than the section rhythm near the
footer. Trimmed to a real mobile value here instead. */
.wfg-section { padding-top: 44px; padding-bottom: 44px; }
}
`,
}}
/>
<section className="wfg-section" id="wings-features">
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className="wfg-header">
<div className="wfg-eyebrow">/ Why Businesses Choose Doormile Wings /</div>
<h2 className="wfg-title">Built to be depended on</h2>
</ScrollReveal>
<div className="dm-section-container">
<div className="wfg-inner">
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className="wfg-header">
<div className="wfg-eyebrow">/ Why Businesses Choose Doormile Wings /</div>
<h2 className="wfg-title">Built to be depended on</h2>
</ScrollReveal>
<StaggerChildren stagger={0.1} duration={0.7} yOffset={30} className="wfg-grid">
{FEATURES.map((f) => (
<div key={f.title} className="wfg-card">
<div className="wfg-icon" aria-hidden="true">{f.icon}</div>
<h3 className="wfg-card-title">{f.title}</h3>
<p className="wfg-card-desc">{f.desc}</p>
</div>
))}
</StaggerChildren>
<StaggerChildren stagger={0.1} duration={0.7} yOffset={30} className="wfg-grid">
{FEATURES.map((f) => (
<div key={f.title} className="wfg-card">
<div className="wfg-icon" aria-hidden="true">{f.icon}</div>
<h3 className="wfg-card-title">{f.title}</h3>
<p className="wfg-card-desc">{f.desc}</p>
</div>
))}
</StaggerChildren>
</div>
</div>
</section>
</>
);

View File

@@ -6,6 +6,15 @@ export default function WingsFinalCTA() {
<style
dangerouslySetInnerHTML={{
__html: `
/* dm-section-container replaces the vendor .we-container class here
(which is otherwise reused verbatim on the About page CTA), so this
section's edges line up with the Footer's shared container instead
of that widget's own 1320px width. It carries no vertical margin,
so restore the same top spacing .we-container provided. */
.wfcta-container {
margin-top: 50px;
}
.wfcta-box {
position: relative;
overflow: hidden;
@@ -46,12 +55,28 @@ export default function WingsFinalCTA() {
@media (max-width: 768px) {
.wfcta-container {
margin-top: 40px !important;
margin-bottom: 40px !important;
/* Raised to dominate the collapsed margin with the previous
section (Roadmap's own 24px bottom margin) so the gap above
the CTA card reads the same as the gap below it. */
margin-top: 14px !important;
/* There's ~110px of unexplained extra space rendered between this
container and the Footer that follows on mobile (verified with
Playwright — no element/padding/margin in the DOM accounts for
it, so it isn't coming from either section's own box). Pulling
the CTA up with a negative bottom margin closes most of that
gap directly instead of chasing the underlying cause, without
touching the shared Footer component (used on every page) or
either card's internal padding. Tuned so the resulting visible
gap below the card matches the ~44px gap above it. */
margin-bottom: -96px !important;
padding: 0 10px !important;
}
.wfcta-box {
padding: 48px 20px !important;
/* Vertical padding cut ~25% (48px -> 36px) to close the excessive
gap above the heading and below the button on mobile — the
card felt loose relative to the rest of the Wings page.
Horizontal padding/border-radius/width untouched. */
padding: 36px 20px !important;
border-radius: 22px !important;
}
.wfcta-title {
@@ -68,7 +93,7 @@ export default function WingsFinalCTA() {
`,
}}
/>
<div className="we-container wfcta-container" id="wings-final-cta">
<div className="dm-section-container wfcta-container" id="wings-final-cta">
<div className="we-cta wfcta-box">
<div className="we-cta-inner">
<h2 className="we-cta-title wfcta-title">

View File

@@ -49,6 +49,20 @@ const FEATURES: EVFeature[] = [
export default function WingsFleetStrategy() {
return (
<div id="wings-fleet">
{/* EVSection is shared across other pages (Workflow1/Workflow2 etc.),
so its own .evnd-gap-bottom rule isn't touched — this override is
scoped to #wings-fleet only, and reduces just the blank margin
immediately before Infrastructure by ~42% (24px -> 14px) on
mobile, matching the site's tighter mobile rhythm elsewhere. */}
<style
dangerouslySetInnerHTML={{
__html: `
@media (max-width: 767px) {
#wings-fleet .evnd-gap-bottom { margin-bottom: 14px !important; }
}
`,
}}
/>
<EVSection
bannerImage="/images/pahse1.png"
cardNumber="Phase 1"
@@ -61,29 +75,29 @@ export default function WingsFleetStrategy() {
metrics={METRICS}
cardsHeading="FLEET SPECIFICATIONS"
cardsTheme={{ accent: "#c8102e", accent2: "#ff6a3d", glow: "rgba(200,16,46,0.22)" }}
gapTop
gapBottom
badges={[
{ value: "3", label: "Phase 1 Aircraft" },
{ value: "25", label: "Phase 2 Vision" },
]}
mediaSlot={
<div
<video
autoPlay
loop
muted
playsInline
style={{
width: "100%",
height: "100%",
minHeight: 420,
background: "linear-gradient(150deg, #17171b 0%, #0b0b0d 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
objectFit: "cover",
display: "block",
background: "#17171b"
}}
aria-hidden="true"
>
{/* Placeholder — swap for sourced ATR72/Q400 photography or a
designed illustration once available. */}
<svg width="88" height="88" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-6l-4-9-2 1 3 8H5l-2-3-1.5.6L3 14l1.5 4.4L6 18l-2-3h8l3 8 2-1-3-8h6z" />
</svg>
</div>
<source src="/videos/fleet.mp4" type="video/mp4" />
</video>
}
ariaLabel="Doormile Wings fleet strategy"
/>

View File

@@ -36,23 +36,29 @@ export default function WingsHero() {
return;
}
gsap.fromTo(
heading,
{ y: 55, opacity: 0, scale: 0.95 },
{ y: 0, opacity: 1, scale: 1, duration: 1.1, ease: "power4.out" }
);
if (heading) {
gsap.fromTo(
heading,
{ y: 55, opacity: 0, scale: 0.95 },
{ y: 0, opacity: 1, scale: 1, duration: 1.1, ease: "power4.out" }
);
}
gsap.fromTo(
text,
{ y: 30, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.95, ease: "power3.out", delay: 0.25 }
);
if (text) {
gsap.fromTo(
text,
{ y: 30, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.95, ease: "power3.out", delay: 0.25 }
);
}
gsap.fromTo(
actions,
{ y: 24, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.85, ease: "power3.out", delay: 0.38 }
);
if (actions) {
gsap.fromTo(
actions,
{ y: 24, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.85, ease: "power3.out", delay: 0.38 }
);
}
}, [activeSlide]);
const handleDotClick = (index: number) => {
@@ -72,14 +78,14 @@ export default function WingsHero() {
__html: `
.wings-hero-bg.elementor-repeater-item-3264830,
.wings-hero-bg.elementor-repeater-item-6867061 {
background-image: linear-gradient(rgba(0, 0, 0, 0.62), rgba(0, 0, 0, 0.16)), url('/images/wings-heroslider3.png') !important;
background-image: linear-gradient(rgba(0, 0, 0, 0.38), rgba(0, 0, 0, 0.06)), url('/images/wings-heroslider3.png') !important;
background-position: center !important;
background-repeat: no-repeat !important;
background-size: cover !important;
}
.wings-hero-bg.elementor-repeater-item-6867061 {
background-image: linear-gradient(rgba(0, 0, 0, 0.62), rgba(0, 0, 0, 0.16)), url('/images/wings-heroslide2.png') !important;
background-image: linear-gradient(rgba(0, 0, 0, 0.38), rgba(0, 0, 0, 0.06)), url('/images/wings-heroslide2.png') !important;
}
#doormile-wings,
@@ -125,7 +131,7 @@ export default function WingsHero() {
align-items: center !important;
text-align: center !important;
width: 100% !important;
max-width: 1040px !important;
max-width: 1000px !important;
margin: 0 auto !important;
box-sizing: border-box !important;
}
@@ -157,7 +163,7 @@ export default function WingsHero() {
.wings-hero-page .text-content {
text-align: center !important;
max-width: 760px !important;
max-width: 680px !important;
width: 100% !important;
margin: 0 auto !important;
padding-left: 15px !important;
@@ -222,12 +228,15 @@ export default function WingsHero() {
@media (min-width: 1025px) {
.wings-hero-page.elementor-element-741f56c {
padding: 20px !important;
padding: 20px 24px !important;
margin-top: 8px !important;
}
.wings-hero-page .owl-carousel.owl-theme .content-item {
height: 800px !important;
min-height: 800px !important;
border-radius: 24px !important;
overflow: hidden !important;
}
}
@@ -245,19 +254,19 @@ export default function WingsHero() {
}
.wings-hero-page .owl-stage-outer {
height: 620px !important;
height: 600px !important;
}
.wings-hero-page .owl-carousel.owl-theme .content-item {
width: 100% !important;
min-height: 620px !important;
height: 620px !important;
min-height: 600px !important;
height: 600px !important;
border-radius: 25px !important;
overflow: hidden !important;
}
.wings-hero-page .slide-content {
min-height: 620px !important;
min-height: 600px !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
@@ -284,13 +293,13 @@ export default function WingsHero() {
}
.wings-hero-page .owl-stage-outer {
height: 560px !important;
height: 520px !important;
}
.wings-hero-page .owl-carousel.owl-theme .content-item,
.wings-hero-page .slide-content {
min-height: 560px !important;
height: 560px !important;
min-height: 520px !important;
height: 520px !important;
border-radius: 22px !important;
}
@@ -303,7 +312,7 @@ export default function WingsHero() {
.wings-hero-page .content-slider-item-heading {
max-width: 100% !important;
font-size: clamp(24px, 7.2vw, 32px) !important;
font-size: clamp(20px, 7vw, 32px) !important;
line-height: 1.12 !important;
}

View File

@@ -0,0 +1,292 @@
/* Premium editorial "How It Works" — bright warm-white surface, huge uppercase
display headline, top-right slide counter, and a five-column process row
divided by hairlines with oversized outlined numerals pinned to the top and
copy pinned to the bottom, echoing a tall, breathing editorial layout. */
.section {
background: linear-gradient(180deg, #fbfaf7 0%, #f5f2ec 100%);
padding: clamp(56px, 7vw, 96px) 0 clamp(80px, 8vw, 120px);
margin-bottom: 40px;
margin-right: 24px;
margin-left: 24px;
border-radius: 30px;
position: relative;
overflow: hidden;
}
.sectionShell {
width: 100%;
max-width: 1420px;
margin: 0 auto;
padding: 0 5vw;
box-sizing: border-box;
position: relative;
}
@media (max-width: 1024px) {
.sectionShell {
padding: 0 24px;
}
}
/* ---------- Header: eyebrow + giant display headline, counter top-right ---------- */
.headerRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 32px;
margin-bottom: clamp(48px, 6vw, 88px);
}
.header {
text-align: left;
max-width: 780px;
margin: 0;
}
.eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: lowercase;
color: rgba(20, 18, 15, 0.5);
margin-bottom: 22px;
}
.title {
font-size: clamp(32px, 4.6vw, 68px) !important;
font-weight: 800 !important;
letter-spacing: -0.01em !important;
line-height: 1.02 !important;
text-transform: uppercase !important;
color: #17140f !important;
margin: 0;
}
.progress {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
margin-top: 4px;
}
.progressCount {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
color: #17140f;
white-space: nowrap;
}
.progressTrack {
position: relative;
width: 110px;
height: 2px;
border-radius: 2px;
background: rgba(23, 20, 15, 0.12);
overflow: hidden;
}
.progressFill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #c8102e;
transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
/* ---------- Process row: hairline-divided columns, numeral up top, copy at the bottom ---------- */
.flow {
width: 100%;
display: flex;
align-items: stretch;
}
.step {
position: relative;
flex: 1 1 0;
min-width: 0;
min-height: clamp(200px, 18vw, 260px);
display: grid;
grid-template-rows: auto 1fr auto;
padding: 0 32px;
border-left: 1px solid rgba(23, 20, 15, 0.14);
cursor: pointer;
}
.step:first-child {
padding-left: 0;
border-left: none;
}
.step:last-child {
padding-right: 0;
}
.step:focus-visible {
outline: 2px solid rgba(200, 16, 46, 0.55);
outline-offset: 8px;
border-radius: 4px;
}
.stepTop {
display: flex;
align-items: flex-start;
gap: 8px;
}
.stepNum {
font-size: clamp(44px, 5.1vw, 92px);
font-weight: 800;
line-height: 0.85;
letter-spacing: -0.02em;
color: transparent;
-webkit-text-stroke: 1.5px rgba(23, 20, 15, 0.22);
transition: -webkit-text-stroke-color 0.4s ease, filter 0.4s ease;
}
.stepActive .stepNum {
-webkit-text-stroke-color: #c8102e;
filter: drop-shadow(0 0 14px rgba(200, 16, 46, 0.18));
}
.stepTag {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: rgba(23, 20, 15, 0.4);
transition: color 0.4s ease;
}
.stepIcon {
display: flex;
align-items: center;
justify-content: center;
color: inherit;
}
.stepActive .stepTag {
color: #c8102e;
}
.stepBottom {
align-self: end;
max-width: 280px;
}
.stepTitle {
font-size: 19px !important;
font-weight: 700 !important;
line-height: 1.3 !important;
text-transform: none !important;
color: #17140f !important;
margin: 0 0 10px 0 !important;
}
.stepDesc {
font-size: 13.5px;
line-height: 1.65;
color: rgba(23, 20, 15, 0.55);
margin: 0;
}
@media (max-width: 1024px) {
.headerRow {
flex-direction: column;
align-items: flex-start;
gap: 24px;
}
.progress {
align-items: flex-start;
}
.flow {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 40px 28px;
}
.step {
flex: none;
border-left: none;
padding: 0;
min-height: 0;
display: block;
}
.stepBottom {
max-width: none;
margin-top: 24px;
}
.step:nth-child(n + 3) {
border-top: 1px solid rgba(23, 20, 15, 0.14);
padding-top: 28px;
}
.flow > .step:last-child {
grid-column: 1 / -1;
}
}
@media (max-width: 600px) {
/* The clamp() on .section floors at a fixed 56px/80px on any phone width
(no further breakpoint-based step-down), noticeably heavier than the
rhythm every other Wings section settles into on mobile — normalized to
the same 44px/44px here.
.section's own margin-left/right also stays fixed at 24px below 1024px
(set at the top of this file, for the card's inset from the viewport
edge). Stacked with .sectionShell's padding that put the card's content
34px from the screen edge on phones — noticeably narrower than the
Footer's real 10px edge gutter (--dm-mobile-gutter, see globals.css's
.dm-section-container). Moving the full 10px gutter onto .section's
margin reproduces that same 10px screen-to-card edge, so the card sits
flush with its neighbors. .sectionShell then gets its own small 10px
breathing-room padding (independent of the outer gutter) so the eyebrow,
heading, progress indicator, and step content aren't flush against the
card's inner edge. */
.section {
padding-top: 44px;
padding-bottom: 44px;
margin-left: var(--dm-mobile-gutter, 10px);
margin-right: var(--dm-mobile-gutter, 10px);
}
.sectionShell {
padding-left: 10px;
padding-right: 10px;
}
.flow {
display: flex;
flex-direction: column;
}
.step {
padding: 28px 0;
}
.step:first-child {
padding-top: 0;
}
.step:not(:first-child) {
border-top: 1px solid rgba(23, 20, 15, 0.14);
}
.step:last-child {
padding-bottom: 0;
}
}

View File

@@ -1,14 +1,15 @@
"use client";
import { useState } from "react";
import { ScrollReveal } from "@/animations/Reveal";
import { ScrollReveal, StaggerChildren } from "@/animations/Reveal";
import styles from "./WingsHowItWorks.module.css";
const STEPS = [
{
label: "Local Pickup",
desc: "Fast, on-demand first-mile logistics dispatched by electric vehicle.",
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 13h13l3 4h2v3H3z" />
<circle cx="7" cy="20" r="1.6" />
<circle cx="16" cy="20" r="1.6" />
@@ -20,7 +21,7 @@ const STEPS = [
label: "Airport Clearance",
desc: "Automated screening, grouping, and rapid cross-docking at the hub.",
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
<path d="m9 12 2 2 4-4" />
</svg>
@@ -30,7 +31,7 @@ const STEPS = [
label: "Air Transit",
desc: "Optimized cargo flights bypass surface congestion on our regional fleet.",
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-6l-4-9-2 1 3 8H5l-2-3-1.5.6L3 14l1.5 4.4L6 18l-2-3h8l3 8 2-1-3-8h6z" />
</svg>
),
@@ -39,7 +40,7 @@ const STEPS = [
label: "Destination Hub",
desc: "Immediate de-consolidation and screening upon arrival.",
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="10" width="16" height="11" rx="1" />
<path d="M9 21v-6h6v6M4 10 12 3l8 7" />
</svg>
@@ -49,7 +50,7 @@ const STEPS = [
label: "EV Last-Mile",
desc: "Zero-emission electric vehicles complete the final delivery.",
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 13h13l3 4h2v3H3z" />
<circle cx="7" cy="20" r="1.6" />
<circle cx="16" cy="20" r="1.6" />
@@ -61,246 +62,63 @@ const STEPS = [
export default function WingsHowItWorks() {
const [active, setActive] = useState(0);
const fillPct = ((active + 1) / STEPS.length) * 100;
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `
.whw-section {
background: #09090b;
padding: var(--space-section-lg, 80px) clamp(20px, 5vw, 40px);
}
<section className={styles.section} id="wings-how-it-works">
<div className={styles.sectionShell}>
<div className={styles.headerRow}>
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className={styles.header}>
<div className={styles.eyebrow}>/ How It Works /</div>
<h2 className={styles.title}>
Pickup to
<br />
delivery, one
<br />
connected flow
</h2>
</ScrollReveal>
.whw-header {
margin: 0 auto 56px auto;
max-width: 720px;
text-align: center;
}
.whw-eyebrow {
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: #c8102e;
margin-bottom: 16px;
}
.whw-title {
font-size: clamp(28px, 3.2vw, 46px) !important;
font-weight: 800 !important;
letter-spacing: -0.03em !important;
line-height: 1.15 !important;
text-transform: none !important;
color: #ffffff !important;
margin: 0;
}
.whw-inner {
max-width: 1200px;
margin: 0 auto;
}
.whw-track-wrap {
position: relative;
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
margin: 0 auto 44px auto;
}
.whw-track-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: #c8102e;
box-shadow: 0 0 10px #c8102e;
border-radius: 2px;
transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.whw-nodes {
position: absolute;
inset: 0;
}
.whw-node {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
border-radius: 50%;
background: #18181b;
border: 2px solid rgba(255, 255, 255, 0.2);
transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
}
.whw-node.is-active {
background: #c8102e;
border-color: #ffffff;
box-shadow: 0 0 10px #c8102e;
}
.whw-steps {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 20px;
}
.whw-step {
min-width: 0;
padding: 22px 18px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
cursor: pointer;
transition: border-color 0.35s ease, background-color 0.35s ease, transform 0.35s ease;
}
.whw-step.is-active {
border-color: rgba(200, 16, 46, 0.5);
background: rgba(200, 16, 46, 0.06);
transform: translateY(-4px);
}
.whw-step-num {
font-size: 12px;
font-weight: 800;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 14px;
}
.whw-step.is-active .whw-step-num {
color: #c8102e;
}
.whw-step-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
margin-bottom: 14px;
}
.whw-step.is-active .whw-step-icon {
background: rgba(200, 16, 46, 0.15);
color: #ffffff;
}
.whw-step-label {
font-size: 15px !important;
font-weight: 700 !important;
line-height: 1.3 !important;
text-transform: none !important;
color: #ffffff !important;
margin: 0 0 6px 0;
}
.whw-step-desc {
font-size: 12.5px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
@media (max-width: 767px) {
.whw-track-wrap { display: none; }
.whw-steps {
grid-template-columns: 1fr;
gap: 16px;
position: relative;
padding-left: 28px;
}
.whw-steps::before {
content: '';
position: absolute;
left: 5px;
top: 8px;
bottom: 8px;
width: 2px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
}
.whw-step {
position: relative;
}
.whw-step::before {
content: '';
position: absolute;
left: -27px;
top: 24px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #18181b;
border: 2px solid rgba(255, 255, 255, 0.2);
}
.whw-step.is-active::before {
background: #c8102e;
border-color: #ffffff;
box-shadow: 0 0 10px rgba(200, 16, 46, 0.7);
}
}
`,
}}
/>
<section className="whw-section" id="wings-how-it-works">
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className="whw-header">
<div className="whw-eyebrow">/ How It Works /</div>
<h2 className="whw-title">Pickup to delivery, one connected flow</h2>
</ScrollReveal>
<ScrollReveal delay={0.1} duration={0.8} yOffset={30} className="whw-inner">
<div className="whw-track-wrap" aria-hidden="true">
<div className="whw-track-fill" style={{ width: `${fillPct}%` }} />
<div className="whw-nodes">
{STEPS.map((_, i) => (
<div
key={i}
className={`whw-node${i <= active ? " is-active" : ""}`}
style={{ left: `${((i + 0.5) / STEPS.length) * 100}%` }}
/>
))}
</div>
<div className={styles.progress} aria-hidden="true">
<span className={styles.progressCount}>
{String(active + 1).padStart(2, "0")} / {String(STEPS.length).padStart(2, "0")}
</span>
<span className={styles.progressTrack}>
<span
className={styles.progressFill}
style={{ width: `${((active + 1) / STEPS.length) * 100}%` }}
/>
</span>
</div>
</div>
<div className="whw-steps">
{STEPS.map((step, i) => (
<div
key={step.label}
className={`whw-step${i === active ? " is-active" : ""}`}
onMouseEnter={() => setActive(i)}
onClick={() => setActive(i)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setActive(i);
}}
>
<div className="whw-step-num">0{i + 1}</div>
<div className="whw-step-icon" aria-hidden="true">{step.icon}</div>
<h3 className="whw-step-label">{step.label}</h3>
<p className="whw-step-desc">{step.desc}</p>
<StaggerChildren stagger={0.06} duration={0.6} yOffset={24} className={styles.flow}>
{STEPS.map((step, i) => (
<div
key={step.label}
className={`${styles.step} ${i === active ? styles.stepActive : ""}`}
onMouseEnter={() => setActive(i)}
onClick={() => setActive(i)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setActive(i);
}}
>
<div className={styles.stepTop}>
<span className={styles.stepNum}>{String(i + 1).padStart(2, "0")}</span>
<span className={styles.stepTag}>
<span className={styles.stepIcon} aria-hidden="true">{step.icon}</span>
Step
</span>
</div>
))}
</div>
</ScrollReveal>
</section>
</>
<div className={styles.stepBottom}>
<h3 className={styles.stepTitle}>{step.label}</h3>
<p className={styles.stepDesc}>{step.desc}</p>
</div>
</div>
))}
</StaggerChildren>
</div>
</section>
);
}

View File

@@ -5,31 +5,37 @@ const INDUSTRIES = [
title: "Quick Commerce",
challenge: "Sub-3-hour delivery windows for dark stores and micro-warehouses.",
solution: "Dedicated same-day air capacity linking metros to fulfillment hubs.",
image: "/images/card 1.png",
},
{
title: "Express B2B Cargo",
challenge: "Time-critical industrial parts and components stuck on slow trunk routes.",
solution: "Rapid airport-to-airport transport between major economic nodes.",
image: "/images/card 2.png",
},
{
title: "Healthcare & Pharma",
challenge: "Temperature-sensitive, compliance-critical shipments with zero margin for error.",
solution: "Active and passive cold-chain packaging with full chain-of-custody tracking.",
image: "/images/card 3.png",
},
{
title: "Cold Chain Logistics",
challenge: "Perishables and fresh goods that can't survive slow surface transit.",
solution: "Precision temperature tracking matched to airline-speed transit.",
image: "/images/card4.png",
},
{
title: "High-Value Cargo",
challenge: "Zero tolerance for loss, tampering, or delay on high-value shipments.",
solution: "Maximum-security handling with real-time electronic custody tracking.",
image: "/images/card5.png",
},
{
title: "E-Commerce Logistics",
challenge: "Next-day and same-day fulfillment expectations across tier-1 and tier-2 cities.",
solution: "Direct sorting-hub-to-airport clearance network for faster regional delivery.",
image: "/images/card6.png",
},
];
@@ -41,7 +47,7 @@ export default function WingsIndustries() {
__html: `
.wind-section {
background: #fafafa;
padding: var(--space-section-lg, 80px) clamp(20px, 5vw, 40px);
padding: var(--space-section-lg, 80px) 0 20px 0; /* Reduced bottom padding to minimize gap */
}
.wind-header {
@@ -69,9 +75,12 @@ export default function WingsIndustries() {
margin: 0;
}
.wind-grid {
max-width: 1280px;
.wind-inner {
max-width: 1380px;
margin: 0 auto;
}
.wind-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
@@ -172,40 +181,57 @@ export default function WingsIndustries() {
@media (max-width: 600px) {
.wind-grid { grid-template-columns: 1fr; }
.wind-card { aspect-ratio: 3 / 4; }
/* var(--space-section-lg) floors at a fixed 52px on any phone
width; bottom is already trimmed to 20px, so only the top needs
a real mobile reduction to match the section rhythm near the
footer. */
.wind-section { padding-top: 44px; }
}
`,
}}
/>
<section className="wind-section" id="wings-industries">
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className="wind-header">
<div className="wind-eyebrow">/ Industries We Serve /</div>
<h2 className="wind-title">Built for the shipments that can&rsquo;t wait</h2>
</ScrollReveal>
<div className="dm-section-container">
<div className="wind-inner">
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className="wind-header">
<div className="wind-eyebrow">/ Industries We Serve /</div>
<h2 className="wind-title">Built for the shipments that can&rsquo;t wait</h2>
</ScrollReveal>
<ScrollReveal delay={0.1} duration={0.8} yOffset={30} className="wind-grid">
{INDUSTRIES.map((ind) => (
<div key={ind.title} className="wind-card">
<div className="wind-card-wash" aria-hidden="true" />
<ScrollReveal delay={0.1} duration={0.8} yOffset={30} className="wind-grid">
{INDUSTRIES.map((ind) => (
<div
key={ind.title}
className="wind-card"
style={ind.image ? {
backgroundImage: `linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.8)), url('${ind.image}')`,
backgroundSize: 'cover',
backgroundPosition: 'center'
} : undefined}
>
<div className="wind-card-wash" aria-hidden="true" />
<div className="wind-card-default">
<h3 className="wind-card-default-title">{ind.title}</h3>
</div>
<div className="wind-card-default">
<h3 className="wind-card-default-title">{ind.title}</h3>
</div>
<div className="wind-card-hover">
<h3 className="wind-card-hover-title">{ind.title}</h3>
<div>
<p className="wind-card-label">Challenge</p>
<p className="wind-card-text">{ind.challenge}</p>
<div className="wind-card-hover">
<h3 className="wind-card-hover-title">{ind.title}</h3>
<div>
<p className="wind-card-label">Challenge</p>
<p className="wind-card-text">{ind.challenge}</p>
</div>
<div>
<p className="wind-card-label">Doormile Wings</p>
<p className="wind-card-text">{ind.solution}</p>
</div>
</div>
</div>
<div>
<p className="wind-card-label">Doormile Wings</p>
<p className="wind-card-text">{ind.solution}</p>
</div>
</div>
</div>
))}
</ScrollReveal>
))}
</ScrollReveal>
</div>
</div>
</section>
</>
);

View File

@@ -0,0 +1,247 @@
.section {
background: #09090b;
padding: var(--space-section-lg, 100px) 0;
margin-bottom: 30px;
margin-right: 24px;
margin-left: 24px;
border-radius: 30px;
position: relative;
overflow: hidden;
}
/* Ambient depth: calm, near-static light (stable mood) instead of the more
energetic red glow used on How It Works, plus a matching vignette. */
.section::before {
content: "";
position: absolute;
top: -18%;
left: 50%;
width: 58%;
aspect-ratio: 1;
background: radial-gradient(circle, rgba(255, 255, 255, 0.05) 0%, rgba(200, 16, 46, 0.05) 40%, rgba(0, 0, 0, 0) 68%);
transform: translateX(-50%);
filter: blur(8px);
pointer-events: none;
z-index: 0;
animation: infraGlowDrift 14s ease-in-out infinite alternate;
}
.section::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(120% 90% at 50% 0%, transparent 50%, rgba(0, 0, 0, 0.5) 100%);
pointer-events: none;
z-index: 0;
}
@keyframes infraGlowDrift {
0% {
transform: translateX(-52%) translateY(0);
opacity: 0.7;
}
100% {
transform: translateX(-48%) translateY(10px);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.section::before {
animation: none;
}
}
/*
* Single page-alignment wrapper. Numbers verified against the Footer's real
* rendered content container (elementor-element-e183a77) with Playwright at
* 320-2560px: that row sits flush at a 20px gutter up to a 1420px cap, then
* centers beyond it, dropping to a 10px gutter under ~1024px. max-width:1420
* + padding:20px reproduces the same edge as a 20px-gutter + inner
* max-width:1380px pair in one box (no nested max-width needed). Kept
* byte-identical to WingsHowItWorks.module.css's .sectionShell.
*/
.sectionShell {
width: 100%;
max-width: 1420px;
margin: 0 auto;
padding: 0 5vw;
box-sizing: border-box;
position: relative;
z-index: 1;
}
@media (max-width: 1024px) {
.sectionShell {
padding: 0 24px;
}
}
.header {
width: 100%;
max-width: 700px;
margin: 0 auto 56px auto;
text-align: center;
}
.eyebrow {
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.45);
margin-bottom: 16px;
}
.title {
font-size: clamp(28px, 3.2vw, 46px) !important;
font-weight: 800 !important;
letter-spacing: -0.03em !important;
line-height: 1.15 !important;
text-transform: none !important;
color: #ffffff !important;
margin: 0;
}
/* Capability row — editorial columns divided by hairline rules, matching
How It Works' language but unnumbered (these aren't sequential steps). */
.grid {
width: 100%;
display: flex;
align-items: flex-start;
}
.card {
flex: 1 1 0;
min-width: 0;
padding: 0 30px;
border-left: 1px solid rgba(255, 255, 255, 0.09);
transition: border-color 0.4s ease;
}
.card:first-child {
padding-left: 0;
border-left: none;
}
.icon {
flex-shrink: 0;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.55);
margin-bottom: 26px;
transition: border-color 0.4s ease, color 0.4s ease, background-color 0.4s ease, box-shadow 0.4s ease;
}
.card:hover .icon {
border-color: rgba(200, 16, 46, 0.55);
color: #ffffff;
background: rgba(200, 16, 46, 0.12);
box-shadow: 0 0 18px rgba(200, 16, 46, 0.28);
}
.cardTitle {
font-size: 17px !important;
font-weight: 700 !important;
line-height: 1.3 !important;
text-transform: none !important;
color: rgba(255, 255, 255, 0.82) !important;
margin: 0 0 10px 0;
transition: color 0.4s ease;
}
.card:hover .cardTitle {
color: #ffffff !important;
}
.cardDesc {
font-size: 14px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.45);
margin: 0;
transition: color 0.4s ease;
}
.card:hover .cardDesc {
color: rgba(255, 255, 255, 0.62);
}
@media (max-width: 1024px) {
.section {
padding: var(--space-section, 64px) 0;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 34px 28px;
}
.card {
border-left: none;
padding: 0;
}
.card:nth-child(n + 3) {
border-top: 1px solid rgba(255, 255, 255, 0.09);
padding-top: 28px;
}
}
@media (max-width: 600px) {
/* var(--space-section) floors at a fixed 40px on any phone width (no
further breakpoint-based step-down), and .sectionShell's horizontal
padding otherwise stays fixed at 24px below 1024px — both read as
inconsistent with the rhythm every other Wings section settles into on
mobile. Normalized to match: 44px/44px vertical, and the shared
--dm-mobile-gutter (10px) horizontal.
.section's own margin-left/right also stays fixed at 24px below 1024px
(set at the top of this file, for the card's inset from the viewport
edge). Stacked with .sectionShell's padding that put the card's content
34px from the screen edge on phones — noticeably narrower than the
Footer's real 10px edge gutter (--dm-mobile-gutter, see globals.css's
.dm-section-container). Moving the full 10px gutter onto .section's
margin reproduces that same 10px screen-to-card edge, so the card sits
flush with its neighbors. .sectionShell then gets its own small 10px
breathing-room padding (independent of the outer gutter) so the eyebrow,
heading, icons, card titles/descriptions, and dividers (which span
.card's full width) aren't flush against the card's inner edge. */
.section {
padding-top: 44px;
padding-bottom: 44px;
margin-left: var(--dm-mobile-gutter, 10px);
margin-right: var(--dm-mobile-gutter, 10px);
}
.sectionShell {
padding-left: 10px;
padding-right: 10px;
}
.grid {
display: flex;
flex-direction: column;
}
.card {
padding: 28px 0;
}
.card:first-child {
padding-top: 0;
}
.card:not(:first-child) {
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
.card:last-child {
padding-bottom: 0;
}
}

View File

@@ -1,4 +1,5 @@
import { ScrollReveal, StaggerChildren } from "@/animations/Reveal";
import styles from "./WingsInfrastructure.module.css";
const CARDS = [
{
@@ -44,117 +45,23 @@ const CARDS = [
export default function WingsInfrastructure() {
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `
.wia-section {
background: #09090b;
padding: var(--space-section, 64px) clamp(20px, 5vw, 40px);
}
.wia-header {
margin: 0 auto 44px auto;
max-width: 720px;
text-align: center;
}
.wia-eyebrow {
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 16px;
}
.wia-title {
font-size: clamp(26px, 3vw, 40px) !important;
font-weight: 800 !important;
letter-spacing: -0.03em !important;
line-height: 1.2 !important;
text-transform: none !important;
color: #ffffff !important;
margin: 0;
}
.wia-grid {
max-width: 1280px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
}
.wia-card {
min-width: 0;
padding: 28px 24px;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 18px;
background: rgba(255, 255, 255, 0.02);
transition: border-color 0.3s ease, background-color 0.3s ease;
}
.wia-card:hover {
border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.035);
}
.wia-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
margin-bottom: 18px;
}
.wia-card-title {
font-size: 15.5px !important;
font-weight: 700 !important;
line-height: 1.3 !important;
text-transform: none !important;
color: #ffffff !important;
margin: 0 0 8px 0;
}
.wia-card-desc {
font-size: 13.5px;
line-height: 1.55;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
@media (max-width: 1024px) {
.wia-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 600px) {
.wia-grid { grid-template-columns: 1fr; }
}
`,
}}
/>
<section className="wia-section" id="wings-infrastructure">
<ScrollReveal delay={0.05} duration={0.7} yOffset={20} className="wia-header">
<div className="wia-eyebrow">/ Infrastructure /</div>
<h2 className="wia-title">A network built to move, not to sit idle</h2>
<section className={styles.section} id="wings-infrastructure">
<div className={styles.sectionShell}>
<ScrollReveal delay={0.05} duration={0.7} yOffset={20} className={styles.header}>
<div className={styles.eyebrow}>/ Infrastructure /</div>
<h2 className={styles.title}>A network built to move, not to sit idle</h2>
</ScrollReveal>
<StaggerChildren stagger={0.08} duration={0.6} yOffset={20} className="wia-grid">
<StaggerChildren stagger={0.08} duration={0.6} yOffset={20} className={styles.grid}>
{CARDS.map((c) => (
<div key={c.title} className="wia-card">
<div className="wia-icon" aria-hidden="true">{c.icon}</div>
<h3 className="wia-card-title">{c.title}</h3>
<p className="wia-card-desc">{c.desc}</p>
<div key={c.title} className={styles.card}>
<div className={styles.icon} aria-hidden="true">{c.icon}</div>
<h3 className={styles.cardTitle}>{c.title}</h3>
<p className={styles.cardDesc}>{c.desc}</p>
</div>
))}
</StaggerChildren>
</section>
</>
</div>
</section>
);
}

View File

@@ -160,7 +160,7 @@ export default function WingsMileTruth() {
.wmt-section {
position: relative;
width: calc(100% - 40px);
margin: 0 auto;
margin: clamp(24px, 4vw, 40px) auto;
border-radius: 28px;
overflow: hidden;
box-sizing: border-box;
@@ -195,7 +195,7 @@ export default function WingsMileTruth() {
.wmt-inner {
position: relative;
z-index: 1;
max-width: 1200px;
max-width: 1380px;
margin: 0 auto;
}
@@ -347,6 +347,11 @@ export default function WingsMileTruth() {
@media (max-width: 600px) {
.wmt-grid { grid-template-columns: 1fr; }
.wmt-callout { flex-direction: column; align-items: flex-start; }
/* The vertical clamp() floors at a fixed 64px on any phone width;
trimmed to a real mobile value to match the section rhythm near
the footer. Horizontal padding is left untouched (its own
clamp already resolves small on phones). */
.wmt-section { padding-top: 48px; padding-bottom: 48px; }
}
`,
}}
@@ -357,7 +362,7 @@ export default function WingsMileTruth() {
<div className="wmt-glow tl" aria-hidden="true" />
<div className="wmt-glow br" aria-hidden="true" />
<div className="wmt-inner">
<div className="wmt-inner dm-section-container">
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className="wmt-header">
<div className="wmt-eyebrow">/ MileTruth&trade; AI /</div>
<h2 className="wmt-title">The intelligence layer behind every delivery</h2>

View File

@@ -53,7 +53,7 @@ export default function WingsRoadmap() {
.wrm-section {
position: relative;
width: calc(100% - 40px);
margin: 0 auto;
margin: clamp(56px, 7vw, 96px) auto clamp(24px, 4vw, 40px);
border-radius: 28px;
overflow: hidden;
box-sizing: border-box;
@@ -87,7 +87,7 @@ export default function WingsRoadmap() {
}
.wrm-inner {
max-width: 1200px;
max-width: 1380px;
margin: 0 auto;
}
@@ -203,6 +203,16 @@ export default function WingsRoadmap() {
}
@media (max-width: 767px) {
/* Both the outer margin-top and the internal vertical padding
clamp()s floor at fixed, sizeable values (56px + 64px = ~120px
combined) on any phone width — noticeably heavier than the
section rhythm near the footer. Horizontal padding is left
untouched (its own clamp already resolves small on phones). */
.wrm-section {
margin-top: 40px;
padding-top: 48px;
padding-bottom: 48px;
}
.wrm-track-wrap { display: none; }
.wrm-grid {
grid-template-columns: 1fr;
@@ -244,43 +254,45 @@ export default function WingsRoadmap() {
/>
<section className="wrm-section" id="wings-roadmap">
<div className="wrm-header">
<div className="wrm-eyebrow">/ Future Roadmap /</div>
<h2 className="wrm-title">A ten-year path to national scale</h2>
</div>
<div className="wrm-inner">
<div className="wrm-track-wrap" aria-hidden="true">
<div className="wrm-track-fill" style={{ width: `${fillPct}%` }} />
<div className="wrm-nodes">
{ROADMAP_DATA.map((_, i) => (
<div
key={i}
className={`wrm-node${i <= active ? " is-active" : ""}`}
style={{ left: `${((i + 0.5) / ROADMAP_DATA.length) * 100}%` }}
/>
))}
</div>
<div className="dm-section-container">
<div className="wrm-header">
<div className="wrm-eyebrow">/ Future Roadmap /</div>
<h2 className="wrm-title">A ten-year path to national scale</h2>
</div>
<div className="wrm-grid">
{ROADMAP_DATA.map((item, i) => (
<div
key={item.year}
className={`wrm-card${item.glow ? " wrm-glow" : ""}${i === active ? " is-active" : ""}`}
onMouseEnter={() => setActive(i)}
onClick={() => setActive(i)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setActive(i);
}}
>
<div className="wrm-card-year">{item.year}</div>
<span className={`wrm-phase-pill ${item.phaseClass}`}>{item.phase}</span>
<p className="wrm-card-desc">{item.desc}</p>
<div className="wrm-inner">
<div className="wrm-track-wrap" aria-hidden="true">
<div className="wrm-track-fill" style={{ width: `${fillPct}%` }} />
<div className="wrm-nodes">
{ROADMAP_DATA.map((_, i) => (
<div
key={i}
className={`wrm-node${i <= active ? " is-active" : ""}`}
style={{ left: `${((i + 0.5) / ROADMAP_DATA.length) * 100}%` }}
/>
))}
</div>
))}
</div>
<div className="wrm-grid">
{ROADMAP_DATA.map((item, i) => (
<div
key={item.year}
className={`wrm-card${item.glow ? " wrm-glow" : ""}${i === active ? " is-active" : ""}`}
onMouseEnter={() => setActive(i)}
onClick={() => setActive(i)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setActive(i);
}}
>
<div className="wrm-card-year">{item.year}</div>
<span className={`wrm-phase-pill ${item.phaseClass}`}>{item.phase}</span>
<p className="wrm-card-desc">{item.desc}</p>
</div>
))}
</div>
</div>
</div>
</section>

View File

@@ -43,7 +43,7 @@ export default function WingsSpeedAdvantage() {
__html: `
.wsa-section {
background: #fafafa;
padding: var(--space-section-lg, 80px) clamp(20px, 5vw, 40px);
padding: var(--space-section-lg, 80px) 0;
}
.wsa-header {
@@ -71,10 +71,13 @@ export default function WingsSpeedAdvantage() {
margin: 0;
}
.wsa-inner {
max-width: 1380px;
margin: 0 auto;
}
.wsa-face-off {
position: relative;
max-width: 1100px;
margin: 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
@@ -159,39 +162,53 @@ export default function WingsSpeedAdvantage() {
@media (max-width: 767px) {
.wsa-face-off { grid-template-columns: 1fr; gap: 40px; }
.wsa-vs { position: static; transform: none; margin: -20px auto; }
/* Effective visible gap above/below the VS circle is (grid gap +
this margin), i.e. 40px - 28px = 12px on each side — an equal,
~40% reduction from the previous 20px/20px so the circle reads
as bridging the two cards instead of floating in a large empty
gap. margin: auto keeps it horizontally centered; the shorthand
applies identically to top and bottom so both stay equal. */
.wsa-vs { position: static; transform: none; margin: -28px auto; }
/* var(--space-section-lg) floors at a fixed 52px on any phone
width; trimmed to a real mobile value to match the section
rhythm near the footer. */
.wsa-section { padding-top: 44px; padding-bottom: 44px; }
}
`,
}}
/>
<section className="wsa-section" ref={sectionRef} id="wings-speed">
<div className="dm-section-container">
<div className="wsa-header" ref={headerRef}>
<div className="wsa-eyebrow">/ The Speed Advantage /</div>
<h2 className="wsa-title">Same-day, not some-day</h2>
</div>
<div className="wsa-face-off">
<div className="wsa-panel wsa-panel-surface" ref={leftRef}>
<div className="wsa-panel-label">Surface Logistics</div>
<div className="wsa-value">
48<CountUp end={96} duration={1.6} triggerOnce />
<div className="wsa-inner">
<div className="wsa-face-off">
<div className="wsa-panel wsa-panel-surface" ref={leftRef}>
<div className="wsa-panel-label">Surface Logistics</div>
<div className="wsa-value">
48<CountUp end={96} duration={1.6} triggerOnce />
</div>
<div className="wsa-unit">Hours</div>
<p className="wsa-panel-sub">DelhiMumbai standard surface transit time.</p>
</div>
<div className="wsa-unit">Hours</div>
<p className="wsa-panel-sub">DelhiMumbai standard surface transit time.</p>
</div>
<div className="wsa-vs" ref={vsRef}>VS</div>
<div className="wsa-vs" ref={vsRef}>VS</div>
<div className="wsa-panel wsa-panel-wings" ref={rightRef}>
<div className="wsa-panel-label">Doormile Wings</div>
<div className="wsa-value">
Max <CountUp end={3} duration={1.4} triggerOnce />
<div className="wsa-panel wsa-panel-wings" ref={rightRef}>
<div className="wsa-panel-label">Doormile Wings</div>
<div className="wsa-value">
Max <CountUp end={3} duration={1.4} triggerOnce />
</div>
<div className="wsa-unit">Hours, Door-to-Door</div>
<p className="wsa-panel-sub">Same-day delivery between major regional capitals.</p>
</div>
<div className="wsa-unit">Hours, Door-to-Door</div>
<p className="wsa-panel-sub">Same-day delivery between major regional capitals.</p>
</div>
</div>
</div>
</section>
</>
);

View File

@@ -1,5 +1,31 @@
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { MotionPathPlugin } from "gsap/MotionPathPlugin";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
import { ScrollReveal, StaggerChildren } from "@/animations/Reveal";
let hasDrawSVG = false;
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);
try {
// DrawSVGPlugin ships free with gsap 3.13+. Registration (not the import
// itself) is guarded so the draw step can fall back to plain
// stroke-dasharray if it's ever unavailable, without touching timing.
gsap.registerPlugin(DrawSVGPlugin);
hasDrawSVG = true;
} catch {
hasDrawSVG = false;
}
}
const MOBILE_BQ = 600;
const AIRCRAFT_ROTATION_OFFSET = 90; // glyph's rest pose points "up"
const WAVE_AMPLITUDE_MIN = 20;
const WAVE_AMPLITUDE_MAX = 38;
const FEATURES = [
{
title: "Same-Day Air Logistics",
@@ -48,7 +74,415 @@ const FEATURES = [
},
];
interface Point {
x: number;
y: number;
}
function buildPathD(points: Point[]): string {
if (points.length < 2) return "";
let d = `M ${points[0].x} ${points[0].y}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i];
const p1 = points[i + 1];
const dx = (p1.x - p0.x) / 2;
d += ` C ${p0.x + dx} ${p0.y}, ${p1.x - dx} ${p1.y}, ${p1.x} ${p1.y}`;
}
return d;
}
// Inserts one raised "crest" point between every consecutive pair of card
// anchors, turning the mostly-straight spline into an alternating
// trough-crest-trough wave: each card position stays a trough (so the pulse
// still arrives level with its icon, unchanged), while the gap between cards
// arcs upward. buildPathD's per-segment horizontal-tangent control points
// already keep every trough/crest transition smooth (no sharp bends) without
// needing a fancier spline — only the input point list changes.
//
// Each crest's height and horizontal position are nudged by a small,
// index-derived (not random) offset, so consecutive humps are never
// identical — reading as a real, slightly irregular air route rather than a
// generated sine wave — while staying perfectly stable across re-measures
// (resize, etc.) since the offsets depend only on segment index, not chance.
// Each offset sums two sine terms at unrelated frequencies/phases rather
// than one, so the sequence of crest heights doesn't itself read as a
// single obvious repeating rhythm.
function buildWavePoints(points: Point[], amplitude: number): Point[] {
if (points.length < 2 || amplitude <= 0) return points;
const waved: Point[] = [points[0]];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i];
const p1 = points[i + 1];
const ampFactor =
1 + 0.28 * Math.sin(i * 2.9 + 0.4) + 0.15 * Math.sin(i * 5.3 + 2.1);
const xJitter =
(p1.x - p0.x) * (0.08 * Math.sin(i * 1.7 + 1.3) + 0.06 * Math.sin(i * 3.6 + 0.2));
waved.push({
x: (p0.x + p1.x) / 2 + xJitter,
y: Math.min(p0.y, p1.y) - amplitude * ampFactor,
});
waved.push(p1);
}
return waved;
}
export default function WingsWhyGrid() {
const wrapRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const pathRef = useRef<SVGPathElement>(null);
// A second copy of the same "d", drawn brighter/thicker/blurred. A moving
// dasharray window (kept in sync with the pulse's own progress, see
// onUpdate below) makes a soft, feathered segment of the route glow right
// under the pulse and fade back to the resting line just behind it — the
// route being "energized" as the pulse passes, not just a static line.
const illumRef = useRef<SVGPathElement>(null);
// The traveler is the primary visual: a bright optical pulse riding the
// route. It's the outer group MotionPathPlugin translates. The aircraft is
// nested inside it as a small, secondary glyph — never its own focal point.
const travelerRef = useRef<SVGGElement>(null);
// Only this inner group rotates to face the direction of travel (the
// halo). Kept separate from travelerRef with an explicit 0,0 transform
// origin so rotation never pivots around a fill-box-derived point.
const directionalRef = useRef<SVGGElement>(null);
// The aircraft's own rotation wrapper, painted as a sibling *after* the
// core circle (see JSX) so its silhouette stays readable on top of the
// bright core instead of being buried under it. Mirrors directionalRef's
// rotation exactly (set alongside it in onUpdate) so both halves of the
// traveler always face the same way despite being separate DOM branches.
const aircraftDirectionalRef = useRef<SVGGElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const iconRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
const wrap = wrapRef.current;
const svg = svgRef.current;
const path = pathRef.current;
const illum = illumRef.current;
const traveler = travelerRef.current;
const directional = directionalRef.current;
const aircraftDirectional = aircraftDirectionalRef.current;
if (!wrap || !svg || !path || !illum || !traveler || !directional || !aircraftDirectional) return;
// Declared here (not inside the gsap.context callback below) so the
// cleanup function — which must run on every unmount, including ones
// gsap.context() itself doesn't know about, like these plain rAF/
// ResizeObserver handles — can always reach them.
let resizeRaf = 0;
let initialRaf = 0;
let rafIn = 0;
let ro: ResizeObserver | undefined;
let loadHandler: (() => void) | undefined;
// Also assigned (not re-declared) inside the gsap.context callback below,
// for the same reason: cleanup needs to reach them even though
// ctx.revert() already kills anything the context tracked, since the
// ScrollTrigger is created synchronously (tracked) but the flight
// timeline is only created later, asynchronously, once onEnter actually
// fires (not reliably tracked by the context in that case).
let st: ScrollTrigger | null = null;
let playedTimeline: gsap.core.Timeline | null = null;
// Everything GSAP-related (ScrollTrigger, timelines, gsap.set calls) is
// created inside this context so a single ctx.revert() on unmount kills
// all of it — this is what makes the whole setup safe to re-run on every
// mount (initial load, client-side route navigation, back/forward, hot
// reload) instead of only working after a hard refresh, where a prior
// mount's leftover ScrollTrigger/timeline could otherwise linger or hold
// a stale measurement.
const ctx = gsap.context(() => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let played = false;
const isMobileLayout = () => window.matchMedia(`(max-width: ${MOBILE_BQ}px)`).matches;
const measure = (): { points: Point[]; isMobile: boolean } => {
const wrapRect = wrap.getBoundingClientRect();
const isMobile = isMobileLayout();
const points = cardRefs.current
.map((card, i) => {
const icon = iconRefs.current[i];
if (!card || !icon) return null;
const cardRect = card.getBoundingClientRect();
const iconRect = icon.getBoundingClientRect();
if (isMobile) {
return {
x: 12,
y: iconRect.top + iconRect.height / 2 - wrapRect.top,
};
}
return {
x: iconRect.left + iconRect.width / 2 - wrapRect.left,
y: cardRect.top - wrapRect.top - 14,
};
})
.filter((p): p is Point => p !== null);
svg.setAttribute("width", String(wrapRect.width));
svg.setAttribute("height", String(wrapRect.height));
svg.setAttribute("viewBox", `0 0 ${wrapRect.width} ${wrapRect.height}`);
// Wave only applies to the horizontal (non-mobile) layout — mobile
// keeps its existing straight vertical line unchanged. Amplitude
// scales with the section's width so it stays "gentle" (never a
// straight line, never overly aggressive) at any breakpoint.
const amplitude = isMobile
? 0
: Math.max(WAVE_AMPLITUDE_MIN, Math.min(WAVE_AMPLITUDE_MAX, wrapRect.width / 20));
const routePoints = isMobile ? points : buildWavePoints(points, amplitude);
const d = buildPathD(routePoints);
path.setAttribute("d", d);
illum.setAttribute("d", d);
return { points, isMobile };
};
// Keep the (static, or already-settled) line in sync with layout reflow.
// Only ever updates geometry — never restarts or replays the animation.
ro = new ResizeObserver(() => {
cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(measure);
});
ro.observe(wrap);
// Initial synchronous measurement so `path` has real geometry before any
// gsap.set/timeline touches it (ResizeObserver's first callback is async).
// Followed by one deferred re-measure: StaggerChildren's own mount effect
// (a sibling/child effect) can still have cards translated by their
// pre-reveal yOffset at this exact tick, so the very first synchronous
// read can be transiently stale — a post-paint rAF corrects it.
measure();
initialRaf = requestAnimationFrame(() => {
measure();
// Recalculates every ScrollTrigger's start/end position against the
// now-settled layout. Without this, a trigger created a moment before
// web fonts/images finish shifting the page can end up with a stale
// "top 85%" position that a normal scroll never actually crosses —
// the classic reason a GSAP ScrollTrigger works after a hard refresh
// (cache-warm, layout already stable) but not after a fresh client-side
// navigation.
ScrollTrigger.refresh();
});
// Extra safety net: images loading in after this point can still shift
// layout. One more refresh once the whole page (including images) has
// finished loading catches that — a no-op if `load` already fired.
if (document.readyState !== "complete") {
loadHandler = () => ScrollTrigger.refresh();
window.addEventListener("load", loadHandler, { once: true });
}
const activateCard = (i: number) => {
const card = cardRefs.current[i];
const icon = iconRefs.current[i];
if (!card) return;
// Arrival: border gently strengthens, ambient glow grows, icon
// brightens, card lifts a touch (CSS-driven, eased over ~0.5s — see
// .wwg-card / .wwg-card-glow transition durations below — so it grows
// and settles rather than snapping on).
card.classList.add("wwg-card-flown", "wwg-card-pulse");
// Icon's own gentle scale pulse starts a beat after the arrival
// begins, so the sequence reads as "arrives, then responds" rather
// than everything happening at once.
if (icon) {
gsap.timeline({ delay: 0.28 })
.to(icon, { scale: 1.02, duration: 0.32, ease: "expo.out" })
.to(icon, { scale: 1, duration: 0.32, ease: "sine.inOut" });
}
// Hold the arrival state, then ease into the subtler, persistent
// "flown" resting state.
gsap.delayedCall(0.95, () => card.classList.remove("wwg-card-pulse"));
};
const runSequence = () => {
if (played) return;
played = true;
const { points, isMobile } = measure();
if (points.length < 2) return;
const restOpacity = isMobile ? 0.15 : 0.18;
const total = path.getTotalLength();
// The illuminated window trailing the pulse: roughly a fifth of the
// route's length, so it reads as a genuine glowing trail rather than
// a small blip or the entire line at once.
const trailLen = total * 0.22;
const illumDashGap = total + 200;
illum.setAttribute("stroke-dasharray", `${trailLen} ${illumDashGap}`);
const fractions = points.map((pt) => {
let best = 0;
let bestDist = Infinity;
const step = Math.max(2, total / 400);
for (let l = 0; l <= total; l += step) {
const p = path.getPointAtLength(l);
const dist = (p.x - pt.x) ** 2 + (p.y - pt.y) ** 2;
if (dist < bestDist) {
bestDist = dist;
best = l;
}
}
return best / total;
});
const fired = fractions.map(() => false);
let currentAngle = 0;
let angleInit = false;
const tl = gsap.timeline();
playedTimeline = tl;
// The energy pulse's own journey — calm and deliberate, ~4.8-5.5s so
// it glides rather than rushes from the first card to the last.
const FLIGHT_DURATION = 5.2;
// The route draws slightly faster than the pulse travels, both
// starting together at t=0 — so the drawn extent always leads the
// pulse's position (never the other way round) and the whole route
// is guaranteed fully illuminated well before the pulse's own journey
// ends, while both still read as one continuous, synchronized motion.
const DRAW_DURATION = FLIGHT_DURATION * 0.9;
if (hasDrawSVG) {
tl.to(path, { drawSVG: "100%", duration: DRAW_DURATION, ease: "power2.inOut" }, 0);
} else {
gsap.set(path, { strokeDasharray: total, strokeDashoffset: total });
tl.to(path, { strokeDashoffset: 0, duration: DRAW_DURATION, ease: "power2.inOut" }, 0);
}
// The base route ramps straight to its final resting opacity as it
// draws — never to an elevated "traveling" brightness — so there is
// only ever one illuminated layer on screen: the bright illum trail
// riding with the pulse (below). Without this, the whole drawn route
// would sit at a visibly elevated opacity for the entire flight,
// reading as a second, competing "lit" route behind the pulse.
tl.to(path, { opacity: restOpacity, duration: 0.9, ease: "sine.out" }, 0);
// GSAP computes its own rotation pivot for SVG elements from the
// target's bounding box (stamped as a data-svg-origin attribute) and
// ignores plain CSS transform-origin for that purpose — so without
// this, both directional groups were actually rotating around their
// bbox corner instead of (0,0), visibly orbiting off the path's
// centerline as they turned. svgOrigin is GSAP's own equivalent,
// stated in the element's literal SVG coordinates (not bbox-relative
// percentages), and only needs setting once — the per-frame
// rotation-only gsap.set calls below then reuse it automatically.
gsap.set([directional, aircraftDirectional], { svgOrigin: "0 0" });
// A soft fade-in rather than an instant pop, so the pulse's arrival
// reads as one continuous gesture from the very first frame.
tl.to(traveler, { opacity: 1, duration: 0.3, ease: "sine.out" }, 0);
tl.to(illum, { opacity: 1, duration: 0.4, ease: "sine.out" }, 0);
tl.to(traveler, {
// alignOrigin locks the traveler's own center (not a default
// corner/bbox reference) to the path, matching the explicit
// transform-origin: 50% 50% above — keeps the pulse/aircraft
// precisely on the route's centerline with no vertical drift.
motionPath: { path, autoRotate: false, alignOrigin: [0.5, 0.5] },
duration: FLIGHT_DURATION,
ease: "power2.inOut",
onUpdate: function () {
const p = this.progress();
const l = p * total;
const eps = Math.max(1, total * 0.01);
const a = path.getPointAtLength(Math.max(0, l - eps));
const b = path.getPointAtLength(Math.min(total, l + eps));
const target = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI + AIRCRAFT_ROTATION_OFFSET;
if (!angleInit) {
currentAngle = target;
angleInit = true;
} else {
let delta = target - currentAngle;
// shortest angular path so the lerp never spins the long way round
delta = ((delta + 180) % 360 + 360) % 360 - 180;
currentAngle += delta * 0.12;
}
// Only the two directional groups (trail + halo, and the aircraft)
// rotate — the core stays a perfectly round point of light on the
// path. Both are set to the same angle every frame so they always
// face the same way despite being separate DOM branches (see
// aircraftDirectionalRef for why they're split).
gsap.set(directional, { rotation: currentAngle });
gsap.set(aircraftDirectional, { rotation: currentAngle });
// Slide the illuminated window's trailing edge to l - trailLen and
// its leading edge to l (i.e. right under the pulse) by shifting
// the dash pattern's phase — the CSS blur on .wwg-illum-trail turns
// those two hard edges into a soft glow that fades into the resting
// route, rather than a sharp on/off cut.
illum.setAttribute("stroke-dashoffset", String(trailLen - l));
fractions.forEach((f, i) => {
if (!fired[i] && p >= f) {
fired[i] = true;
activateCard(i);
}
});
},
}, 0);
// Only once the pulse's own journey is complete does it fade out —
// the route itself is already at its resting opacity by this point
// (see above), so nothing further happens to it here.
tl.to(traveler, { opacity: 0, duration: 0.6, ease: "sine.out" }, FLIGHT_DURATION);
tl.to(illum, { opacity: 0, duration: 0.6, ease: "sine.out" }, FLIGHT_DURATION);
};
if (reduced) {
const { isMobile } = measure();
gsap.set(path, { opacity: isMobile ? 0.15 : 0.18 });
if (hasDrawSVG) {
gsap.set(path, { drawSVG: "100%" });
} else {
gsap.set(path, { strokeDasharray: "none", strokeDashoffset: 0 });
}
gsap.set(traveler, { opacity: 0 });
gsap.set(illum, { opacity: 0 });
} else {
gsap.set(path, { opacity: 0 });
if (hasDrawSVG) {
gsap.set(path, { drawSVG: "0%" });
}
gsap.set(traveler, { opacity: 0 });
gsap.set(illum, { opacity: 0 });
// Starts as soon as the section is mostly in view (matches the cards'
// own "top 85%" stagger-reveal trigger) rather than waiting for it to
// be ~centered — the ~6s draw+flight sequence needs that extra lead
// time to actually be seen during a normal scroll pass, not just
// triggered and then scrolled straight past.
st = ScrollTrigger.create({
trigger: wrap,
start: "top 85%",
once: true,
onEnter: runSequence,
});
rafIn = requestAnimationFrame(() => {
const r = wrap.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
if (r.top < vh * 0.85 && r.bottom > 0) {
runSequence();
st?.kill();
}
});
}
}, wrap);
return () => {
ro?.disconnect();
cancelAnimationFrame(resizeRaf);
cancelAnimationFrame(initialRaf);
cancelAnimationFrame(rafIn);
if (loadHandler) window.removeEventListener("load", loadHandler);
st?.kill();
playedTimeline?.kill();
// Reverts/kills everything gsap.context tracked (ScrollTrigger.create,
// gsap.set calls, etc.) as a safety net beyond the explicit kills
// above, which exist specifically because the flight timeline is
// created asynchronously (inside onEnter) and so isn't guaranteed to
// be tracked by the context itself.
ctx.revert();
};
}, []);
return (
<>
<style
@@ -56,7 +490,7 @@ export default function WingsWhyGrid() {
__html: `
.wwg-section {
background: #ffffff;
padding: var(--space-section-lg, 80px) clamp(20px, 5vw, 40px);
padding: var(--space-section-lg, 80px) 0;
}
.wwg-header {
@@ -84,21 +518,129 @@ export default function WingsWhyGrid() {
margin: 0;
}
.wwg-grid {
max-width: 1280px;
.wwg-inner {
max-width: 1380px;
margin: 0 auto;
}
.wwg-grid-wrap {
position: relative;
overflow: visible;
}
.wwg-flightpath {
position: absolute;
inset: 0;
pointer-events: none;
overflow: visible;
z-index: 5;
}
.wwg-flightpath-line {
fill: none;
stroke: #c8102e;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
/* A brighter, blurred copy of the same route, painted *behind* the
crisp foreground line (see JSX order). Its dasharray is driven
per-frame (see onUpdate) to keep a soft window of it trailing
directly beneath the pulse; because it sits behind rather than on
top, its blur reads as a glow radiating from under the route
instead of visually thickening the route's own edges. Stroke
width intentionally matches the base route so it never peeks out
wider than the crisp line drawn on top of it. */
.wwg-illum-trail {
fill: none;
stroke: #ff3d5a;
stroke-width: 2;
stroke-linecap: round;
filter: blur(5px);
opacity: 0;
}
/* The traveler is the primary visual: a single centered optical
pulse riding the route, with a small secondary aircraft glyph
carried inside it. Position (translate) lives on .wwg-traveler;
only the inner .wwg-traveler-directional group rotates to face
the direction of travel — the round pulse core stays outside it
so it never rotates, always reading as a clean point of light. */
.wwg-traveler {
opacity: 0;
pointer-events: none;
will-change: transform;
/* Explicit center alignment (paired with alignOrigin: [0.5, 0.5]
on the motionPath tween) so the traveler's own center — not a
corner/default reference point — is what MotionPathPlugin locks
to the route, keeping it precisely on the path's centerline
with no vertical drift. */
transform-origin: 50% 50%;
}
.wwg-traveler-directional {
transform-origin: 0px 0px;
will-change: transform;
}
/* Large, radially-symmetric ambient glow — the true hero of the
traveler. Centered on the pulse with no directional offset, so
there is never a brighter patch leading ahead of the aircraft.
Non-directional, so it lives outside the rotating
.wwg-traveler-directional group. */
.wwg-pulse-bloom {
fill: #c8102e;
opacity: 0.2;
filter: blur(18px);
}
.wwg-pulse-halo {
fill: #c8102e;
opacity: 0.52;
filter: blur(7px);
}
.wwg-pulse-core {
fill: #ffffff;
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.98)) drop-shadow(0 0 9px rgba(200, 16, 46, 0.85))
drop-shadow(0 0 15px rgba(200, 16, 46, 0.5));
}
/* Deliberately no drop-shadow of its own — the bloom/halo/trail
above supply all the glow. Sized to be clearly recognizable as an
aircraft without becoming the focal point: the pulse is still the
brightest, largest element on screen at every moment. */
.wwg-aircraft {
color: #c8102e;
opacity: 0.65;
}
.wwg-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 24px;
}
.wwg-card {
position: relative;
min-width: 0;
padding: 30px 24px;
border: 1px solid rgba(17, 17, 17, 0.08);
border-radius: 18px;
background: #fafafa;
transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease;
/* Staggered delays so activation reads as a cascade — glow moves
first (see .wwg-card-glow below, no delay), then border/shape,
then the background tint settles last — rather than every
property snapping at once. The "expo-out" curve (rather than
plain ease) is what gives it a soft, premium settle instead of a
mechanical linear-ish snap. */
transition:
border-color 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
box-shadow 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
transform 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
background-color 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.22s;
}
.wwg-card:hover {
@@ -107,6 +649,37 @@ export default function WingsWhyGrid() {
transform: translateY(-4px);
}
.wwg-card-glow {
position: absolute;
inset: -12px;
border-radius: 22px;
background: radial-gradient(circle, rgba(200, 16, 46, 0.45) 0%, rgba(200, 16, 46, 0) 70%);
opacity: 0;
z-index: -1;
pointer-events: none;
filter: blur(6px);
transition: opacity 0.75s cubic-bezier(0.16, 1, 0.3, 1);
}
.wwg-card-flown {
border-color: rgba(200, 16, 46, 0.25);
background-color: #fdf7f8;
}
.wwg-card-flown .wwg-card-glow {
opacity: 0.32;
}
.wwg-card-pulse {
border-color: rgba(200, 16, 46, 0.5);
box-shadow: 0 16px 34px rgba(0, 0, 0, 0.07);
transform: translateY(-3px);
}
.wwg-card-pulse .wwg-card-glow {
opacity: 0.85;
}
.wwg-icon {
width: 52px;
height: 52px;
@@ -117,6 +690,18 @@ export default function WingsWhyGrid() {
background: rgba(200, 16, 46, 0.06);
color: #c8102e;
margin-bottom: 20px;
transition:
background-color 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.16s,
filter 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.16s;
}
.wwg-card-flown .wwg-icon {
background: rgba(200, 16, 46, 0.1);
}
.wwg-card-pulse .wwg-icon {
background: rgba(200, 16, 46, 0.14);
filter: brightness(1.12);
}
.wwg-card-title {
@@ -140,27 +725,101 @@ export default function WingsWhyGrid() {
}
@media (max-width: 600px) {
.wwg-grid { grid-template-columns: 1fr; }
.wwg-grid { grid-template-columns: 1fr; padding-left: 28px; }
/* var(--space-section-lg) floors at a fixed 52px on any phone
width; trimmed to a real mobile value to match the section
rhythm near the footer. Matches this file's own MOBILE_BQ
breakpoint used by the flight-path animation logic. */
.wwg-section { padding-top: 44px; padding-bottom: 44px; }
}
@media (prefers-reduced-motion: reduce) {
.wwg-card, .wwg-card-glow, .wwg-icon {
transition: none;
}
}
`,
}}
/>
<section className="wwg-section" id="wings-why">
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className="wwg-header">
<div className="wwg-eyebrow">/ Why Doormile Wings /</div>
<h2 className="wwg-title">Built for the speed of the Gen-Z economy</h2>
</ScrollReveal>
<div className="dm-section-container">
<div className="wwg-inner">
<ScrollReveal delay={0.05} duration={0.75} yOffset={25} className="wwg-header">
<div className="wwg-eyebrow">/ Why Doormile Wings /</div>
<h2 className="wwg-title">Built for the speed of the Gen-Z economy</h2>
</ScrollReveal>
<StaggerChildren stagger={0.1} duration={0.7} yOffset={30} className="wwg-grid">
{FEATURES.map((f) => (
<div key={f.title} className="wwg-card">
<div className="wwg-icon" aria-hidden="true">{f.icon}</div>
<h3 className="wwg-card-title">{f.title}</h3>
<p className="wwg-card-desc">{f.desc}</p>
<div className="wwg-grid-wrap" ref={wrapRef}>
<svg ref={svgRef} className="wwg-flightpath" aria-hidden="true" focusable="false">
{/* Rendered first (behind) so its blur/extra width reads as a
soft glow sitting under the route rather than visually
thickening it — the crisp .wwg-flightpath-line painted
after it always defines the route's actual edges. */}
<path ref={illumRef} className="wwg-illum-trail" d="" />
<path ref={pathRef} className="wwg-flightpath-line" d="" />
<g ref={travelerRef} className="wwg-traveler">
{/* Large feathered bloom: the true hero of the traveler, an
ambient glow with no direction of its own. */}
<circle className="wwg-pulse-bloom" cx="0" cy="0" r="26" />
<g ref={directionalRef} className="wwg-traveler-directional">
<ellipse className="wwg-pulse-halo" cx="0" cy="0" rx="11" ry="6" />
</g>
<circle className="wwg-pulse-core" cx="0" cy="0" r="2.6" />
{/* Painted after (on top of) the core so the aircraft's
silhouette stays readable instead of being buried under
the bright core dot. Rotation mirrors directionalRef
exactly (see onUpdate) despite living in its own group. */}
<g ref={aircraftDirectionalRef} className="wwg-traveler-directional">
{/* The stock glyph's original 24x24 viewBox had ~3.5
units of dead padding on every side around the actual
ink (true silhouette bbox was only 17x20) — scaling
from the nominal 24-unit box would have kept baking
that padding in and rendering smaller than intended.
These coordinates instead start from the silhouette's
own tight bbox (no padding), centered on (0,0), then
scaled 1.3x — giving a true rendered size of ~22x26px,
clearing 22px on both axes. Deliberately no
scale()/translate() transform: baked directly into
the path data so it renders at full native
resolution rather than through a transform stacked on
top of this group's own rotation transform. */}
<path
className="wwg-aircraft"
d="M0,-13 C-0.78,-13 -1.3,-12.22 -1.3,-11.31 L-1.3,-3.9 L-11.05,1.3 L-11.05,3.9 L-1.3,0.91 L-1.3,9.1 L-4.55,11.44 L-4.55,13 L0,11.7 L4.55,13 L4.55,11.44 L1.3,9.1 L1.3,0.91 L11.05,3.9 L11.05,1.3 L1.3,-3.9 L1.3,-11.31 C1.3,-12.22 0.78,-13 0,-13 Z"
fill="currentColor"
/>
</g>
</g>
</svg>
<StaggerChildren stagger={0.1} duration={0.7} yOffset={30} className="wwg-grid">
{FEATURES.map((f, i) => (
<div
key={f.title}
className="wwg-card"
ref={(el) => {
cardRefs.current[i] = el;
}}
>
<span className="wwg-card-glow" aria-hidden="true" />
<div
className="wwg-icon"
aria-hidden="true"
ref={(el) => {
iconRefs.current[i] = el;
}}
>
{f.icon}
</div>
<h3 className="wwg-card-title">{f.title}</h3>
<p className="wwg-card-desc">{f.desc}</p>
</div>
))}
</StaggerChildren>
</div>
))}
</StaggerChildren>
</div>
</div>
</section>
</>
);