diff --git a/apps/edr-passenger-web/portal/src/app/globals.css b/apps/edr-passenger-web/portal/src/app/globals.css
index 9714d54bc..9296f3030 100644
--- a/apps/edr-passenger-web/portal/src/app/globals.css
+++ b/apps/edr-passenger-web/portal/src/app/globals.css
@@ -26,19 +26,17 @@
}
.btn-ghost {
- @apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors;
+ @apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors hover:bg-[rgba(20,113,76,0.1)] dark:hover:bg-[rgba(20,113,76,0.2)];
}
-
- .btn-ghost:hover {
- background-color: rgba(20, 113, 76, 0.1);
+
+ .booking-page {
+ @apply min-h-screen bg-gray-50 dark:bg-gray-900 py-6 md:py-8;
}
-
- @media (prefers-color-scheme: dark) {
- .btn-ghost:hover {
- background-color: rgba(20, 113, 76, 0.2);
- }
+
+ .booking-container {
+ @apply container mx-auto px-4 max-w-6xl;
}
-
+
.input-field {
@apply w-full px-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-[rgb(20_113_76)] focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-all duration-200 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100;
}
@@ -162,7 +160,18 @@
opacity: 1;
}
}
-
+
+ @keyframes fade-in-up {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
.animate-bounce-in {
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@@ -188,6 +197,10 @@
animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
}
+ .animate-fade-in-up {
+ animation: fade-in-up 0.35s ease-out;
+ }
+
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
diff --git a/apps/edr-passenger-web/portal/src/app/layout.tsx b/apps/edr-passenger-web/portal/src/app/layout.tsx
index 3c18f1c13..e393b5f20 100644
--- a/apps/edr-passenger-web/portal/src/app/layout.tsx
+++ b/apps/edr-passenger-web/portal/src/app/layout.tsx
@@ -5,7 +5,7 @@ import { Providers } from './providers';
import AppHeader from '@/components/AppHeader';
import { Footer } from '@/components/Footer';
import { LoadingIndicator } from '@/components/LoadingIndicator';
-import SupportWidget from '@/features/support/SupportWidget';
+import SupportWidget from '@/features/support/SupportWidgetLazy';
export const metadata: Metadata = {
title: 'EDR Passenger Portal - Book your train journey',
diff --git a/apps/edr-passenger-web/portal/src/app/page.tsx b/apps/edr-passenger-web/portal/src/app/page.tsx
index 93bf02fc2..b9475fc81 100644
--- a/apps/edr-passenger-web/portal/src/app/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/page.tsx
@@ -1,9 +1,37 @@
import { Suspense } from 'react';
+import dynamic from 'next/dynamic';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import SearchPage from '@/app/booking/search/page';
-import PackagesSection from '@/components/PackagesSection';
-export default async function Home() {
+// 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'));
+
+// Skeleton for the search form while stations are prefetched server-side —
+// keeps first paint on a slow connection from being a blank page, and
+// approximates the real form's height to avoid layout shift once it streams in.
+function SearchFormSkeleton() {
+ return (
+
+ );
+}
+
+// Isolates the server-side stations prefetch behind its own Suspense boundary
+// (a real async Server Component, unlike a plain `await` in the page body) so
+// Next.js can stream: the shell flushes immediately with SearchFormSkeleton,
+// then the real search form + hydrated stations data stream in once ready —
+// instead of blocking the whole page's TTFB on that one fetch.
+async function StationsPrefetch({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient();
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
@@ -18,10 +46,18 @@ export default async function Home() {
return (
-
-
-
-
+ {children}
);
}
+
+export default function Home() {
+ return (
+
}>
+
+
+
+
+
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/components/LoadingIndicator.tsx b/apps/edr-passenger-web/portal/src/components/LoadingIndicator.tsx
index 9fa5c466e..09ad7d787 100644
--- a/apps/edr-passenger-web/portal/src/components/LoadingIndicator.tsx
+++ b/apps/edr-passenger-web/portal/src/components/LoadingIndicator.tsx
@@ -1,23 +1,72 @@
'use client';
-import { useEffect, useState } from 'react';
+import { Suspense, useEffect, useRef, useState } from 'react';
+import { usePathname, useSearchParams } from 'next/navigation';
-export function LoadingIndicator() {
+// Minimum time the bar stays visible once shown, so very fast local
+// transitions don't flash imperceptibly (standard practice for top-loading
+// progress bars).
+const MIN_VISIBLE_MS = 300;
+
+function LoadingIndicatorInner() {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
const [isVisible, setIsVisible] = useState(false);
+ const shownAtRef = useRef
(null);
+ // Detects the START of a navigation: a same-origin link click gives
+ // instant feedback on tap, before the RSC fetch even begins. Browser
+ // back/forward (popstate) is covered the same way.
useEffect(() => {
- const handleStart = () => setIsVisible(true);
- const handleEnd = () => setIsVisible(false);
+ const handleClick = (e: MouseEvent) => {
+ if (e.defaultPrevented || e.button !== 0) return;
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
- window.addEventListener('beforeunload', handleStart);
- window.addEventListener('load', handleEnd);
+ const anchor = (e.target as HTMLElement | null)?.closest('a');
+ if (!anchor) return;
+ if (anchor.target && anchor.target !== '_self') return;
+ if (anchor.hasAttribute('download')) return;
+ const href = anchor.getAttribute('href');
+ if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
+
+ let url: URL;
+ try {
+ url = new URL(href, window.location.href);
+ } catch {
+ return;
+ }
+ if (url.origin !== window.location.origin) return;
+ if (url.pathname + url.search === window.location.pathname + window.location.search) return;
+
+ shownAtRef.current = Date.now();
+ setIsVisible(true);
+ };
+
+ const handlePopState = () => {
+ shownAtRef.current = Date.now();
+ setIsVisible(true);
+ };
+
+ document.addEventListener('click', handleClick, true);
+ window.addEventListener('popstate', handlePopState);
return () => {
- window.removeEventListener('beforeunload', handleStart);
- window.removeEventListener('load', handleEnd);
+ document.removeEventListener('click', handleClick, true);
+ window.removeEventListener('popstate', handlePopState);
};
}, []);
+ // Detects the END of a navigation: pathname/search settling to a new value
+ // means the route transition has completed.
+ useEffect(() => {
+ if (!isVisible) return;
+ const elapsed = shownAtRef.current ? Date.now() - shownAtRef.current : MIN_VISIBLE_MS;
+ const remaining = Math.max(0, MIN_VISIBLE_MS - elapsed);
+ const timer = setTimeout(() => setIsVisible(false), remaining);
+ return () => clearTimeout(timer);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pathname, searchParams]);
+
if (!isVisible) return null;
return (
@@ -28,3 +77,11 @@ export function LoadingIndicator() {
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx
index 5ea145bba..1f0bbddfe 100644
--- a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx
+++ b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx
@@ -259,6 +259,21 @@ export default function ModernDatePicker({
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
{calendarFooter}