mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
feat(seo): Add metadata and SEO improvements to passenger portal
This commit is contained in:
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@@ -2,7 +2,6 @@ name: Deploy Stacks
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- staging
|
||||
workflow_dispatch:
|
||||
|
||||
21
apps/edr-passenger-web/portal/src/app/about/layout.tsx
Normal file
21
apps/edr-passenger-web/portal/src/app/about/layout.tsx
Normal 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;
|
||||
}
|
||||
21
apps/edr-passenger-web/portal/src/app/contact/layout.tsx
Normal file
21
apps/edr-passenger-web/portal/src/app/contact/layout.tsx
Normal 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;
|
||||
}
|
||||
28
apps/edr-passenger-web/portal/src/app/guide/layout.tsx
Normal file
28
apps/edr-passenger-web/portal/src/app/guide/layout.tsx
Normal 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;
|
||||
}
|
||||
23
apps/edr-passenger-web/portal/src/app/help/layout.tsx
Normal file
23
apps/edr-passenger-web/portal/src/app/help/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
23
apps/edr-passenger-web/portal/src/app/packages/layout.tsx
Normal file
23
apps/edr-passenger-web/portal/src/app/packages/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
37
apps/edr-passenger-web/portal/src/app/robots.ts
Normal file
37
apps/edr-passenger-web/portal/src/app/robots.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
23
apps/edr-passenger-web/portal/src/app/services/layout.tsx
Normal file
23
apps/edr-passenger-web/portal/src/app/services/layout.tsx
Normal 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;
|
||||
}
|
||||
59
apps/edr-passenger-web/portal/src/app/sitemap.ts
Normal file
59
apps/edr-passenger-web/portal/src/app/sitemap.ts
Normal 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;
|
||||
}
|
||||
12
apps/edr-passenger-web/portal/src/components/JsonLd.tsx
Normal file
12
apps/edr-passenger-web/portal/src/components/JsonLd.tsx
Normal 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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user