feat(seo): Add metadata and SEO improvements to passenger portal

This commit is contained in:
yonastewabe
2026-07-14 09:06:43 +03:00
parent f2e2eab6b4
commit 173aad7b5c
14 changed files with 459 additions and 6 deletions

View File

@@ -2,7 +2,6 @@ name: Deploy Stacks
on:
push:
branches:
- main
- dev
- staging
workflow_dispatch:

View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About EDR Connecting East Africa by Rail',
description:
'Learn about the Ethio-Djibouti Railway (EDR): our mission to connect Ethiopia and Djibouti with comfortable, affordable, and sustainable train travel.',
alternates: {
canonical: '/about',
},
openGraph: {
title: 'About EDR Connecting East Africa by Rail',
description:
'Our mission is to provide reliable, affordable, and comfortable train travel connecting Ethiopia and Djibouti.',
url: '/about',
images: [{ url: '/edr-banner.jpg', width: 1200, height: 630, alt: 'EDR Railway About Us' }],
},
};
export default function AboutLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Contact EDR Get in Touch',
description:
'Contact the Ethio-Djibouti Railway (EDR) support team. We are available to assist with your train journey.',
alternates: {
canonical: '/contact',
},
openGraph: {
title: 'Contact EDR Get in Touch',
description:
'Reach the EDR support team by phone, email, or in person. We are available to assist with your train journey.',
url: '/contact',
images: [{ url: '/edr-banner.jpg', width: 1200, height: 630, alt: 'EDR Railway Contact Us' }],
},
};
export default function ContactLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,28 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'How to Book a Train Ticket EDR Step-by-Step Guide',
description:
'Step-by-step guide to booking an EDR train ticket online: search trains, select your seat class, enter passenger details, and complete payment.',
alternates: {
canonical: '/guide',
},
openGraph: {
title: 'How to Book a Train Ticket EDR Step-by-Step Guide',
description:
'Follow our simple guide to book your EDR train journey in minutes.',
url: '/guide',
images: [
{
url: '/edr-banner.jpg',
width: 1200,
height: 630,
alt: 'EDR Railway How to Book a Train Ticket',
},
],
},
};
export default function GuideLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Help & FAQs EDR Train Booking',
description:
'Find answers to common questions about booking EDR train tickets: how to book, passenger pricing, identity verification, seat classes, payment methods, and more.',
alternates: {
canonical: '/help',
},
openGraph: {
title: 'Help & FAQs EDR Train Booking',
description:
'Answers to your questions about booking EDR train tickets, payment methods, identity verification, and seat selection.',
url: '/help',
images: [
{ url: '/edr-banner.jpg', width: 1200, height: 630, alt: 'EDR Railway Help & FAQs' },
],
},
};
export default function HelpLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -7,10 +7,61 @@ import MobileTopBar from '@/components/MobileTopBar';
import BottomTabBar from '@/components/BottomTabBar';
import { LoadingIndicator } from '@/components/LoadingIndicator';
import SupportWidget from '@/features/support/SupportWidgetLazy';
import { JsonLd } from '@/components/JsonLd';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://passenger.edrsc.com';
export const metadata: Metadata = {
title: 'EDR Passenger Portal - Book your train journey',
description: 'Book train tickets on the Ethio-Djibouti Railway',
metadataBase: new URL(siteUrl),
title: {
default: 'EDR Passenger Portal Book Train Tickets Online',
template: '%s | EDR Passenger Portal',
},
description:
'Book train tickets on the Ethio-Djibouti Railway. Travel between Ethiopia and Djibouti with easy online booking, seat selection, and multiple payment options.',
authors: [{ name: 'EDR Ethio-Djibouti Railway' }],
creator: 'EDR Ethio-Djibouti Railway',
publisher: 'EDR Ethio-Djibouti Railway',
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-snippet': -1,
'max-image-preview': 'large',
'max-video-preview': -1,
},
},
openGraph: {
type: 'website',
locale: 'en_US',
url: siteUrl,
siteName: 'EDR Passenger Portal',
title: 'EDR Passenger Portal Book Train Tickets Online',
description:
'Book train tickets on the Ethio-Djibouti Railway. Comfortable seats, flexible payment options, and easy online booking.',
images: [
{
url: '/edr-banner.jpg',
width: 1200,
height: 630,
alt: 'EDR Ethio-Djibouti Railway Book your train journey',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'EDR Passenger Portal Book Train Tickets Online',
description:
'Book train tickets on the Ethio-Djibouti Railway. Comfortable seats, flexible payment options, and easy online booking.',
images: ['/edr-banner.jpg'],
},
icons: {
icon: '/edr-logo.png',
shortcut: '/edr-logo.png',
apple: '/edr-logo.png',
},
};
// viewportFit: 'cover' lets fixed bottom bars (e.g. the payment page's Pay
@@ -29,8 +80,48 @@ export default function RootLayout({
}) {
// Nonce set per-request by middleware; required for this inline script under the CSP.
const nonce = headers().get('x-nonce') ?? undefined;
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',
contactType: 'customer service',
availableLanguage: ['English', 'Amharic'],
},
address: {
'@type': 'PostalAddress',
addressLocality: 'Addis Ababa',
addressCountry: 'ET',
},
sameAs: [],
};
const websiteSchema = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'EDR Passenger Portal',
url: siteUrl,
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${siteUrl}/booking/search?q={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
};
return (
<html lang="en" suppressHydrationWarning>
<html lang="en" dir="ltr" suppressHydrationWarning>
<head>
<JsonLd data={organizationSchema} />
<JsonLd data={websiteSchema} />
</head>
<body className="font-sans antialiased">
<script
nonce={nonce}

View File

@@ -0,0 +1,80 @@
import type { Metadata } from 'next';
interface PackageSummary {
id: string;
name: string;
description?: string | null;
outboundSchedule?: {
originStation?: { name?: string };
destinationStation?: { name?: string };
};
}
/**
* Fetch a minimal package summary server-side so we can generate per-package
* metadata without requiring the page itself to be a Server Component.
* Errors are swallowed so the page still renders even if the API is unreachable
* at build/request time.
*/
async function fetchPackageSummary(id: string): Promise<PackageSummary | null> {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
const res = await fetch(`${apiUrl}/packages/${id}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export async function generateMetadata({
params,
}: {
params: { id: string };
}): Promise<Metadata> {
const pkg = await fetchPackageSummary(params.id);
if (!pkg) {
return {
title: 'Train Package EDR',
description:
'View details and book this Ethio-Djibouti Railway travel package with seat selection and flexible payment options.',
robots: { index: true, follow: true },
};
}
const origin = pkg.outboundSchedule?.originStation?.name?.trim();
const destination = pkg.outboundSchedule?.destinationStation?.name?.trim();
const routeLabel = origin && destination ? ` ${origin} to ${destination}` : '';
const title = `${pkg.name}${routeLabel} | EDR`;
const description =
pkg.description?.trim() ||
`Book the "${pkg.name}" train package on the Ethio-Djibouti Railway${routeLabel ? ` (${origin}${destination})` : ''}. Choose your seat class and complete payment online.`;
return {
title,
description,
alternates: {
canonical: `/packages/${params.id}`,
},
openGraph: {
title,
description,
url: `/packages/${params.id}`,
images: [
{
url: '/edr-banner.jpg',
width: 1200,
height: 630,
alt: `EDR Railway ${pkg.name}`,
},
],
},
};
}
export default function PackageDetailLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Train Journey Packages EDR',
description:
'Browse curated Ethio-Djibouti Railway travel packages. Choose from one-way and round-trip options with a range of seat classes at competitive fares.',
alternates: {
canonical: '/packages',
},
openGraph: {
title: 'Train Journey Packages EDR',
description:
'Curated one-way and round-trip train packages on the Ethio-Djibouti Railway with multiple seat classes and flexible payment options.',
url: '/packages',
images: [
{ url: '/edr-banner.jpg', width: 1200, height: 630, alt: 'EDR Railway Travel Packages' },
],
},
};
export default function PackagesLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -1,8 +1,25 @@
import { Suspense } from 'react';
import type { Metadata } from 'next';
import dynamic from 'next/dynamic';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import SearchPage from '@/app/booking/search/page';
export const metadata: Metadata = {
title: 'EDR Passenger Portal Book Train Tickets Online',
description:
'Book train tickets on the Ethio-Djibouti Railway. Search trains between Ethiopia and Djibouti with easy online booking, seat selection, and flexible payment options.',
alternates: {
canonical: '/',
},
openGraph: {
title: 'EDR Passenger Portal Book Train Tickets Online',
description:
'Search and book train tickets across the Ethio-Djibouti Railway. Multiple seat classes available.',
url: '/',
type: 'website',
},
};
// Below-the-fold on the landing page — code-split out of the initial bundle
// needed to render/hydrate the (above-the-fold) search form.
const PackagesSection = dynamic(() => import('@/components/PackagesSection'));

View File

@@ -0,0 +1,37 @@
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://passenger.edrsc.com';
return {
rules: [
{
userAgent: '*',
allow: [
'/',
'/about',
'/contact',
'/services',
'/packages',
'/packages/',
'/help',
'/guide',
],
disallow: [
'/booking/',
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/set-password',
'/verify-account',
'/fayda-setup',
'/profile',
'/go/',
'/api/',
],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}

View File

@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'EDR Train Services Booking, Seats, Payments & More',
description:
'Explore all services offered by the Ethio-Djibouti Railway: easy online booking, seat selection, flexible payment options, customer support, and live train tracking.',
alternates: {
canonical: '/services',
},
openGraph: {
title: 'EDR Train Services Booking, Seats, Payments & More',
description:
'Book seats, pay with your preferred method, track your train live, and get support on the Ethio-Djibouti Railway.',
url: '/services',
images: [
{ url: '/edr-banner.jpg', width: 1200, height: 630, alt: 'EDR Railway Our Services' },
],
},
};
export default function ServicesLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,59 @@
import type { MetadataRoute } from 'next';
/**
* Static sitemap covering all publicly indexable pages.
* Dynamic package pages are omitted here because they are fetched at runtime;
* if you want to include them, replace this with an async function that calls
* the packages API and maps over the results.
*/
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://passenger.edrsc.com';
const now = new Date();
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: now,
changeFrequency: 'daily',
priority: 1.0,
},
{
url: `${baseUrl}/packages`,
lastModified: now,
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${baseUrl}/services`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/about`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.7,
},
{
url: `${baseUrl}/help`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/guide`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.6,
},
{
url: `${baseUrl}/contact`,
lastModified: now,
changeFrequency: 'yearly',
priority: 0.5,
},
];
return staticPages;
}

View File

@@ -0,0 +1,12 @@
/**
* Injects JSON-LD structured data into the page <head>.
* Usage: <JsonLd data={{ "@context": "https://schema.org", ... }} />
*/
export function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}

View File

@@ -16,6 +16,7 @@ const PRESERVED_ROUTES = [
'/help',
'/guide',
'/services',
'/packages',
'/go/',
'/packages/',
];
@@ -99,10 +100,10 @@ export function middleware(request: NextRequest) {
pathname.includes('.') ||
pathname === '/'
) {
// Next.js internals, static files, API routes, and home: no redirect, just render.
// Next.js internals, static files, API routes, and home.
response = render();
} else if (PRESERVED_ROUTES.some((r) => pathname.startsWith(r))) {
// On hard refresh of a preserved route, let it through.
// Public and auth routes: let them through.
response = render();
} else {
response = render();
@@ -112,6 +113,24 @@ export function middleware(request: NextRequest) {
// Anti-clickjacking. `frame-ancestors 'none'` (in the CSP above) is the modern control;
// X-Frame-Options: DENY is the legacy equivalent for older browsers and scanners.
response.headers.set('X-Frame-Options', 'DENY');
// Tell crawlers not to index private/transactional routes.
const NOINDEX_PREFIXES = [
'/booking/',
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/set-password',
'/verify-account',
'/fayda-setup',
'/profile',
'/go/',
];
if (NOINDEX_PREFIXES.some((p) => pathname.startsWith(p))) {
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
}
return response;
}