diff --git a/.gitignore b/.gitignore index 8fa8bca90..f3dd54fba 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # build output **/dist/ .next/ +**/out/ coverage/ *.tsbuildinfo **/*.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md index a20fbaee7..f070313f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ copying a pattern across: | `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 | | `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 | | `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 | +| `edr-landing` | `@edr/landing` | **Next.js** static export | 5163 | Those are the **fallbacks compiled into the code**, not what you will be running. Every port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite @@ -43,8 +44,16 @@ the whole team and the low ports are contested — see the workspace root `CLAUD Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace packages (see `pnpm-workspace.yaml`). -`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace -package and is not built, linted, or type-checked. Leave it alone unless asked. +`apps/edr-landing/` is the public front door at `edrsc.com`: one static page that routes +visitors to the passenger or freight app. It is a Next.js **static export** +(`output: 'export'`), so its build artifact is `out/`, not `.next/`. It depends on no +workspace package — not even `@edr/ui-common`, whose Tailwind 4 tokens do not fit its +Tailwind 3 setup. + +Its two destinations come from `NEXT_PUBLIC_PASSENGER_URL` and `NEXT_PUBLIC_FREIGHT_URL` +(each an origin; the entry path is appended in `src/lib/apps.ts`). A static export inlines +those at **build** time, so they must be passed as Docker build args — setting them in the +runtime environment does nothing. `apps/edr-gps-tracker/` is a separate service with its own `.env.example`. diff --git a/apps/edr-landing/next-env.d.ts b/apps/edr-landing/next-env.d.ts index c4b7818fb..40c3d6809 100644 --- a/apps/edr-landing/next-env.d.ts +++ b/apps/edr-landing/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -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. diff --git a/apps/edr-landing/next.config.js b/apps/edr-landing/next.config.js new file mode 100644 index 000000000..656afb70d --- /dev/null +++ b/apps/edr-landing/next.config.js @@ -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; diff --git a/apps/edr-landing/package.json b/apps/edr-landing/package.json new file mode 100644 index 000000000..b8baf027b --- /dev/null +++ b/apps/edr-landing/package.json @@ -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" + } +} diff --git a/apps/edr-landing/postcss.config.js b/apps/edr-landing/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/apps/edr-landing/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/edr-landing/public/edr-logo.png b/apps/edr-landing/public/edr-logo.png new file mode 100644 index 000000000..3966c9e80 Binary files /dev/null and b/apps/edr-landing/public/edr-logo.png differ diff --git a/apps/edr-landing/src/app/globals.css b/apps/edr-landing/src/app/globals.css new file mode 100644 index 000000000..6aa66fed6 --- /dev/null +++ b/apps/edr-landing/src/app/globals.css @@ -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; + } +} diff --git a/apps/edr-landing/src/app/layout.tsx b/apps/edr-landing/src/app/layout.tsx new file mode 100644 index 000000000..60510d9f7 --- /dev/null +++ b/apps/edr-landing/src/app/layout.tsx @@ -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 ( + + + {/* 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. */} + + + + {/* Gates the reveal animation's hidden state (see globals.css) so the + page is fully readable with JavaScript off. */} +