mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 12:25:02 +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:
3
apps/edr-landing/next-env.d.ts
vendored
3
apps/edr-landing/next-env.d.ts
vendored
@@ -1,6 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
|
||||
13
apps/edr-landing/next.config.js
Normal file
13
apps/edr-landing/next.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Static HTML at build time — the page has no server work to do, and
|
||||
// infrastructure/docker/Dockerfile.web already serves an `out/` directory
|
||||
// through nginx. Also means NEXT_PUBLIC_* is inlined at build time, so the
|
||||
// two destination URLs must be passed as Docker build args, not runtime env.
|
||||
output: 'export',
|
||||
reactStrictMode: true,
|
||||
// No image optimizer exists behind a static export.
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
28
apps/edr-landing/package.json
Normal file
28
apps/edr-landing/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@edr/landing",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 5163",
|
||||
"build": "next build",
|
||||
"start": "next start -p 5163",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.446.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
6
apps/edr-landing/postcss.config.js
Normal file
6
apps/edr-landing/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
apps/edr-landing/public/edr-logo.png
Normal file
BIN
apps/edr-landing/public/edr-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
174
apps/edr-landing/src/app/globals.css
Normal file
174
apps/edr-landing/src/app/globals.css
Normal file
@@ -0,0 +1,174 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-white text-slate-900 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Motion — lifted from the freight portal's landing page so the two */
|
||||
/* public surfaces share one visual language. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.edr-reveal {
|
||||
transition:
|
||||
opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* The hidden state is scoped to `html.js`, set by an inline script in the
|
||||
layout. Without it a reader with JavaScript off would get a blank page —
|
||||
fatal for a front door whose whole job is two links. */
|
||||
.js .edr-reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
.js .edr-reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Hero: rail streaks racing toward the horizon */
|
||||
.edr-rails {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: repeating-linear-gradient(
|
||||
100deg,
|
||||
transparent 0 46px,
|
||||
rgba(52, 211, 153, 0.16) 46px 48px
|
||||
);
|
||||
mask-image: radial-gradient(120% 90% at 78% 40%, #000 0%, transparent 70%);
|
||||
-webkit-mask-image: radial-gradient(120% 90% at 78% 40%, #000 0%, transparent 70%);
|
||||
animation: edr-rails 9s linear infinite;
|
||||
}
|
||||
@keyframes edr-rails {
|
||||
to {
|
||||
background-position: 480px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.edr-hero-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(46% 60% at 12% 22%, rgba(10, 138, 95, 0.45), transparent 70%),
|
||||
radial-gradient(40% 55% at 88% 82%, rgba(242, 165, 22, 0.22), transparent 70%);
|
||||
animation: edr-breathe 11s ease-in-out infinite;
|
||||
}
|
||||
@keyframes edr-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.75;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulsing live dot */
|
||||
.edr-pulse-dot {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: #34d399;
|
||||
}
|
||||
.edr-pulse-dot::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid currentColor;
|
||||
color: inherit;
|
||||
background: inherit;
|
||||
opacity: 0.55;
|
||||
animation: edr-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
@keyframes edr-ping {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.6;
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(2.1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The corridor, with a train light running its length */
|
||||
.edr-corridor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 18px;
|
||||
}
|
||||
.edr-corridor::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(52, 211, 153, 0.28);
|
||||
}
|
||||
.edr-corridor-node {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
background: #10202f;
|
||||
border: 2px solid #34d399;
|
||||
}
|
||||
.edr-corridor-node.is-hub {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #34d399;
|
||||
}
|
||||
.edr-corridor-node.is-border {
|
||||
background: #f2a516;
|
||||
border-color: #f2a516;
|
||||
}
|
||||
.edr-corridor-train {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 74px;
|
||||
height: 4px;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(90deg, transparent, #f2a516);
|
||||
box-shadow: 0 0 18px 3px rgba(242, 165, 22, 0.55);
|
||||
animation: edr-run 12s linear infinite;
|
||||
}
|
||||
@keyframes edr-run {
|
||||
0% {
|
||||
left: -6%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.js .edr-reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
.edr-rails,
|
||||
.edr-hero-glow,
|
||||
.edr-pulse-dot::after,
|
||||
.edr-corridor-train {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
106
apps/edr-landing/src/app/layout.tsx
Normal file
106
apps/edr-landing/src/app/layout.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://edrsc.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: "Ethio-Djibouti Railway — Passenger & Freight",
|
||||
description:
|
||||
"The Ethio-Djibouti Railway online. Book a train ticket on the passenger portal, or book and track cargo on the freight portal — 752 km of electrified standard-gauge railway between Addis Ababa and Djibouti.",
|
||||
applicationName: "Ethio-Djibouti Railway",
|
||||
authors: [{ name: "EDR — Ethio-Djibouti Railway" }],
|
||||
creator: "EDR — Ethio-Djibouti Railway",
|
||||
publisher: "EDR — Ethio-Djibouti Railway",
|
||||
alternates: { canonical: "/" },
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: { index: true, follow: true, "max-image-preview": "large" },
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: siteUrl,
|
||||
siteName: "Ethio-Djibouti Railway",
|
||||
title: "Ethio-Djibouti Railway — Passenger & Freight",
|
||||
description:
|
||||
"Book a seat or ship a container on the same railway. Pick the service you need.",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Ethio-Djibouti Railway — Passenger & Freight",
|
||||
description:
|
||||
"Book a seat or ship a container on the same railway. Pick the service you need.",
|
||||
},
|
||||
icons: {
|
||||
icon: "/edr-logo.png",
|
||||
shortcut: "/edr-logo.png",
|
||||
apple: "/edr-logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: "#0c1a2b",
|
||||
};
|
||||
|
||||
const organizationSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: "Ethio-Djibouti Railway (EDR)",
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/edr-logo.png`,
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
telephone: "9546",
|
||||
email: "edr_@edrsc.com",
|
||||
contactType: "customer service",
|
||||
availableLanguage: ["English", "Amharic"],
|
||||
},
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressLocality: "Addis Ababa",
|
||||
addressCountry: "ET",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
{/* Loaded as a stylesheet rather than next/font, which fetches from
|
||||
fonts.googleapis.com during `next build` and fails the whole build
|
||||
on a machine without network. Matches the freight portal. */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
{/* Gates the reveal animation's hidden state (see globals.css) so the
|
||||
page is fully readable with JavaScript off. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: "document.documentElement.classList.add('js')",
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
|
||||
/>
|
||||
</head>
|
||||
<body className="font-sans antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
395
apps/edr-landing/src/app/page.tsx
Normal file
395
apps/edr-landing/src/app/page.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
Container,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
|
||||
import { CountUp, Reveal } from "@/components/motion";
|
||||
import { FREIGHT_URL, PASSENGER_URL } from "@/lib/apps";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Content */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const doors = [
|
||||
{
|
||||
key: "passenger",
|
||||
icon: TrainFront,
|
||||
name: "Passenger",
|
||||
lede: "Travelling yourself.",
|
||||
href: PASSENGER_URL,
|
||||
cta: "Open Passenger",
|
||||
points: [
|
||||
"Search trains and book a seat",
|
||||
"Choose your class and seat",
|
||||
"Pay with Telebirr or CBE Birr",
|
||||
"Manage or reschedule a trip",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "freight",
|
||||
icon: Container,
|
||||
name: "Freight",
|
||||
lede: "Shipping goods.",
|
||||
href: FREIGHT_URL,
|
||||
cta: "Open Freight",
|
||||
points: [
|
||||
"Book containers or bulk cargo",
|
||||
"Track every wagon live",
|
||||
"Customs-ready documents",
|
||||
"Invoices and settlement",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const stations = [
|
||||
{ name: "Addis Ababa", kind: "hub" },
|
||||
{ name: "Mojo", kind: "stop" },
|
||||
{ name: "Adama", kind: "stop" },
|
||||
{ name: "Awash", kind: "stop" },
|
||||
{ name: "Dire Dawa", kind: "hub" },
|
||||
{ name: "Dewele", kind: "border" },
|
||||
{ name: "Djibouti Port", kind: "hub" },
|
||||
] as const;
|
||||
|
||||
const stats = [
|
||||
{ value: 752, suffix: " km", label: "Addis Ababa to Djibouti, electrified standard gauge" },
|
||||
{ value: 16, prefix: "~", suffix: " h", label: "Mojo Dry Port to Djibouti Port transit" },
|
||||
{ value: 2, label: "Countries connected by one railway" },
|
||||
{ text: "24/7", label: "Booking and tracking online" },
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href="#choose"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[60] focus:rounded-lg focus:bg-white focus:px-4 focus:py-2 focus:font-semibold focus:text-edr-ink"
|
||||
>
|
||||
Skip to the services
|
||||
</a>
|
||||
|
||||
<Header />
|
||||
|
||||
<main>
|
||||
<Hero />
|
||||
<Corridor />
|
||||
<Stats />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Wordmark({ subtitle = true }: { subtitle?: boolean }) {
|
||||
return (
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-edr-primary text-white shadow-lg shadow-emerald-500/25">
|
||||
<TrainFront className="size-5" />
|
||||
</span>
|
||||
|
||||
<span className="leading-tight">
|
||||
<span className="block text-lg font-bold text-white">EDR</span>
|
||||
{subtitle ? (
|
||||
<span className="block text-[11px] tracking-wide text-slate-400">
|
||||
Ethio-Djibouti Railway
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-white/10 bg-edr-ink/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6">
|
||||
<a href="#choose" aria-label="Ethio-Djibouti Railway, back to top">
|
||||
<Wordmark />
|
||||
</a>
|
||||
|
||||
<nav className="hidden items-center gap-8 sm:flex">
|
||||
<a
|
||||
href={PASSENGER_URL}
|
||||
className="text-sm font-medium text-slate-300 transition hover:text-white"
|
||||
>
|
||||
Passenger
|
||||
</a>
|
||||
<a
|
||||
href={FREIGHT_URL}
|
||||
className="text-sm font-medium text-slate-300 transition hover:text-white"
|
||||
>
|
||||
Freight
|
||||
</a>
|
||||
<a
|
||||
href="#contact"
|
||||
className="text-sm font-medium text-slate-300 transition hover:text-white"
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Hero() {
|
||||
return (
|
||||
<section id="choose" className="relative overflow-hidden bg-edr-ink">
|
||||
<div className="edr-rails" aria-hidden />
|
||||
<div className="edr-hero-glow" aria-hidden />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-6 py-12 lg:py-14">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<Reveal>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 py-1.5 pl-2 pr-3 text-[11px] font-medium text-white sm:text-[13px]">
|
||||
<span className="edr-pulse-dot" />
|
||||
Addis Ababa – Djibouti · 752 km electrified corridor
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={80}>
|
||||
<h1 className="mt-5 text-4xl font-bold leading-[1.08] tracking-tight text-white md:text-5xl">
|
||||
The Ethio-Djibouti Railway,
|
||||
<span className="block text-edr-light">online.</span>
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={160}>
|
||||
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-slate-300 md:text-lg">
|
||||
Book a seat or ship a container on the same railway. Pick the
|
||||
service you need.
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{/* The two doors — the reason this page exists. */}
|
||||
<div className="mt-9 grid gap-6 md:grid-cols-2">
|
||||
{doors.map((door, index) => (
|
||||
<Reveal key={door.key} delay={240 + index * 100}>
|
||||
<Door {...door} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal delay={460}>
|
||||
<p className="mt-6 text-center text-sm text-slate-400">
|
||||
Travelling yourself?{" "}
|
||||
<span className="font-semibold text-white">Passenger.</span>{" "}
|
||||
Shipping goods?{" "}
|
||||
<span className="font-semibold text-white">Freight.</span>
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Door({
|
||||
icon: Icon,
|
||||
name,
|
||||
lede,
|
||||
href,
|
||||
cta,
|
||||
points,
|
||||
}: (typeof doors)[number]) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="group flex h-full flex-col rounded-3xl bg-white p-6 shadow-2xl ring-1 ring-white/15 transition duration-300 hover:-translate-y-1 hover:ring-4 hover:ring-edr-light focus-visible:-translate-y-1 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-edr-light md:p-7"
|
||||
>
|
||||
<span className="flex size-12 items-center justify-center rounded-2xl bg-emerald-50 text-edr-primary">
|
||||
<Icon className="size-6" />
|
||||
</span>
|
||||
|
||||
<h2 className="mt-4 text-2xl font-bold tracking-tight text-edr-ink">
|
||||
{name}
|
||||
</h2>
|
||||
<p className="mt-1 text-base font-medium text-edr-primary">{lede}</p>
|
||||
|
||||
<ul className="mt-4 space-y-2 text-[15px] text-slate-600">
|
||||
{points.map((point) => (
|
||||
<li key={point} className="flex items-start gap-2.5">
|
||||
<span
|
||||
className="mt-[7px] size-1.5 shrink-0 rounded-full bg-edr-light"
|
||||
aria-hidden
|
||||
/>
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<span className="mt-6 flex items-center justify-center gap-2 rounded-xl bg-edr-primary px-6 py-3.5 font-semibold text-white transition group-hover:bg-edr-primary-dark">
|
||||
{cta}
|
||||
<ArrowRight className="size-4 transition group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Corridor() {
|
||||
return (
|
||||
<section className="bg-edr-ink-2 py-14">
|
||||
<div className="mx-auto max-w-4xl px-10 sm:px-14">
|
||||
<Reveal>
|
||||
<div className="relative pb-8">
|
||||
<span
|
||||
className="absolute inset-x-0 top-2 h-0.5 -translate-y-1/2 bg-edr-light/25"
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className="edr-corridor-train"
|
||||
style={{ top: 8 }}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<ol className="relative flex justify-between">
|
||||
{stations.map((station, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === stations.length - 1;
|
||||
|
||||
return (
|
||||
<li key={station.name} className="relative flex h-4 items-center">
|
||||
<span
|
||||
className={`edr-corridor-node ${
|
||||
station.kind === "hub"
|
||||
? "is-hub"
|
||||
: station.kind === "border"
|
||||
? "is-border"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Absolute so a long label never widens its column — the
|
||||
nodes have to stay evenly spaced along the rail. */}
|
||||
<span
|
||||
className={`absolute top-7 whitespace-nowrap text-[11px] text-slate-400 ${
|
||||
isFirst
|
||||
? "left-0"
|
||||
: isLast
|
||||
? "right-0"
|
||||
: "left-1/2 -translate-x-1/2"
|
||||
} ${isFirst || isLast ? "" : "hidden sm:block"}`}
|
||||
>
|
||||
{station.name}
|
||||
{station.kind === "border" ? " (border)" : ""}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stats() {
|
||||
return (
|
||||
<section className="bg-edr-ink-2 pb-20">
|
||||
<div className="mx-auto max-w-6xl px-6">
|
||||
<Reveal>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-10 border-t border-white/10 pt-12 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label}>
|
||||
<dt className="text-3xl font-bold tracking-tight text-white md:text-4xl">
|
||||
{"text" in stat ? (
|
||||
stat.text
|
||||
) : (
|
||||
<CountUp
|
||||
value={stat.value}
|
||||
prefix={"prefix" in stat ? stat.prefix : ""}
|
||||
suffix={"suffix" in stat ? stat.suffix : ""}
|
||||
/>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-2 text-sm leading-relaxed text-slate-400">
|
||||
{stat.label}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer id="contact" className="bg-edr-ink">
|
||||
<div className="mx-auto max-w-6xl px-6 py-14">
|
||||
<div className="grid gap-10 sm:grid-cols-3">
|
||||
<div>
|
||||
<Wordmark />
|
||||
<p className="mt-4 max-w-xs text-sm leading-relaxed text-slate-400">
|
||||
The electrified standard-gauge railway connecting Ethiopia and
|
||||
Djibouti.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">Services</h2>
|
||||
<ul className="mt-4 space-y-3 text-sm">
|
||||
<li>
|
||||
<a
|
||||
href={PASSENGER_URL}
|
||||
className="text-slate-400 transition hover:text-white"
|
||||
>
|
||||
Passenger portal
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={FREIGHT_URL}
|
||||
className="text-slate-400 transition hover:text-white"
|
||||
>
|
||||
Freight portal
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">Contact</h2>
|
||||
<ul className="mt-4 space-y-3 text-sm text-slate-400">
|
||||
<li className="flex items-center gap-2.5">
|
||||
<Phone className="size-4 shrink-0 text-edr-light" />
|
||||
<a href="tel:9546" className="transition hover:text-white">
|
||||
9546
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<Mail className="size-4 shrink-0 text-edr-light" />
|
||||
<a
|
||||
href="mailto:edr_@edrsc.com"
|
||||
className="transition hover:text-white"
|
||||
>
|
||||
edr_@edrsc.com
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<MapPin className="size-4 shrink-0 text-edr-light" />
|
||||
Addis Ababa, Ethiopia
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-12 border-t border-white/10 pt-6 text-xs text-slate-500">
|
||||
© {new Date().getFullYear()} Ethio-Djibouti Railway. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
16
apps/edr-landing/src/lib/apps.ts
Normal file
16
apps/edr-landing/src/lib/apps.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// The two destinations this page exists to route to. Set NEXT_PUBLIC_* to each
|
||||
// app's origin; the entry path is appended here so no deploy has to remember
|
||||
// it. Inlined at build time by `output: 'export'`, so these are passed as
|
||||
// Docker build args (see infrastructure/docker/Dockerfile.web), not runtime env.
|
||||
const origin = (url: string) => url.replace(/\/+$/, "");
|
||||
|
||||
// The passenger app serves its booking search at `/` — that is already the app.
|
||||
export const PASSENGER_URL = origin(
|
||||
process.env.NEXT_PUBLIC_PASSENGER_URL ?? "https://passenger.edrsc.com",
|
||||
);
|
||||
|
||||
// The freight app serves its own marketing landing at `/`, so link past it:
|
||||
// `/portal` is the app itself, and redirects to /login when signed out.
|
||||
export const FREIGHT_URL = `${origin(
|
||||
process.env.NEXT_PUBLIC_FREIGHT_URL ?? "https://freight.edrsc.com",
|
||||
)}/portal`;
|
||||
24
apps/edr-landing/tailwind.config.js
Normal file
24
apps/edr-landing/tailwind.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Palette lifted from the freight landing page so the two public
|
||||
// surfaces read as one railway.
|
||||
edr: {
|
||||
ink: "#0c1a2b",
|
||||
"ink-2": "#10202f",
|
||||
primary: "#0a8a5f",
|
||||
"primary-dark": "#087049",
|
||||
light: "#34d399",
|
||||
amber: "#f2a516",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
12
apps/edr-landing/tsconfig.json
Normal file
12
apps/edr-landing/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../packages/config/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"plugins": [{ "name": "next" }],
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user