Files
edr-platform/apps/edr-passenger-web/portal/src/components/LoadingIndicator.tsx
2026-07-09 16:18:52 +03:00

88 lines
3.0 KiB
TypeScript

'use client';
import { Suspense, useEffect, useRef, useState } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
// 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<number | null>(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 handleClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
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 () => {
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 (
<div className="fixed top-0 left-0 right-0 z-[9999]">
<div className="h-1 bg-gradient-to-r from-primary via-primary/70 to-primary animate-pulse">
<div className="h-full bg-gradient-to-r from-primary to-primary/50 animate-[shimmer_2s_infinite]" />
</div>
</div>
);
}
export function LoadingIndicator() {
return (
<Suspense fallback={null}>
<LoadingIndicatorInner />
</Suspense>
);
}