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:
ghost2023
2026-09-04 20:32:53 +03:00
parent 062995d268
commit 4b3e8ad32c
17 changed files with 1181 additions and 57 deletions

View 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>
);
}