mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 05:23:38 +00:00
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:
BIN
apps/edr-landing/public/brand/edr-logo-green.png
Normal file
BIN
apps/edr-landing/public/brand/edr-logo-green.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/edr-landing/public/brand/edr-logo-white.png
Normal file
BIN
apps/edr-landing/public/brand/edr-logo-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/edr-landing/public/brand/edr-mark-green.png
Normal file
BIN
apps/edr-landing/public/brand/edr-mark-green.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/edr-landing/public/brand/edr-mark-white.png
Normal file
BIN
apps/edr-landing/public/brand/edr-mark-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/edr-landing/public/photos/freight-train.jpg
Normal file
BIN
apps/edr-landing/public/photos/freight-train.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
apps/edr-landing/public/photos/station.jpg
Normal file
BIN
apps/edr-landing/public/photos/station.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
42
apps/edr-landing/src/components/brand.tsx
Normal file
42
apps/edr-landing/src/components/brand.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
apps/edr-landing/src/components/corridor.tsx
Normal file
77
apps/edr-landing/src/components/corridor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/edr-landing/src/components/footer.tsx
Normal file
65
apps/edr-landing/src/components/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/edr-landing/src/components/header.tsx
Normal file
57
apps/edr-landing/src/components/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
apps/edr-landing/src/components/hero.tsx
Normal file
158
apps/edr-landing/src/components/hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
apps/edr-landing/src/components/portals.tsx
Normal file
253
apps/edr-landing/src/components/portals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/edr-landing/src/components/stats.tsx
Normal file
37
apps/edr-landing/src/components/stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user