mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 06:35:42 +00:00
feat(landing): add front door routing to passenger and freight
edrsc.com had no entry point — a visitor could not tell which of the two apps they wanted. This adds @edr/landing: one static page whose job is to make that choice obvious, with the two doors above the fold and the rest kept to supporting context. Fills in apps/edr-landing/, which until now held an orphaned next-env.d.ts from an abandoned Next 15 start and was built by nothing. - Next.js 14 static export. Versions mirror the passenger portal exactly, so they resolve to what the lockfile already had and add 6 packages. Depends on no workspace package — @edr/ui-common's Tailwind 4 tokens do not fit this Tailwind 3 setup. - Destinations come from NEXT_PUBLIC_PASSENGER_URL / NEXT_PUBLIC_FREIGHT_URL as origins, with the entry path appended in src/lib/apps.ts. Freight links to /portal rather than /, which is that app's own marketing landing. - Design lifted from EDRFreightLandingPage so the two public surfaces read as one railway. Figures (752 km, ~16 h) are the ones freight already asserts publicly. - Fonts load as a stylesheet, not next/font: next/font fetches from fonts.googleapis.com during `next build` and failed the build outright on a machine without network. Verified deterministic over repeated builds. - CountUp renders its final value server-side and the reveal animation's hidden state is scoped behind html.js, so the page is complete with JavaScript off instead of blank. turbo.json: a static export's artifact is out/, which the build task did not list — a cache hit would have restored a build with the artifact missing. Also gitignored. Dockerfile.web: passes the two NEXT_PUBLIC_* build args through (a static export inlines them at build time, so runtime env does nothing), and exempts this app from the VITE_* required-check that guards the freight Vite apps and would otherwise fail its build. Claude-Session: https://claude.ai/code/session_011ZMMHwK4Ham1Dt19FVZQj3
This commit is contained in:
116
apps/edr-landing/src/components/motion.tsx
Normal file
116
apps/edr-landing/src/components/motion.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/** Fires once when the element first scrolls into view. */
|
||||
function useReveal<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setShown(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setShown(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -12% 0px", threshold: 0.15 },
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return { ref, shown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fades and lifts its children in on first view.
|
||||
*
|
||||
* The hidden state lives behind `html.js` in globals.css, so with JavaScript
|
||||
* off the content renders plainly instead of staying at opacity 0 forever.
|
||||
*/
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
className = "",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const { ref, shown } = useReveal<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`edr-reveal ${shown ? "is-visible" : ""} ${className}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts up to `value` once scrolled into view.
|
||||
*
|
||||
* Starts at the final value so the static export and any no-JS reader show the
|
||||
* real number, then arms itself back to 0 on mount. The stats row is below the
|
||||
* fold, so that reset happens off-screen and is never seen.
|
||||
*/
|
||||
export function CountUp({
|
||||
value,
|
||||
prefix = "",
|
||||
suffix = "",
|
||||
duration = 1400,
|
||||
}: {
|
||||
value: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
duration?: number;
|
||||
}) {
|
||||
const { ref, shown } = useReveal<HTMLSpanElement>();
|
||||
const [display, setDisplay] = useState(value);
|
||||
const [armed, setArmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
||||
setDisplay(0);
|
||||
setArmed(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!armed || !shown) return;
|
||||
|
||||
let frame = 0;
|
||||
const start = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - start) / duration);
|
||||
// easeOutCubic
|
||||
setDisplay(value * (1 - Math.pow(1 - progress, 3)));
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [armed, shown, value, duration]);
|
||||
|
||||
return (
|
||||
<span ref={ref}>
|
||||
{prefix}
|
||||
{Math.round(display)}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user