+ {/* Background image with zoom — desktop only; mobile drops the hero image entirely
+ so the booking widget can sit at the top and use the available space. */}
+
- {/* Gradient overlay */}
-
-
+ {/* Gradient overlay — desktop only (exists to keep the hero text readable over the image) */}
+
+
- {/* Hero headline — top area */}
-
+ {/* Hero headline — desktop only; removed on mobile along with the banner image */}
+
Where are you
headed today?
@@ -867,14 +875,28 @@ export default function SearchPage() {
- {/* ── Widget — absolutely positioned at bottom with margin ── */}
+ {/* ── Widget — top-aligned, normal flow on mobile; absolutely positioned at
+ bottom on desktop over the hero image. z-[35] sits above the floating
+ support chat launcher (z-30) but below the sidebar/tab bar (z-40), so it
+ never covers either. Modals opened from inside the widget (date picker
+ etc.) are portaled to — see ModernDatePicker — so they aren't
+ capped by this wrapper's own stacking context. ── */}
+ {/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */}
+
+
+ Where are you headed today?
+
+
+ Book your train journey across East Africa
+
+
{errors.departureDate && (
@@ -1219,7 +1259,7 @@ export default function SearchPage() {
Search
@@ -1324,6 +1364,7 @@ export default function SearchPage() {
}}
minDate={new Date()}
placeholder="Select date"
+ error={!!errors.departureDate}
/>
{errors.departureDate && (
@@ -1357,6 +1398,7 @@ export default function SearchPage() {
: new Date()
}
placeholder="Select date"
+ error={!!errors.returnDate}
/>
{errors.returnDate && (
@@ -1482,7 +1524,7 @@ export default function SearchPage() {
Search
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
index ccb953267..1463b238a 100644
--- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx
@@ -11,6 +11,7 @@ import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "l
import Image from "next/image";
import CustomModal from "@/components/CustomModal";
+import { Skeleton } from "@/components/Skeleton";
import { isChild } from "@/utils/fare-utils";
const BED_POSITION_SUFFIX: Record = { lower: 'L', middle: 'M', upper: 'U' };
@@ -2187,11 +2188,16 @@ export default function SeatsPage() {
{isLoading ? (
-
-
-
- Loading seat map...
-
+
+
+
+
+
+
+ {Array.from({ length: 24 }).map((_, i) => (
+
+ ))}
+
) : error ? (
diff --git a/apps/edr-passenger-web/portal/src/app/contact/page.tsx b/apps/edr-passenger-web/portal/src/app/contact/page.tsx
index f2c0f4296..669a99d0b 100644
--- a/apps/edr-passenger-web/portal/src/app/contact/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/contact/page.tsx
@@ -3,16 +3,18 @@
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react';
+import { Footer } from '@/components/Footer';
const styles = `
.contact-hero {
padding: 60px 20px;
- background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
+ background: #ffffff;
text-align: center;
color: #111827;
}
-
+
.dark .contact-hero {
+ background: #111827;
color: #f3f4f6;
}
@@ -370,6 +372,7 @@ export default function Contact() {
+
>
);
}
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/go/page.tsx b/apps/edr-passenger-web/portal/src/app/go/page.tsx
index d9f6f583f..feed05990 100644
--- a/apps/edr-passenger-web/portal/src/app/go/page.tsx
+++ b/apps/edr-passenger-web/portal/src/app/go/page.tsx
@@ -13,7 +13,7 @@ import { useSearchParams } from "next/navigation";
* hop is browser-dependent and can be stripped) must NOT be used here.
*
* It fires immediately (no delay) and paints a bare white full-screen cover
- * above the sticky header (z-[60]) — no portal chrome, no text on the happy
+ * above the sidebar/tab bar (z-40) — no portal chrome, no text on the happy
* path. A short message shows only when the link is missing/untrusted.
*
* `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the
diff --git a/apps/edr-passenger-web/portal/src/app/layout.tsx b/apps/edr-passenger-web/portal/src/app/layout.tsx
index 3c18f1c13..1128124e3 100644
--- a/apps/edr-passenger-web/portal/src/app/layout.tsx
+++ b/apps/edr-passenger-web/portal/src/app/layout.tsx
@@ -2,10 +2,11 @@ import type { Metadata } from 'next';
import { headers } from 'next/headers';
import './globals.css';
import { Providers } from './providers';
-import AppHeader from '@/components/AppHeader';
-import { Footer } from '@/components/Footer';
+import AppSidebar from '@/components/AppSidebar';
+import MobileTopBar from '@/components/MobileTopBar';
+import BottomTabBar from '@/components/BottomTabBar';
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',
@@ -21,7 +22,7 @@ export default function RootLayout({
const nonce = headers().get('x-nonce') ?? undefined;
return (
-
+
-
-
- {children}
-
-
+
+
+
+
+ {children}
+
+
+
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/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
deleted file mode 100644
index b1fc2ddf5..000000000
--- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-"use client";
-
-import { Menu, X, Moon, Sun, HelpCircle, KeyRound, LogOut, ChevronDown } from "lucide-react";
-import Link from "next/link";
-import Image from "next/image";
-import { useEffect, useState } from "react";
-import { useAuthStore } from "@/lib/auth-store";
-import ChangePasswordModal from "@/components/ChangePasswordModal";
-
-export default function AppHeader() {
- const [isOpen, setIsOpen] = useState(false);
- const [isDark, setIsDark] = useState(false);
- const [showUserMenu, setShowUserMenu] = useState(false);
- const [showChangePassword, setShowChangePassword] = useState(false);
- const { user, isAuthenticated, initialize, logout } = useAuthStore();
-
- useEffect(() => {
- const isDarkMode = document.documentElement.classList.contains("dark");
- setIsDark(isDarkMode);
- initialize();
- }, [initialize]);
-
- const toggleTheme = () => {
- const html = document.documentElement;
- const isDarkMode = html.classList.contains("dark");
- if (isDarkMode) {
- html.classList.remove("dark");
- setIsDark(false);
- localStorage.setItem("theme", "light");
- } else {
- html.classList.add("dark");
- setIsDark(true);
- localStorage.setItem("theme", "dark");
- }
- };
-
-
-
- return (
-
-
-
-
- {/* Logo */}
-
-
-
-
- {/* Desktop Menu */}
-
-
- My Booking
-
-
-
- {/* Right Actions */}
-
- {/* Help Link */}
-
-
-
-
- {/* Theme Toggler */}
-
- {isDark ? (
-
- ) : (
-
- )}
-
-
- {/* Auth */}
- {isAuthenticated && user ? (
-
-
setShowUserMenu(!showUserMenu)}
- className="flex items-center gap-2 p-1.5 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
- >
-
- {user.fullName?.toUpperCase().charAt(0) || 'U'}
-
-
-
-
- {showUserMenu && (
-
-
-
{user.fullName}
-
{user.email}
-
-
- {
- setShowUserMenu(false);
- setShowChangePassword(true);
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
- >
-
- Change Password
-
- {
- setShowUserMenu(false);
- logout();
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
- >
-
- Sign out
-
-
-
- )}
-
- ) : (
-
-
- Sign in
-
-
- Register
-
-
- )}
-
- {/* Mobile Menu Button */}
-
setIsOpen(!isOpen)}
- className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
- >
- {isOpen ? (
-
- ) : (
-
- )}
-
-
-
-
- {/* Mobile Menu */}
- {isOpen && (
-
- setIsOpen(false)}
- >
- My Booking
-
- setIsOpen(false)}
- >
- Help
-
- {!isAuthenticated && (
- <>
- setIsOpen(false)}
- >
- Sign in
-
- setIsOpen(false)}
- >
- Register
-
- >
- )}
-
- )}
-
-
-
- setShowChangePassword(false)}
- />
-
- );
-}
diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
new file mode 100644
index 000000000..6d47b8b5a
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx
@@ -0,0 +1,212 @@
+'use client';
+
+import {
+ Home,
+ Phone,
+ Ticket,
+ HelpCircle,
+ Moon,
+ Sun,
+ KeyRound,
+ LogOut,
+ ChevronDown,
+ Check,
+} from 'lucide-react';
+import Link from 'next/link';
+import Image from 'next/image';
+import { usePathname } from 'next/navigation';
+import { useEffect, useState } from 'react';
+import { useAuthStore } from '@/lib/auth-store';
+import ChangePasswordModal from '@/components/ChangePasswordModal';
+import { BOOKING_STEPS } from '@/components/ProgressIndicator';
+
+// Mirrors booking/layout.tsx's stepMap — the linear booking flow routes that
+// get a vertical step list instead of the standard nav highlighting.
+const BOOKING_STEP_MAP: Record
= {
+ '/booking/search': 'search',
+ '/booking/results': 'results',
+ '/booking/auth-check': 'passengers',
+ '/booking/passengers': 'passengers',
+ '/booking/seats': 'seats',
+ '/booking/review': 'review',
+ '/booking/payment': 'payment',
+ '/booking/confirmation': 'confirmation',
+};
+
+const NAV_LINKS = [
+ { href: '/', label: 'Home', icon: Home },
+ { href: '/booking/lookup', label: 'My Bookings', icon: Ticket },
+ { href: '/contact', label: 'Contact', icon: Phone },
+ { href: '/help', label: 'Help', icon: HelpCircle },
+];
+
+export default function AppSidebar() {
+ const pathname = usePathname();
+ const [isDark, setIsDark] = useState(false);
+ const [showUserMenu, setShowUserMenu] = useState(false);
+ const [showChangePassword, setShowChangePassword] = useState(false);
+ const { user, isAuthenticated, initialize, logout } = useAuthStore();
+
+ useEffect(() => {
+ setIsDark(document.documentElement.classList.contains('dark'));
+ initialize();
+ }, [initialize]);
+
+ const toggleTheme = () => {
+ const html = document.documentElement;
+ const nextDark = !html.classList.contains('dark');
+ html.classList.toggle('dark', nextDark);
+ localStorage.setItem('theme', nextDark ? 'dark' : 'light');
+ setIsDark(nextDark);
+ };
+
+ const currentStepId = BOOKING_STEP_MAP[pathname ?? ''];
+ const currentStepIndex = currentStepId ? BOOKING_STEPS.findIndex((s) => s.id === currentStepId) : -1;
+
+ return (
+
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx
new file mode 100644
index 000000000..47bc58792
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import { Home, Phone, Ticket, User } from 'lucide-react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { useAuthStore } from '@/lib/auth-store';
+
+// The linear, one-screen-at-a-time booking flow — each of these pages already
+// has its own sticky mobile CTA bar (and the mobile step strip at the top),
+// so the tab bar hides rather than stacking a second bottom bar underneath.
+const LINEAR_FLOW_PREFIXES = [
+ '/booking/search',
+ '/booking/results',
+ '/booking/seats',
+ '/booking/passengers',
+ '/booking/auth-check',
+ '/booking/review',
+ '/booking/payment',
+ '/booking/confirmation',
+];
+
+export default function BottomTabBar() {
+ const pathname = usePathname() ?? '';
+ const { isAuthenticated } = useAuthStore();
+
+ const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p));
+ if (isInLinearFlow) return null;
+
+ const tabs = [
+ { href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
+ { href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
+ { href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
+ {
+ href: isAuthenticated ? '/profile' : '/login',
+ label: isAuthenticated ? 'Account' : 'Sign in',
+ icon: User,
+ match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'),
+ },
+ ];
+
+ return (
+ <>
+ {/* In-flow spacer so page content/Footer can't end up hidden behind the
+ fixed bar below — self-contained here so it only exists when the
+ bar itself is actually rendered. */}
+
+
+
+ {tabs.map(({ href, label, icon: Icon, match }) => {
+ const isActive = match(pathname);
+ return (
+
+
+
+ {label}
+
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/apps/edr-passenger-web/portal/src/components/LandingNav.tsx b/apps/edr-passenger-web/portal/src/components/LandingNav.tsx
deleted file mode 100644
index 76d5fabb9..000000000
--- a/apps/edr-passenger-web/portal/src/components/LandingNav.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-'use client';
-
-import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
-import Link from 'next/link';
-import { useState, useEffect } from 'react';
-import { LanguageSwitcher } from './LanguageSwitcher';
-import { getTranslation, Language } from '@/lib/i18n';
-import { useLanguage } from '@/lib/i18n';
-
-export function LandingNav() {
- const [isOpen, setIsOpen] = useState(false);
- const [lang, setLang] = useState('en');
- const [isDark, setIsDark] = useState(false);
- const [mounted, setMounted] = useState(false);
- const { getLang } = useLanguage();
- const t = (key: string) => getTranslation(lang, key);
-
- useEffect(() => {
- setMounted(true);
- setLang(getLang());
- const handleLanguageChange = (e: any) => setLang(e.detail);
- window.addEventListener('languageChange', handleLanguageChange);
- return () => window.removeEventListener('languageChange', handleLanguageChange);
- }, [getLang]);
-
- useEffect(() => {
- const isDarkMode = document.documentElement.classList.contains('dark');
- setIsDark(isDarkMode);
- }, []);
-
- const toggleTheme = () => {
- const html = document.documentElement;
- const isDarkMode = html.classList.contains('dark');
- if (isDarkMode) {
- html.classList.remove('dark');
- setIsDark(false);
- localStorage.setItem('theme', 'light');
- } else {
- html.classList.add('dark');
- setIsDark(true);
- localStorage.setItem('theme', 'dark');
- }
- };
-
- const menuItems = [
- { label: t('nav.home'), href: '/' },
- { label: t('nav.services'), href: '/services' },
- { label: t('nav.about'), href: '/about' },
- { label: t('nav.contact'), href: '/contact' },
- ];
-
- if (!mounted) return null;
-
- return (
-
-
-
-
- {/* Logo */}
-
-
-
-
-
-
- Ethio-Djibouti Railway
-
-
- Book your train journey with us
-
-
-
-
- {/* Desktop Menu */}
-
- {menuItems.map((item) => (
-
- {item.label}
-
- ))}
-
-
- {/* Right Actions */}
-
-
-
- {/* Help Link */}
-
-
-
-
- {/* Theme Toggler */}
-
- {isDark ? (
-
- ) : (
-
- )}
-
-
-
- {t('nav.bookNow')}
-
-
- {/* Mobile Menu Button */}
- setIsOpen(!isOpen)}
- className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
- >
- {isOpen ? : }
-
-
-
-
- {/* Mobile Menu */}
- {isOpen && (
-
- {menuItems.map((item) => (
- setIsOpen(false)}
- >
- {item.label}
-
- ))}
- setIsOpen(false)}
- >
- {t('nav.help')}
-
- setIsOpen(false)}
- >
- {t('nav.bookNow')}
-
-
- )}
-
-
-
- );
-}
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() {
);
}
+
+export function LoadingIndicator() {
+ return (
+