feat(landing): redesign front door with official logo, photo hero and portal previews

Replace the icon-and-cards landing with a photographic hero that
alternates
between the Furi station and freight train photos, a single headline,
and two
compact glass cards that pin the background on hover. Below it: the
corridor
rail, one showcase mockup per portal (passenger trip search, freight
live
tracking), the existing stats and footer.

- Add the official EDR logo as transparent green/white PNGs (lockup and
mark),
  used in the header, footer and favicon
- Switch to Plus Jakarta Sans and a palette keyed to the logo green
- Split the page into components under src/components
- Header links: Passenger, Freight, Publications
This commit is contained in:
nathnael
2026-09-04 18:56:51 +00:00
parent 4b3e8ad32c
commit 29a7381335
18 changed files with 862 additions and 451 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

View File

@@ -4,24 +4,53 @@
@layer base {
body {
@apply bg-white text-slate-900 antialiased;
@apply bg-white text-edr-ink antialiased;
}
}
/* ------------------------------------------------------------------ */
/* Motion — lifted from the freight portal's landing page so the two */
/* public surfaces share one visual language. */
/* Motion */
/* */
/* Every hidden-by-default state is scoped to `html.js`, which an */
/* inline script in the layout sets. With JavaScript off the page */
/* renders plainly instead of staying invisible — fatal for a front */
/* door whose whole job is two links. */
/* ------------------------------------------------------------------ */
/* One-shot page-load choreography for the hero copy. */
.js .edr-rise {
opacity: 0;
transform: translateY(18px);
animation: edr-rise 0.9s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: var(--rise-delay, 0ms);
}
@keyframes edr-rise {
to {
opacity: 1;
transform: none;
}
}
/* Hero photos drift slowly while they are on screen. */
.js .edr-kenburns {
animation: edr-kenburns 16s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes edr-kenburns {
from {
transform: scale(1);
}
to {
transform: scale(1.08);
}
}
/* Scroll reveal for the sections below the fold. */
.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);
@@ -31,45 +60,6 @@
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;
@@ -79,6 +69,9 @@
border-radius: 9999px;
background: #34d399;
}
.edr-pulse-dot--green {
background: #008550;
}
.edr-pulse-dot::after {
content: "";
position: absolute;
@@ -90,6 +83,18 @@
opacity: 0.55;
animation: edr-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;
}
.edr-ping {
position: relative;
}
.edr-ping::after {
content: "";
position: absolute;
inset: -4px;
border-radius: 9999px;
background: currentColor;
opacity: 0.35;
animation: edr-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes edr-ping {
0% {
transform: scale(0.6);
@@ -103,23 +108,6 @@
}
/* 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;
@@ -159,16 +147,90 @@
}
}
/* Mockups ported from the freight portal's landing page */
.edr-float {
animation: edr-float 7s ease-in-out infinite;
}
@keyframes edr-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
.edr-minitrack {
position: relative;
height: 14px;
}
.edr-minitrack::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
transform: translateY(-50%);
background: #c9e9db;
border-radius: 9999px;
}
.edr-minitrack-dot {
position: absolute;
top: 50%;
width: 9px;
height: 9px;
margin-top: -4.5px;
border-radius: 9999px;
background: #008550;
box-shadow:
0 0 0 4px rgba(0, 133, 80, 0.18),
0 0 14px 2px rgba(0, 133, 80, 0.5);
animation: edr-run-dot 6s ease-in-out infinite;
}
@keyframes edr-run-dot {
0%,
8% {
left: 0%;
}
92%,
100% {
left: calc(100% - 9px);
}
}
.edr-grow {
transform-origin: bottom;
animation: edr-grow 0.8s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes edr-grow {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.js .edr-rise,
.js .edr-kenburns {
opacity: 1;
transform: none;
animation: none;
}
.js .edr-reveal {
opacity: 1;
transform: none;
transition: none;
}
.edr-rails,
.edr-hero-glow,
.edr-pulse-dot::after,
.edr-corridor-train {
.edr-ping::after,
.edr-corridor-train,
.edr-float,
.edr-minitrack-dot,
.edr-grow {
animation: none !important;
}
}

View File

@@ -27,17 +27,26 @@ export const metadata: Metadata = {
title: "Ethio-Djibouti Railway — Passenger & Freight",
description:
"Book a seat or ship a container on the same railway. Pick the service you need.",
images: [
{
url: "/photos/station.jpg",
width: 1280,
height: 720,
alt: "Furi-Lebu station of the Ethio-Djibouti Railway",
},
],
},
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.",
images: ["/photos/station.jpg"],
},
icons: {
icon: "/edr-logo.png",
shortcut: "/edr-logo.png",
apple: "/edr-logo.png",
icon: "/brand/edr-mark-green.png",
shortcut: "/brand/edr-mark-green.png",
apple: "/brand/edr-mark-green.png",
},
};
@@ -52,7 +61,7 @@ const organizationSchema = {
"@type": "Organization",
name: "Ethio-Djibouti Railway (EDR)",
url: siteUrl,
logo: `${siteUrl}/edr-logo.png`,
logo: `${siteUrl}/brand/edr-logo-green.png`,
contactPoint: {
"@type": "ContactPoint",
telephone: "9546",
@@ -85,7 +94,7 @@ export default function RootLayout({
crossOrigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
{/* Gates the reveal animation's hidden state (see globals.css) so the

View File

@@ -1,70 +1,9 @@
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 */
/* ------------------------------------------------------------------ */
import { Corridor } from "@/components/corridor";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import { Hero } from "@/components/hero";
import { Portals } from "@/components/portals";
import { Stats } from "@/components/stats";
export default function Home() {
return (
@@ -78,9 +17,10 @@ export default function Home() {
<Header />
<main>
<main id="top">
<Hero />
<Corridor />
<Portals />
<Stats />
</main>
@@ -88,308 +28,3 @@ export default function Home() {
</>
);
}
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>
);
}

View File

@@ -0,0 +1,42 @@
// The official EDR logo, keyed from the brand JPEGs into transparent PNGs
// (see public/brand). `lockup` is the mark plus wordmark; `mark` is the swoosh.
const assets = {
lockup: {
white: "/brand/edr-logo-white.png",
green: "/brand/edr-logo-green.png",
width: 1600,
height: 668,
},
mark: {
white: "/brand/edr-mark-white.png",
green: "/brand/edr-mark-green.png",
width: 1234,
height: 386,
},
} as const;
export function Logo({
tone = "white",
variant = "lockup",
decorative = false,
className = "",
}: {
tone?: "white" | "green";
variant?: "lockup" | "mark";
decorative?: boolean;
className?: string;
}) {
const asset = assets[variant];
return (
// eslint-disable-next-line @next/next/no-img-element -- static export, no optimizer
<img
src={asset[tone]}
width={asset.width}
height={asset.height}
alt={decorative ? "" : "Ethio-Djibouti Railway"}
decoding="async"
className={className}
/>
);
}

View File

@@ -0,0 +1,77 @@
const stations = [
{ name: "Addis Ababa", kind: "hub", note: "Km 0" },
{ name: "Mojo", kind: "stop", note: "Dry port" },
{ name: "Adama", kind: "stop" },
{ name: "Awash", kind: "stop" },
{ name: "Dire Dawa", kind: "hub" },
{ name: "Dewele", kind: "border", note: "Customs" },
{ name: "Djibouti Port", kind: "hub", note: "Km 752" },
] as const;
/** The line itself: seven stations on one rail, with a train light running it. */
export function Corridor() {
return (
<section aria-label="The Addis Ababa to Djibouti corridor" className="bg-edr-ink-2 py-12">
<div className="mx-auto max-w-4xl px-10 sm:px-14">
<div className="relative pb-12">
<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] leading-tight ${
isFirst
? "left-0 text-left"
: isLast
? "right-0 text-right"
: "left-1/2 -translate-x-1/2 text-center"
} ${
// Only the ends and the hub fit under a phone-width rail.
isFirst || isLast || station.kind === "hub"
? ""
: "hidden sm:block"
}`}
>
<span className="block font-medium text-slate-300">
{station.name}
</span>
{"note" in station ? (
<span
className={`block ${
station.kind === "border" ? "text-edr-amber" : "text-slate-500"
}`}
>
{station.note}
</span>
) : null}
</span>
</li>
);
})}
</ol>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,65 @@
import { Mail, MapPin, Phone } from "lucide-react";
import { FREIGHT_URL, PASSENGER_URL } from "@/lib/apps";
import { Logo } from "./brand";
export 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>
<Logo className="h-12 w-auto" />
<p className="mt-5 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>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { FREIGHT_URL, PASSENGER_URL, PUBLICATIONS_URL } from "@/lib/apps";
import { Logo } from "./brand";
const link =
"rounded-md text-sm font-semibold text-white/80 transition hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-edr-light";
/**
* Sits transparently over the hero photos, then turns solid once the reader
* scrolls so it stays legible over the light sections.
*/
export function Header() {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 40);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
scrolled
? "border-b border-white/10 bg-edr-ink/85 backdrop-blur-xl"
: "bg-gradient-to-b from-edr-ink/70 to-transparent"
}`}
>
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between px-6">
<a
href="#top"
aria-label="Ethio-Djibouti Railway, back to top"
className="rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-edr-light"
>
<Logo className="h-9 w-auto sm:h-11" />
</a>
<nav aria-label="Primary" className="flex items-center gap-5 sm:gap-8">
<a href={PASSENGER_URL} className={link}>
Passenger
</a>
<a href={FREIGHT_URL} className={link}>
Freight
</a>
<a href={PUBLICATIONS_URL} className={`hidden sm:block ${link}`}>
Publications
</a>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { ArrowRight, Container, TrainFront } from "lucide-react";
import { useEffect, useState } from "react";
import { FREIGHT_URL, PASSENGER_URL } from "@/lib/apps";
const doors = [
{
key: "passenger",
icon: TrainFront,
title: "Passenger",
body: "Book a seat, choose your class, pay with Telebirr or CBE Birr.",
href: PASSENGER_URL,
cta: "Open Passenger",
photo: {
src: "/photos/station.jpg",
width: 1280,
height: 720,
alt: "Furi-Lebu station in Addis Ababa, the Ethio-Djibouti Railway's passenger terminal",
position: "center 30%",
},
},
{
key: "freight",
icon: Container,
title: "Freight",
body: "Book wagons and containers, track every one of them live.",
href: FREIGHT_URL,
cta: "Open Freight",
photo: {
src: "/photos/freight-train.jpg",
width: 1280,
height: 1134,
alt: "An Ethio-Djibouti Railway locomotive hauling container wagons",
position: "45% 45%",
},
},
] as const;
const ROTATE_MS = 7000;
const rise = (ms: number) =>
({ "--rise-delay": `${ms}ms` }) as React.CSSProperties;
/**
* One photograph at a time behind one message. The background alternates
* between the two portals' worlds on its own, and settles on whichever card
* the reader is hovering.
*/
export function Hero() {
const [active, setActive] = useState(0);
const [pinned, setPinned] = useState<number | null>(null);
useEffect(() => {
if (pinned !== null) return;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
const id = window.setInterval(
() => setActive((index) => (index + 1) % doors.length),
ROTATE_MS,
);
return () => window.clearInterval(id);
}, [pinned]);
const shown = pinned ?? active;
return (
<section
id="choose"
aria-labelledby="hero-title"
className="relative flex min-h-[100svh] items-center overflow-hidden bg-edr-ink"
>
{doors.map((door, index) => (
// eslint-disable-next-line @next/next/no-img-element -- static export
<img
key={door.key}
src={door.photo.src}
width={door.photo.width}
height={door.photo.height}
alt={door.photo.alt}
fetchPriority={index === 0 ? "high" : "low"}
decoding="async"
className={`edr-kenburns absolute inset-0 h-full w-full object-cover transition-opacity duration-[1400ms] ease-out ${
index === shown ? "opacity-100" : "opacity-0"
}`}
style={{ objectPosition: door.photo.position }}
/>
))}
{/* Backdrop: a heavy ink wash so the message and cards always read. */}
<div
className="absolute inset-0 bg-gradient-to-b from-edr-ink/75 via-edr-ink/60 to-edr-ink"
aria-hidden
/>
<div
className="absolute inset-0 bg-[radial-gradient(70%_60%_at_50%_45%,transparent,rgba(12,26,43,0.55))]"
aria-hidden
/>
<div className="relative z-10 mx-auto w-full max-w-5xl px-6 pb-20 pt-36 text-center lg:pb-24">
<p
className="edr-rise text-sm font-semibold text-edr-light sm:text-base"
style={rise(0)}
>
Addis Ababa to Djibouti, by rail
</p>
<h1
id="hero-title"
className="edr-rise mx-auto mt-4 max-w-4xl text-5xl font-extrabold leading-[1.0] tracking-[-0.025em] text-white sm:text-6xl lg:text-[84px]"
style={rise(90)}
>
One railway.
<br />
Two ways to move.
</h1>
<p
className="edr-rise mx-auto mt-6 max-w-xl text-lg leading-relaxed text-slate-200 sm:text-xl"
style={rise(180)}
>
Book a seat or ship a container on the Ethio-Djibouti Railway. Pick
the service you need.
</p>
<div className="mx-auto mt-12 grid max-w-2xl gap-4 sm:grid-cols-2">
{doors.map(({ key, icon: Icon, title, body, href, cta }, index) => (
<a
key={key}
href={href}
onMouseEnter={() => setPinned(index)}
onMouseLeave={() => setPinned(null)}
onFocus={() => setPinned(index)}
onBlur={() => setPinned(null)}
className={`edr-rise group flex flex-col rounded-2xl border p-5 text-left backdrop-blur-xl transition duration-300 hover:-translate-y-1 focus-visible:-translate-y-1 focus-visible:outline-none ${
index === shown
? "border-edr-light/60 bg-white/15 shadow-[0_24px_60px_-20px_rgba(52,211,153,0.35)]"
: "border-white/15 bg-white/10 hover:border-edr-light/60 hover:bg-white/15"
}`}
style={rise(280 + index * 90)}
>
<span className="flex size-10 items-center justify-center rounded-xl bg-edr-primary text-white shadow-lg shadow-emerald-900/40">
<Icon className="size-5" />
</span>
<span className="mt-4 text-xl font-bold text-white">{title}</span>
<span className="mt-1 text-sm leading-relaxed text-slate-300">
{body}
</span>
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-semibold text-edr-light">
{cta}
<ArrowRight className="size-4 transition group-hover:translate-x-0.5" />
</span>
</a>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,253 @@
import {
ArrowLeftRight,
ArrowRight,
Armchair,
CalendarDays,
FileCheck,
MapPin,
Radar,
Receipt,
Smartphone,
Ticket,
Users,
} from "lucide-react";
import { FREIGHT_URL, PASSENGER_URL } from "@/lib/apps";
import { Reveal } from "./motion";
/* ------------------------------------------------------------------ */
/* Passenger — the trip search, as it looks in the portal */
/* ------------------------------------------------------------------ */
function Field({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon: React.ElementType;
}) {
return (
<div className="flex items-center gap-3 rounded-xl border-2 border-edr-border bg-edr-surface px-3.5 py-2.5">
<Icon className="size-4 shrink-0 text-edr-primary" />
<span className="min-w-0">
<span className="block text-[11px] font-medium text-edr-muted">{label}</span>
<span className="block truncate text-[15px] font-semibold">{value}</span>
</span>
</div>
);
}
function SearchMock() {
return (
<div className="rounded-3xl border border-edr-border bg-white p-5 shadow-[0_24px_60px_-24px_rgba(12,26,43,0.35)] sm:p-6">
<div className="inline-flex rounded-xl bg-slate-100 p-1 text-sm font-semibold">
<span className="rounded-lg bg-white px-3.5 py-1.5 text-edr-primary shadow-sm">
One way
</span>
<span className="px-3.5 py-1.5 text-edr-muted">Round trip</span>
</div>
<div className="mt-4 grid items-center gap-2.5 sm:grid-cols-[1fr_auto_1fr]">
<Field label="From" value="Addis Ababa" icon={MapPin} />
<span className="mx-auto flex size-9 items-center justify-center rounded-full border-2 border-edr-border bg-edr-surface text-slate-500">
<ArrowLeftRight className="size-4" />
</span>
<Field label="To" value="Dire Dawa" icon={MapPin} />
</div>
<div className="mt-2.5 grid grid-cols-2 gap-2.5">
<Field label="Date" value="Fri 12 Sep" icon={CalendarDays} />
<Field label="Passengers" value="1 adult" icon={Users} />
</div>
<span className="mt-4 flex items-center justify-center rounded-xl bg-edr-primary py-3 text-[15px] font-semibold text-white">
Search trains
</span>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Freight — the live tracking card from the freight portal */
/* ------------------------------------------------------------------ */
const trackingStops = [
{ name: "Mojo Dry Port", time: "Departed 06:10", state: "done" },
{ name: "Adama", time: "08:05", state: "done" },
{ name: "Awash", time: "Now · 64 km/h", state: "current" },
{ name: "Dire Dawa", time: "ETA 14:30", state: "idle" },
{ name: "Dewele customs", time: "ETA 17:50", state: "idle" },
{ name: "Djibouti Port", time: "ETA 22:10", state: "idle" },
] as const;
function TrackingMock() {
return (
<div className="edr-float overflow-hidden rounded-3xl border border-edr-border bg-white shadow-[0_24px_60px_-24px_rgba(12,26,43,0.35)]">
<div className="flex items-center justify-between gap-4 border-b border-edr-border px-6 py-4">
<div className="min-w-0">
<p className="truncate text-base font-bold">CN-2026-08417</p>
<p className="truncate text-xs text-edr-muted">
Mojo Dry Port Djibouti Port · 40ft × 12
</p>
</div>
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700">
<span className="edr-pulse-dot edr-pulse-dot--green" />
In transit
</span>
</div>
<ol className="px-6 py-5">
{trackingStops.map((stop, index) => {
const isLast = index === trackingStops.length - 1;
const done = stop.state === "done";
const current = stop.state === "current";
const nextDone = !isLast && trackingStops[index + 1].state === "done";
return (
<li key={stop.name} className="flex items-stretch gap-3.5">
<span className="flex w-4 flex-col items-center">
<span
className={`size-3 shrink-0 rounded-full border-2 ${
done
? "border-edr-primary bg-edr-primary"
: current
? "edr-ping border-edr-amber bg-edr-amber text-edr-amber"
: "border-slate-300 bg-white"
}`}
/>
{!isLast ? (
<span
className={`my-1 w-0.5 flex-1 ${
done && nextDone ? "bg-edr-primary" : "bg-edr-border"
}`}
/>
) : null}
</span>
<span
className={`flex flex-1 items-center justify-between gap-3 ${
isLast ? "" : "pb-4"
}`}
>
<span
className={`text-sm ${
current
? "font-semibold"
: done
? "font-medium"
: "font-medium text-edr-muted"
}`}
>
{stop.name}
</span>
<span className="text-xs text-edr-muted">{stop.time}</span>
</span>
</li>
);
})}
</ol>
<div className="grid grid-cols-3 gap-3 bg-edr-surface px-6 pb-5 pt-4">
{[
["Train", "TS-14 · 2 locos"],
["Wagons", "12 / 12 loaded"],
["Next gate", "Dewele customs"],
].map(([key, value]) => (
<div key={key}>
<p className="text-[11px] font-medium text-edr-muted">{key}</p>
<p className="mt-0.5 text-[13px] font-semibold">{value}</p>
</div>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
const portals = [
{
key: "passenger",
title: "Passenger",
lede: "Search a train, choose your class and seat, and pay with Telebirr or CBE Birr. Your e-ticket comes to your phone.",
points: [
{ icon: Armchair, text: "Economy, standard and VIP coaches" },
{ icon: Smartphone, text: "Pay with Telebirr, CBE Birr or card" },
{ icon: Ticket, text: "QR e-ticket by SMS, shown at the gate" },
],
href: PASSENGER_URL,
cta: "Open Passenger",
Mock: SearchMock,
},
{
key: "freight",
title: "Freight",
lede: "Book wagons and containers, watch every train move along the line, and keep customs documents and invoices in one place.",
points: [
{ icon: Radar, text: "Live GPS position of every wagon" },
{ icon: FileCheck, text: "Interchange and customs documents, signed online" },
{ icon: Receipt, text: "Invoices settled with Telebirr, CBE Birr or eBirr" },
],
href: FREIGHT_URL,
cta: "Open Freight",
Mock: TrackingMock,
},
] as const;
export function Portals() {
return (
<section id="portals" className="scroll-mt-24 bg-white">
<div className="mx-auto max-w-6xl px-6">
{portals.map(({ key, title, lede, points, href, cta, Mock }, index) => {
const flip = index % 2 === 1;
return (
<Reveal
key={key}
className={`grid items-center gap-12 py-20 lg:grid-cols-2 lg:gap-20 lg:py-28 ${
index > 0 ? "border-t border-edr-border" : ""
}`}
>
<div className={flip ? "lg:order-2" : ""}>
<h2 className="text-3xl font-extrabold leading-[1.05] tracking-[-0.02em] sm:text-4xl">
{title}
</h2>
<p className="mt-4 max-w-md text-lg leading-relaxed text-edr-muted">
{lede}
</p>
<ul className="mt-8 space-y-4">
{points.map(({ icon: Icon, text }) => (
<li key={text} className="flex items-center gap-3.5 text-[15px]">
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-emerald-50 text-edr-primary">
<Icon className="size-4" />
</span>
{text}
</li>
))}
</ul>
<a
href={href}
className="mt-10 inline-flex items-center gap-2 rounded-xl bg-edr-primary px-6 py-3.5 font-semibold text-white transition hover:bg-edr-primary-dark focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-edr-primary focus-visible:ring-offset-2"
>
{cta}
<ArrowRight className="size-4" />
</a>
</div>
<div className={`mx-auto w-full max-w-md ${flip ? "lg:order-1" : ""}`}>
<Mock />
</div>
</Reveal>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { CountUp, Reveal } from "./motion";
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;
export function Stats() {
return (
<section className="border-t border-edr-border bg-edr-surface py-16">
<div className="mx-auto max-w-6xl px-6">
<Reveal>
<dl className="grid grid-cols-2 gap-x-6 gap-y-10 lg:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label}>
<dt className="text-3xl font-extrabold tracking-tight text-edr-ink 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-edr-muted">{stat.label}</dd>
</div>
))}
</dl>
</Reveal>
</div>
</section>
);
}

View File

@@ -11,6 +11,10 @@ export const PASSENGER_URL = origin(
// 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(
const FREIGHT_ORIGIN = origin(
process.env.NEXT_PUBLIC_FREIGHT_URL ?? "https://freight.edrsc.com",
)}/portal`;
);
export const FREIGHT_URL = `${FREIGHT_ORIGIN}/portal`;
// Public notices and tariffs, published from the freight app.
export const PUBLICATIONS_URL = `${FREIGHT_ORIGIN}/publications`;

View File

@@ -4,19 +4,31 @@ export default {
theme: {
extend: {
colors: {
// Palette lifted from the freight landing page so the two public
// surfaces read as one railway.
// One palette for the whole railway: the green is sampled from the
// official logo, the dark inks and light surfaces come from the
// freight portal so the two public surfaces read as one brand.
edr: {
ink: "#0c1a2b",
"ink-2": "#10202f",
primary: "#0a8a5f",
"primary-dark": "#087049",
primary: "#008550",
"primary-dark": "#006b41",
light: "#34d399",
amber: "#f2a516",
surface: "#f7fafc",
border: "#e6ecf2",
muted: "#6b7c8e",
},
},
fontFamily: {
sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
sans: [
'"Plus Jakarta Sans"',
"ui-sans-serif",
"system-ui",
"-apple-system",
"Segoe UI",
"Roboto",
"sans-serif",
],
},
},
},