diff --git a/package-lock.json b/package-lock.json index 4fc9349..dc5039d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "doormile-next", "version": "0.1.0", "dependencies": { + "@emailjs/browser": "^4.4.1", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", "@react-three/postprocessing": "^3.0.4", @@ -722,6 +723,15 @@ "node": ">=18" } }, + "node_modules/@emailjs/browser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@emailjs/browser/-/browser-4.4.1.tgz", + "integrity": "sha512-DGSlP9sPvyFba3to2A50kDtZ+pXVp/0rhmqs2LmbMS3I5J8FSOgLwzY2Xb4qfKlOVHh29EAutLYwe5yuEZmEFg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/package.json b/package.json index b4dc8ba..722c841 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:coverage": "jest --coverage" }, "dependencies": { + "@emailjs/browser": "^4.4.1", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", "@react-three/postprocessing": "^3.0.4", diff --git a/public/css/site.css b/public/css/site.css index f9edcd8..59d94b6 100644 --- a/public/css/site.css +++ b/public/css/site.css @@ -2663,7 +2663,7 @@ iframe { margin-bottom: 0 } -.wp-block-group.has-background { +.dm-block-group.has-background { border-radius: var(--logico-radius-medium,0) } @@ -5487,11 +5487,11 @@ body:not(.block-editor-page) .content-wrapper .widget p { margin-top: -.25em } -.sidebar .widget-wrapper>.wp-block-title:not(:last-child),.sidebar .wp-block-group>.wp-block-title:not(:last-child) { +.sidebar .widget-wrapper>.wp-block-title:not(:last-child),.sidebar .dm-block-group>.wp-block-title:not(:last-child) { margin: 0 0 .95em } -.sidebar .widget-wrapper>.wp-block-title:first-child h1,.sidebar .widget-wrapper>.wp-block-title:first-child h2,.sidebar .widget-wrapper>.wp-block-title:first-child h3,.sidebar .widget-wrapper>.wp-block-title:first-child h4,.sidebar .widget-wrapper>.wp-block-title:first-child h5,.sidebar .widget-wrapper>.wp-block-title:first-child h6,.sidebar .wp-block-group>.wp-block-title:first-child h1,.sidebar .wp-block-group>.wp-block-title:first-child h2,.sidebar .wp-block-group>.wp-block-title:first-child h3,.sidebar .wp-block-group>.wp-block-title:first-child h4,.sidebar .wp-block-group>.wp-block-title:first-child h5,.sidebar .wp-block-group>.wp-block-title:first-child h6 { +.sidebar .widget-wrapper>.wp-block-title:first-child h1,.sidebar .widget-wrapper>.wp-block-title:first-child h2,.sidebar .widget-wrapper>.wp-block-title:first-child h3,.sidebar .widget-wrapper>.wp-block-title:first-child h4,.sidebar .widget-wrapper>.wp-block-title:first-child h5,.sidebar .widget-wrapper>.wp-block-title:first-child h6,.sidebar .dm-block-group>.wp-block-title:first-child h1,.sidebar .dm-block-group>.wp-block-title:first-child h2,.sidebar .dm-block-group>.wp-block-title:first-child h3,.sidebar .dm-block-group>.wp-block-title:first-child h4,.sidebar .dm-block-group>.wp-block-title:first-child h5,.sidebar .dm-block-group>.wp-block-title:first-child h6 { margin: 0!important; padding: 0 1.5em 1.05em 0; border-bottom: solid 1px; @@ -5501,7 +5501,7 @@ body:not(.block-editor-page) .content-wrapper .widget p { font: 600 normal 20px/1.25em var(--logico-body-font-family) } -.sidebar .widget-wrapper>.wp-block-title:first-child h1:after,.sidebar .widget-wrapper>.wp-block-title:first-child h2:after,.sidebar .widget-wrapper>.wp-block-title:first-child h3:after,.sidebar .widget-wrapper>.wp-block-title:first-child h4:after,.sidebar .widget-wrapper>.wp-block-title:first-child h5:after,.sidebar .widget-wrapper>.wp-block-title:first-child h6:after,.sidebar .wp-block-group>.wp-block-title:first-child h1:after,.sidebar .wp-block-group>.wp-block-title:first-child h2:after,.sidebar .wp-block-group>.wp-block-title:first-child h3:after,.sidebar .wp-block-group>.wp-block-title:first-child h4:after,.sidebar .wp-block-group>.wp-block-title:first-child h5:after,.sidebar .wp-block-group>.wp-block-title:first-child h6:after { +.sidebar .widget-wrapper>.wp-block-title:first-child h1:after,.sidebar .widget-wrapper>.wp-block-title:first-child h2:after,.sidebar .widget-wrapper>.wp-block-title:first-child h3:after,.sidebar .widget-wrapper>.wp-block-title:first-child h4:after,.sidebar .widget-wrapper>.wp-block-title:first-child h5:after,.sidebar .widget-wrapper>.wp-block-title:first-child h6:after,.sidebar .dm-block-group>.wp-block-title:first-child h1:after,.sidebar .dm-block-group>.wp-block-title:first-child h2:after,.sidebar .dm-block-group>.wp-block-title:first-child h3:after,.sidebar .dm-block-group>.wp-block-title:first-child h4:after,.sidebar .dm-block-group>.wp-block-title:first-child h5:after,.sidebar .dm-block-group>.wp-block-title:first-child h6:after { content: '\e80a'; display: block; position: absolute; @@ -8089,11 +8089,11 @@ h1:where(.wp-block-heading).has-background, /*# sourceURL=local */ /* STYLE BLOCK 8 */ -.wp-block-group { +.dm-block-group { box-sizing: border-box } - :where(.wp-block-group.wp-block-group-is-layout-constrained) { + :where(.dm-block-group.dm-block-group-is-layout-constrained) { position: relative } diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index e3e5736..a073a4b 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect } from "react"; import Link from "next/link"; import Image from "next/image"; +import emailjs from "@emailjs/browser"; import { ScrollReveal } from "@/animations/Reveal"; export default function Footer() { @@ -45,15 +46,37 @@ export default function Footer() { const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); + // Clear any stale success/error message once the user resumes editing. + if (formStatus === "success" || formStatus === "error") setFormStatus("idle"); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + // EmailJS is fully client-side — only the public-safe credentials are used + // (Service ID, Template ID, Public Key). No private/secret key, no backend route. + const serviceId = process.env.NEXT_PUBLIC_EMAILJS_SERVICE_ID; + const templateId = process.env.NEXT_PUBLIC_EMAILJS_TEMPLATE_ID; + const publicKey = process.env.NEXT_PUBLIC_EMAILJS_PUBLIC_KEY; + if (!serviceId || !templateId || !publicKey) { + console.error("EmailJS env vars are missing — set NEXT_PUBLIC_EMAILJS_* in .env.local"); + setFormStatus("error"); + return; + } + setFormStatus("submitting"); try { - // Simulate API submission - console.log("Footer contact form submitted:", formData); - await new Promise((resolve) => setTimeout(resolve, 1000)); + await emailjs.send( + serviceId, + templateId, + { + name: formData.fullName, + email: formData.email, + subject: formData.subject, + message: formData.message, + }, + publicKey, + ); setFormStatus("success"); setFormData({ fullName: "", email: "", subject: "", message: "" }); } catch (err) { @@ -285,12 +308,12 @@ export default function Footer() { {formStatus === "success" && (
- Message sent successfully! + Message sent successfully.
)} {formStatus === "error" && (
- Something went wrong. Please try again. + Failed to send message. Please try again.
)} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 58c8384..adbd3e9 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -145,7 +145,7 @@ export default function Header() {
-
+
; reduced?: boolean; isMobile?: boolean; + isTablet?: boolean; /** Pause the render loop when the section is scrolled off-screen. */ active?: boolean; }; +/** + * Device-specific camera framing. The mobile/tablet scene renders into a much + * smaller, near-square block than the desktop full-screen card, so the camera + * is pulled back, raised, and widened (higher fov) to keep every node in frame + * without clipping. `lerp(start, end, progress)` eases from the chaotic wide + * view to the settled framing as the narrative progresses. + */ +type CameraFraming = { + radiusStart: number; + radiusEnd: number; + heightStart: number; + heightEnd: number; + lookAtY: number; + fov: number; +}; + +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 }, +}; + /** Slow cinematic camera move from a high chaotic view to a settled framing. */ -function CameraRig({ progress }: { progress: React.RefObject }) { +function CameraRig({ progress, framing }: { progress: React.RefObject; framing: CameraFraming }) { const eased = useRef(0); + const camera = useThree((s) => s.camera); + + // Re-frame on device change (orientation / breakpoint crossing): the fov is a + // construction-time prop on , so update it imperatively here. + useEffect(() => { + if ("fov" in camera) { + (camera as THREE_PerspectiveCamera).fov = framing.fov; + (camera as THREE_PerspectiveCamera).updateProjectionMatrix(); + } + }, [camera, framing.fov]); + useFrame((state, dt) => { const p = progress.current ?? 0; eased.current = damp(eased.current, p, 1.5, dt); const e = eased.current; const t = state.clock.elapsedTime; - const radius = lerp(17, 13, e); + const radius = lerp(framing.radiusStart, framing.radiusEnd, e); const angle = lerp(-0.5, 0.45, e) + t * 0.02; - const height = lerp(9, 6.5, e) + Math.sin(t * 0.4) * 0.3; + const height = lerp(framing.heightStart, framing.heightEnd, e) + Math.sin(t * 0.4) * 0.3; const cam = state.camera; cam.position.x = Math.sin(angle) * radius; cam.position.z = Math.cos(angle) * radius; cam.position.y = height; - cam.lookAt(0, 2.4, 0); + cam.lookAt(0, framing.lookAtY, 0); }); return null; } -function OptimizationCanvas({ progress, reduced = false, isMobile = false, active = true }: Props) { +// Minimal structural type so we can set fov without importing three's types here. +type THREE_PerspectiveCamera = { fov: number; updateProjectionMatrix: () => void }; + +function OptimizationCanvas({ progress, reduced = false, isMobile = false, isTablet = false, active = true }: Props) { const cityCount = isMobile ? 48 : 90; + const framing = isMobile ? FRAMING.mobile : isTablet ? FRAMING.tablet : FRAMING.desktop; return ( @@ -56,7 +94,7 @@ function OptimizationCanvas({ progress, reduced = false, isMobile = false, activ - + @@ -68,10 +106,10 @@ function OptimizationCanvas({ progress, reduced = false, isMobile = false, activ diff --git a/src/components/optimization/OptimizationSection.tsx b/src/components/optimization/OptimizationSection.tsx index 52e9cb1..0d96b38 100644 --- a/src/components/optimization/OptimizationSection.tsx +++ b/src/components/optimization/OptimizationSection.tsx @@ -109,6 +109,46 @@ const LiveInsightBar = React.memo(function LiveInsightBar() { ); }); +/** Inner content of the "Without Optimization" panel — shared by the desktop + * (scroll-reactive motion.aside) and mobile (static aside) layouts. */ +function WithoutPanelBody() { + return ( + <> +
+ System: Congested +
+

Without Optimization

+
    +
  • Chaotic overlapping routes
  • +
  • Duplicate & idle trips
  • +
  • 8 vehicles required
  • +
  • 23 delivery delays
  • +
  • +18% cost overrun
  • +
+ + ); +} + +/** Inner content of the "With Doormile AI" panel — shared by both layouts. */ +function WithPanelBody() { + return ( + <> +
+ System: Optimized +
+

With Doormile AI

+
    +
  • Optimized route clusters
  • +
  • Intelligent vehicle assignment
  • +
  • Multi-trip & EV planning
  • +
  • Zero delivery delays
  • +
  • 18% cost saved
  • +
  • Carbon footprint reduced
  • +
+ + ); +} + export default function OptimizationSection() { const containerRef = useRef(null); const progressRef = useRef(0); @@ -119,21 +159,30 @@ export default function OptimizationSection() { 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); + // Final-state metrics value for the mobile stack: a constant MotionValue so + // MetricsPanel renders the optimized numbers without scroll-driven counting. + const staticFinal = useMotionValue(1); + // Environment detection (client only). 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); }; }, []); @@ -176,6 +225,10 @@ export default function OptimizationSection() { useEffect(() => { const el = containerRef.current; if (!el) return; + // Mobile renders a non-pinned vertical stack (see the `isMobile` branch in + // render): no ScrollTrigger pin/scrub at all. Bail before creating one so + // pinState stays "before" and the section keeps its natural auto height. + if (isMobile) return; gsap.registerPlugin(ScrollTrigger); // NOTE: global Lenis (src/animations/SmoothScroll.tsx) is active on this @@ -213,7 +266,29 @@ export default function OptimizationSection() { clearTimeout(refresh); st.kill(); }; - }, [scroll]); + }, [scroll, isMobile]); + + // Mobile ambient loop: with no scroll scrub, gently oscillate the shared + // progress inside the *optimized* band so the hologram stays "alive" and + // coherent with the final metrics. Held static under reduced-motion. + useEffect(() => { + if (!isMobile) return; + if (reduced) { + progressRef.current = 0.85; + return; + } + if (!sceneActive) return; + let raf = 0; + let start = 0; + const tick = (ts: number) => { + if (!start) start = ts; + const t = (ts - start) / 1000; + progressRef.current = 0.76 + Math.sin(t * 0.5) * 0.16; // ~0.60 → 0.92 + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [isMobile, reduced, sceneActive]); // Overlay reactions to scroll (no React re-render — direct DOM updates). const leftOpacity = useTransform(scroll, [0.3, 0.55], [1, 0.32]); @@ -231,9 +306,59 @@ export default function OptimizationSection() { return (
+ {/* ===== MOBILE: non-pinned vertical stack ===== */} + {isMobile && ( +
+
+
+ Doormile AI Control Tower +
+

AI Logistics Optimization Engine

+

+ Watch Doormile's AI engine transform chaotic logistics into precision-optimized delivery networks — reducing distance, fleet size, delays, and cost. +

+
+ + {/* 1. Without Optimization */} + + + {/* 2. 3D Visualization (ambient) */} +
+ {mountScene && ( +
+ +
+ )} + + Live AI optimization + +
+ + {/* 3. With Doormile AI */} + + + {/* 4. Metrics — final optimized values, 2-col grid */} +
+ + +
+
+ )} + + {/* ===== DESKTOP / TABLET: pinned scroll experience ===== */} + {!isMobile && (
{/* Static backdrop (also the canvas loading state) */} @@ -246,6 +371,7 @@ export default function OptimizationSection() { progress={progressRef} reduced={reduced} isMobile={isMobile} + isTablet={isTablet} // Only run the render loop while the section is actually pinned // (filling the viewport). At a workflow seam two sections can both // satisfy their activeIo margin; without the pin gate their two @@ -375,35 +501,14 @@ export default function OptimizationSection() { className="dm-opt-panel dm-opt-panel--bad" style={{ opacity: leftOpacity, filter: leftFilter }} > -
- System: Congested -
-

Without Optimization

-
    -
  • Chaotic overlapping routes
  • -
  • Duplicate & idle trips
  • -
  • 8 vehicles required
  • -
  • 23 delivery delays
  • -
  • +18% cost overrun
  • -
+ -
- System: Optimized -
-

With Doormile AI

-
    -
  • Optimized route clusters
  • -
  • Intelligent vehicle assignment
  • -
  • Multi-trip & EV planning
  • -
  • Zero delivery delays
  • -
  • 18% cost saved
  • -
  • Carbon footprint reduced
  • -
+
@@ -417,6 +522,7 @@ export default function OptimizationSection() {
+ )} @@ -893,6 +999,98 @@ const styles = ` .dm-opt-insight__text { font-size: 8.5px; } .dm-opt-insight__sep { height: 10px; } } + +/* ===== MOBILE STACKED LAYOUT (<=767px) — non-pinned vertical flow ===== + Rendered by the isMobile branch as a normal-flow .dm-opt-mobile container + (header → Without → 3D → With → metrics). These rules come after the block + above so they win for the elements that actually exist on mobile. */ +@media (max-width: 767px) { + /* Un-pin: natural height, no fixed sticky, no 200/230vh scroll runway. + (.dm-opt--mobile out-specifies the base .dm-opt height rules.) */ + .dm-opt.dm-opt--mobile { height: auto; } + + .dm-opt-mobile { + position: relative; + margin: 0 10px; + padding: 24px 13px 22px; + display: flex; + flex-direction: column; + gap: 14px; + box-sizing: border-box; + background: linear-gradient(180deg, #06101f 0%, #020617 55%, #030a18 100%); + border: 1px solid rgba(255, 255, 255, 0.05); + border-bottom: none; + /* Flat bottom + flush so the Performance card (.dm-wf1-card) butts directly + against it as one continuous container (matches Workflow1 ≤767 styles). */ + border-radius: 20px 20px 0 0; + overflow: hidden; + } + + /* Header */ + .dm-opt-mhead { text-align: center; padding: 0 4px; } + .dm-opt-mhead .dm-opt-eyebrow { font-size: 10px; } + .dm-opt-mhead h2 { + font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif; + margin: 10px 0 6px !important; padding: 0 !important; color: #F8FAFC !important; + font-weight: 700 !important; text-transform: none !important; + font-size: clamp(20px, 6.2vw, 26px) !important; line-height: 1.15 !important; + letter-spacing: -0.015em !important; + } + .dm-opt-mhead p { + margin: 0 auto !important; padding: 0 !important; color: ${COLORS.textDim} !important; + max-width: 40ch; font-size: 12.5px !important; line-height: 1.5 !important; + } + + /* Comparison panels → full-width static cards, fully visible, readable. + Trim the heavy glow (box-shadow) but keep the colored border for identity. */ + .dm-opt-mobile .dm-opt-panel { + width: 100%; box-sizing: border-box; + opacity: 1 !important; filter: none !important; + padding: 15px 16px; border-radius: 16px; box-shadow: none; + } + .dm-opt-mobile .dm-opt-panel h3 { + font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif; + font-size: 16px !important; margin: 9px 0 10px !important; + } + .dm-opt-mobile .dm-opt-panel ul { gap: 8px; } + .dm-opt-mobile .dm-opt-panel li { font-size: 12.5px !important; line-height: 1.35 !important; } + .dm-opt-mobile .dm-opt-marker { width: 18px; height: 18px; font-size: 10px; border-radius: 6px; } + .dm-opt-mobile .dm-opt-panel__badge { font-size: 9.5px; padding: 4px 9px; } + + /* 3D visualization block — contained, ~40vh, premium but not dominant. */ + .dm-opt-mobile__scene { + position: relative; width: 100%; height: 40vh; min-height: 260px; max-height: 360px; + border-radius: 16px; overflow: hidden; + background: radial-gradient(120% 90% at 50% 30%, ${rgba(COLORS.cyan, 0.06)} 0%, ${COLORS.bg} 70%); + border: 1px solid ${rgba(COLORS.cyan, 0.14)}; + } + .dm-opt-mobile__scene .dm-opt-canvas { position: absolute; inset: 0; z-index: 0; } + .dm-opt-mobile__scene-tag { + position: absolute; left: 10px; top: 10px; z-index: 1; + display: inline-flex; align-items: center; gap: 6px; + font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; font-weight: 700; + color: #E2E8F0; padding: 4px 9px; border-radius: 999px; + background: ${rgba(COLORS.ink, 0.72)}; border: 1px solid ${rgba(COLORS.cyan, 0.22)}; + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + } + + /* Metrics → 2-col grid (5th card spans full width), final optimized values, + readable sizing so nothing truncates. */ + .dm-opt-mfoot { padding: 0; } + .dm-opt-mobile .dm-opt-metrics { + grid-template-columns: repeat(2, 1fr); gap: 8px; max-width: none; + } + .dm-opt-mobile .dm-opt-metric { padding: 12px 13px 11px; border-radius: 12px; } + .dm-opt-mobile .dm-opt-metric:last-child { grid-column: 1 / -1; } + .dm-opt-mobile .dm-opt-metric__label { font-size: 10px; letter-spacing: 0.02em; } + .dm-opt-mobile .dm-opt-metric__value { font-size: clamp(20px, 6.5vw, 26px); } + + /* Insight bar wraps instead of overflowing. */ + .dm-opt-mobile .dm-opt-insight { + flex-wrap: wrap; max-width: none; gap: 6px 10px; padding: 8px 14px; margin-top: 4px; + } + .dm-opt-mobile .dm-opt-insight__text { font-size: 10px; } +} @media (prefers-reduced-motion: reduce) { .dm-opt-pulse { animation: none; } .dm-opt-metric { animation: none; opacity: 1; transform: none; } diff --git a/src/components/sections/WhyChooseDoormile.tsx b/src/components/sections/WhyChooseDoormile.tsx index 016e2c5..3dd6832 100644 --- a/src/components/sections/WhyChooseDoormile.tsx +++ b/src/components/sections/WhyChooseDoormile.tsx @@ -195,28 +195,37 @@ export default function WhyChooseDoormile() { flex-direction: column; gap: 16px; } - .wcd-card-points li { - position: relative; - padding-left: 30px; + .wcd-section .wcd-card-points li { + /* Flex row so the check icon and its label always sit on the same line. + Scoped with .wcd-section to outrank the global ".logico-front-end ul li" + theme rule, which adds 1.7em padding + a fontello bullet icon. */ + display: flex; + align-items: flex-start; + gap: 12px; + padding-left: 0; font-size: 16px; font-weight: 700; line-height: 1.3; color: #FFFFFF; } - .wcd-card-points li::before { - content: ""; - position: absolute; - left: 0; - top: 0.18em; - width: 14px; - height: 9px; - border-left: 2px solid #c01227; - border-bottom: 2px solid #c01227; - transform: rotate(-45deg) scale(1); + /* Suppress the theme's default fontello list bullet + (.logico-front-end ul li:before) so only our circle-check SVG renders. */ + .wcd-section .wcd-card-points li::before { + content: none; + display: none; + } + /* Clean circle-check feature icon (inline SVG, see markup below) — replaces + the old border-based chevron. Brand red with thin, rounded strokes. */ + .wcd-card-points .wcd-check { + flex: 0 0 auto; + width: 18px; + height: 18px; + margin-top: 0.12em; + color: #c01227; transition: transform 0.3s ease; } - .wcd-card:hover .wcd-card-points li::before { - transform: rotate(-45deg) scale(1.1); + .wcd-card:hover .wcd-card-points .wcd-check { + transform: scale(1.1); } @media (max-width: 1020px) { @@ -266,7 +275,22 @@ export default function WhyChooseDoormile() {

{stage.desc}

    {stage.points.map((point) => ( -
  • {point}
  • +
  • + + {point} +
  • ))}
diff --git a/src/components/sections/Workflow1.tsx b/src/components/sections/Workflow1.tsx index e00f8f5..d777a92 100644 --- a/src/components/sections/Workflow1.tsx +++ b/src/components/sections/Workflow1.tsx @@ -1,12 +1,14 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import OptimizationSection from "../optimization/OptimizationSection"; export default function Workflow1() { const [activeSlide, setActiveSlide] = useState(0); const [paused, setPaused] = useState(false); + const [inView, setInView] = useState(false); + const cardRef = useRef(null); const slides = [ { @@ -23,21 +25,36 @@ export default function Workflow1() { } ]; - // Always begin the storytelling sequence from slide 1 (01/03) on mount — never - // preserve a previous slide index across remounts / route changes back to MileTruth. + // 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); }, []); - // Auto-advance the carousel every 4s, infinite loop. Keyed on activeSlide so any - // manual selection resets the timer; pauses while the user hovers the card. + // 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(() => { - if (paused) return; + 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); - }, 4000); + }, 10000); return () => clearTimeout(id); - }, [activeSlide, paused, slides.length]); + }, [activeSlide, inView, paused, slides.length]); return (
@@ -47,7 +64,7 @@ export default function Workflow1() { {/* ── Bottom sub-section: Performance content, flush + colour-matched to the optimisation section above so the whole workflow reads as one container ── */} -
setPaused(true)} onMouseLeave={() => setPaused(false)}> +
setPaused(true)} onMouseLeave={() => setPaused(false)}> {/* Left Column: Overlapping Chevron Graphic */}
diff --git a/src/components/sections/Workflow2.tsx b/src/components/sections/Workflow2.tsx index 4606521..ab63788 100644 --- a/src/components/sections/Workflow2.tsx +++ b/src/components/sections/Workflow2.tsx @@ -1,12 +1,14 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import LogisticsBrainSection from "../logisticsbrain/LogisticsBrainSection"; export default function Workflow2() { const [activeSlide, setActiveSlide] = useState(0); const [paused, setPaused] = useState(false); + const [inView, setInView] = useState(false); + const cardRef = useRef(null); const slides = [ { @@ -23,21 +25,36 @@ export default function Workflow2() { } ]; - // Always begin the storytelling sequence from slide 1 (01/03) on mount — never - // preserve a previous slide index across remounts / route changes back to MileTruth. + // 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); }, []); - // Auto-advance the carousel every 4s, infinite loop. Keyed on activeSlide so any - // manual selection resets the timer; pauses while the user hovers the card. + // 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(() => { - if (paused) return; + 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); - }, 4000); + }, 10000); return () => clearTimeout(id); - }, [activeSlide, paused, slides.length]); + }, [activeSlide, inView, paused, slides.length]); return (
@@ -47,7 +64,7 @@ export default function Workflow2() { {/* ── Bottom sub-section: Innovation content, flush + colour-matched to the logistics-brain card above so the whole workflow reads as one container ── */} -
setPaused(true)} onMouseLeave={() => setPaused(false)}> +
setPaused(true)} onMouseLeave={() => setPaused(false)}> {/* Left Column: Overlapping Chevron Graphic */}
diff --git a/src/components/sections/Workflow3.tsx b/src/components/sections/Workflow3.tsx index 8c8d491..62e9405 100644 --- a/src/components/sections/Workflow3.tsx +++ b/src/components/sections/Workflow3.tsx @@ -1,12 +1,14 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import StrategySection from "../strategy/StrategySection"; export default function Workflow3() { const [activeSlide, setActiveSlide] = useState(0); const [paused, setPaused] = useState(false); + const [inView, setInView] = useState(false); + const cardRef = useRef(null); const slides = [ { @@ -23,21 +25,36 @@ export default function Workflow3() { } ]; - // Always begin the storytelling sequence from slide 1 (01/03) on mount — never - // preserve a previous slide index across remounts / route changes back to MileTruth. + // 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); }, []); - // Auto-advance the carousel every 4s, infinite loop. Keyed on activeSlide so any - // manual selection resets the timer; pauses while the user hovers the card. + // 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(() => { - if (paused) return; + 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); - }, 4000); + }, 10000); return () => clearTimeout(id); - }, [activeSlide, paused, slides.length]); + }, [activeSlide, inView, paused, slides.length]); return (
@@ -49,7 +66,7 @@ export default function Workflow3() { {/* ── Bottom sub-section: Strategy content, flush + pulled up to butt against the 3D card's flat bottom so the whole workflow reads as one container — the same connected structure used in Workflow 1 & 2 ── */} -
setPaused(true)} onMouseLeave={() => setPaused(false)}> +
setPaused(true)} onMouseLeave={() => setPaused(false)}> {/* Left Column: Overlapping Chevron Graphic */}
diff --git a/src/lib/bodyClasses.ts b/src/lib/bodyClasses.ts index 138caf3..e7526c9 100644 --- a/src/lib/bodyClasses.ts +++ b/src/lib/bodyClasses.ts @@ -11,7 +11,7 @@ /** Base classes shared by every page — safe to SSR before route is known. */ export const SHARED_BODY_CLASSES = - "wp-singular page-template-default page wp-theme-logico wp-child-theme-logico-child theme-logico woocommerce-no-js ehf-header ehf-footer ehf-template-logico ehf-stylesheet-logico-child logico-front-end logico-theme-style-rounded elementor-default elementor-kit-5 elementor-page"; + "wp-singular page-template-default page wp-theme-logico wp-child-theme-logico-child theme-logico ehf-header ehf-footer ehf-template-logico ehf-stylesheet-logico-child logico-front-end logico-theme-style-rounded elementor-default elementor-kit-5 elementor-page"; const SHARED = SHARED_BODY_CLASSES; @@ -19,7 +19,7 @@ const ROUTE_CLASSES: Record = { "/": `home-page ${SHARED} page-id-61 elementor-page-61 is-home-page`, // PHP source quirk: how-it-works omits elementor-kit-5 "/how-it-works": - "wp-singular page-template-default page page-id-59 wp-theme-logico wp-child-theme-logico-child theme-logico woocommerce-no-js ehf-header ehf-footer ehf-template-logico ehf-stylesheet-logico-child logico-front-end logico-theme-style-rounded elementor-default elementor-page elementor-page-59", + "wp-singular page-template-default page page-id-59 wp-theme-logico wp-child-theme-logico-child theme-logico ehf-header ehf-footer ehf-template-logico ehf-stylesheet-logico-child logico-front-end logico-theme-style-rounded elementor-default elementor-page elementor-page-59", "/miletruth": `${SHARED} page-id-59 elementor-page-59`, "/solutions": `${SHARED} page-id-59 elementor-page-59`, // PHP source quirk: about-us.php has `home` class (upstream bug, preserved)