From 982a9b71494f35eb71fcb6c166a1f49650e436b1 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 26 Feb 2026 14:31:23 -0500 Subject: [PATCH 01/21] login - logout - login bug fix --- src/app/store/saga/auth-saga.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/store/saga/auth-saga.ts b/src/app/store/saga/auth-saga.ts index 4406f1d2..db219081 100644 --- a/src/app/store/saga/auth-saga.ts +++ b/src/app/store/saga/auth-saga.ts @@ -22,6 +22,7 @@ import { changePasswordSuccess, loginFail, loginSuccess, + logoutFail, logoutSuccess, signUpFail, signUpSuccess, @@ -91,17 +92,22 @@ function* logoutSaga({ propagate: boolean; }>): Generator { try { - navigateTo(redirectScreen); yield app.auth().signOut(); // Clear the HTTP-only md_session cookie on logout so that // server-side requests immediately see the user as logged out. yield call(clearUserCookieSession); yield put(logoutSuccess()); if (propagate) { - broadcastMessage(LOGOUT_CHANNEL); + try { + broadcastMessage(LOGOUT_CHANNEL); + } catch { + // Broadcast channels may not be initialised if no + // legacy [...slug] page has been rendered yet. + } } + navigateTo(redirectScreen); } catch (error) { - yield put(loginFail(getAppError(error) as ProfileError)); + yield put(logoutFail()); } } From f96e7945f7e46e63ee691955486d3f40cfaea4f3 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 26 Feb 2026 14:32:30 -0500 Subject: [PATCH 02/21] cleanup legacy subscriptions on nav away --- src/app/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 58575a08..bc79339f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -41,7 +41,7 @@ function App({ locale }: AppProps): React.ReactElement { const initialPath = buildPathFromNextRouter(pathname, searchParams, locale); useEffect(() => { - app.auth().onAuthStateChanged((user) => { + const unsubscribe = app.auth().onAuthStateChanged((user) => { if (user != null) { setIsAppReady(true); } else { @@ -50,6 +50,9 @@ function App({ locale }: AppProps): React.ReactElement { } }); dispatch(anonymousLogin()); + return () => { + unsubscribe(); + }; }, [dispatch]); return ( From 46891557da52e5af7bbd37f033f0d6eb0eeb65b8 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 26 Feb 2026 14:33:21 -0500 Subject: [PATCH 03/21] remove fetching data from feed detail layout --- .../feeds/[feedDataType]/[feedId]/authed/layout.tsx | 13 ------------- .../feeds/[feedDataType]/[feedId]/static/layout.tsx | 12 ------------ 2 files changed, 25 deletions(-) diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/layout.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/layout.tsx index b6381eb8..bd393ecd 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/layout.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/layout.tsx @@ -1,7 +1,6 @@ import { type ReactNode } from 'react'; import { notFound } from 'next/navigation'; import { headers } from 'next/headers'; -import { fetchCompleteFeedData } from '../lib/feed-data'; import { AUTHED_PROXY_HEADER } from '../../../../../utils/proxy-helpers'; /** @@ -12,7 +11,6 @@ export const dynamic = 'force-dynamic'; interface Props { children: ReactNode; - params: Promise<{ feedDataType: string; feedId: string }>; } /** @@ -28,7 +26,6 @@ interface Props { */ export default async function AuthedFeedLayout({ children, - params, }: Props): Promise { // Block direct access - only allow requests that came through the proxy const headersList = await headers(); @@ -36,15 +33,5 @@ export default async function AuthedFeedLayout({ notFound(); } - const { feedId, feedDataType } = await params; - - // Fetch complete feed data (cached per-user) - // This will be reused by child pages without additional API calls - const feedData = await fetchCompleteFeedData(feedDataType, feedId); - - if (feedData == null) { - notFound(); - } - return <>{children}; } diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx index 8850599d..f5b80848 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx @@ -31,18 +31,6 @@ export default async function StaticFeedLayout({ children, params, }: Props): Promise { - const { feedId, feedDataType } = await params; - - let feedData; - try { - feedData = await fetchGuestFeedData(feedDataType, feedId); - } catch { - notFound(); - } - - if (feedData == null) { - notFound(); - } return <>{children}; } From 21fe32b3f8a2607ccac13047b1de9bc114c5eecd Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 26 Feb 2026 14:35:01 -0500 Subject: [PATCH 04/21] direct feeds type navigation redirects --- src/app/[locale]/feeds/gbfs/page.tsx | 8 ++++++++ src/app/[locale]/feeds/gtfs/page.tsx | 8 ++++++++ src/app/[locale]/feeds/gtfs_rt/page.tsx | 8 ++++++++ 3 files changed, 24 insertions(+) create mode 100644 src/app/[locale]/feeds/gbfs/page.tsx create mode 100644 src/app/[locale]/feeds/gtfs/page.tsx create mode 100644 src/app/[locale]/feeds/gtfs_rt/page.tsx diff --git a/src/app/[locale]/feeds/gbfs/page.tsx b/src/app/[locale]/feeds/gbfs/page.tsx new file mode 100644 index 00000000..5c783e3e --- /dev/null +++ b/src/app/[locale]/feeds/gbfs/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from 'next/navigation'; + +/** + * Redirects /feeds/gbfs to /feeds?gbfs=true + */ +export default function GbfsFeedsRedirect(): void { + redirect('/feeds?gbfs=true'); +} diff --git a/src/app/[locale]/feeds/gtfs/page.tsx b/src/app/[locale]/feeds/gtfs/page.tsx new file mode 100644 index 00000000..cedbaca3 --- /dev/null +++ b/src/app/[locale]/feeds/gtfs/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from 'next/navigation'; + +/** + * Redirects /feeds/gtfs to /feeds?gtfs=true + */ +export default function GtfsFeedsRedirect(): void { + redirect('/feeds?gtfs=true'); +} diff --git a/src/app/[locale]/feeds/gtfs_rt/page.tsx b/src/app/[locale]/feeds/gtfs_rt/page.tsx new file mode 100644 index 00000000..5d1fcd14 --- /dev/null +++ b/src/app/[locale]/feeds/gtfs_rt/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from 'next/navigation'; + +/** + * Redirects /feeds/gtfs_rt to /feeds?gtfs_rt=true + */ +export default function GtfsRtFeedsRedirect(): void { + redirect('/feeds?gtfs_rt=true'); +} From 31c4f7d5e55f093228fe5137194fca00e8dd7023 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 26 Feb 2026 14:35:56 -0500 Subject: [PATCH 05/21] FeedView generated At + UX enhancement --- src/app/screens/Feed/FeedView.tsx | 12 ++++++++++-- src/app/screens/Feed/components/ScrollToTop.tsx | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 src/app/screens/Feed/components/ScrollToTop.tsx diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index a42c8b57..9822570a 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -11,6 +11,7 @@ import OfficialChip from '../../components/OfficialChip'; import DataQualitySummary from './components/DataQualitySummary'; import FeedSummary from './components/FeedSummary'; import FeedNavigationControls from './components/FeedNavigationControls'; +import ScrollToTop from './components/ScrollToTop'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { getTranslations } from 'next-intl/server'; import { notFound } from 'next/navigation'; @@ -146,8 +147,7 @@ export default async function FeedView({ sx={{ width: '100%', m: 'auto', px: 0 }} maxWidth='xl' > - {/* TODO: remove this timestamp after confirming ISR is working in production and providing real value to users (e.g. helps with debugging feed updates) */} -
Generated at: {new Date().toISOString()}
+ )} + + {`Page generated at: ${new Date().toUTCString().replace(' GMT', ' UTC')}`} + {feed.external_ids?.some((eId) => eId.source === 'tld') === true && ( { + window.scrollTo({ top: 0, behavior: 'instant' }); + }, []); + + return null; +} From a6408d7cb82a2a6ac5adfc3d259df473e1aac2df Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 26 Feb 2026 14:57:54 -0500 Subject: [PATCH 06/21] updated contact us --- src/app/screens/ContactUs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/screens/ContactUs.tsx b/src/app/screens/ContactUs.tsx index 0a01b6c4..30e13715 100644 --- a/src/app/screens/ContactUs.tsx +++ b/src/app/screens/ContactUs.tsx @@ -107,7 +107,7 @@ export default function ContactUs(): React.ReactElement { - + {isValidating && !isLoading && ( + + )} { - setActivePagination(1); - setSelectedFeedTypes({ ...feedTypes }); + navigate({ feedTypes: { ...feedTypes }, page: 1 }); }} setIsOfficialFeedSearch={(isOfficial) => { - setActivePagination(1); - setIsOfficialFeedSearch(isOfficial); + navigate({ isOfficial, page: 1 }); }} setSelectedFeatures={(features) => { - setActivePagination(1); - setSelectedFeatures(features); + navigate({ features, page: 1 }); }} setSelectedGbfsVerions={(versions) => { - setSelectGbfsVersions(versions); - setActivePagination(1); + navigate({ gbfsVersions: versions, page: 1 }); }} setSelectedLicenses={(licenses) => { - setActivePagination(1); - setSelectedLicenses(licenses); + navigate({ licenses, page: 1 }); }} isOfficialTagFilterEnabled={isOfficialTagFilterEnabled} areFeatureFiltersEnabled={areFeatureFiltersEnabled} areGBFSFiltersEnabled={areGBFSFiltersEnabled} - > + /> @@ -454,10 +318,9 @@ export default function Feed(): React.ReactElement { size='small' label={tCommon('gtfsSchedule')} onDelete={() => { - setActivePagination(1); - setSelectedFeedTypes({ - ...selectedFeedTypes, - gtfs: false, + navigate({ + feedTypes: { ...selectedFeedTypes, gtfs: false }, + page: 1, }); }} /> @@ -469,10 +332,9 @@ export default function Feed(): React.ReactElement { size='small' label={tCommon('gtfsRealtime')} onDelete={() => { - setActivePagination(1); - setSelectedFeedTypes({ - ...selectedFeedTypes, - gtfs_rt: false, + navigate({ + feedTypes: { ...selectedFeedTypes, gtfs_rt: false }, + page: 1, }); }} /> @@ -484,10 +346,9 @@ export default function Feed(): React.ReactElement { size='small' label={tCommon('gbfs')} onDelete={() => { - setActivePagination(1); - setSelectedFeedTypes({ - ...selectedFeedTypes, - gbfs: false, + navigate({ + feedTypes: { ...selectedFeedTypes, gbfs: false }, + page: 1, }); }} /> @@ -499,8 +360,7 @@ export default function Feed(): React.ReactElement { size='small' label={'Official Feeds'} onDelete={() => { - setActivePagination(1); - setIsOfficialFeedSearch(false); + navigate({ isOfficial: false, page: 1 }); }} /> )} @@ -513,15 +373,17 @@ export default function Feed(): React.ReactElement { label={feature} key={feature} onDelete={() => { - setSelectedFeatures([ - ...selectedFeatures.filter((sf) => sf !== feature), - ]); + navigate({ + features: selectedFeatures.filter( + (sf) => sf !== feature, + ), + }); }} /> ))} {areGBFSFiltersEnabled && - selectGbfsVersions.map((gbfsVersion) => ( + selectedGbfsVersions.map((gbfsVersion) => ( { - setSelectGbfsVersions([ - ...selectGbfsVersions.filter( + navigate({ + gbfsVersions: selectedGbfsVersions.filter( (sv) => sv !== gbfsVersion, ), - ]); + }); }} /> ))} @@ -546,15 +408,17 @@ export default function Feed(): React.ReactElement { label={license} key={license} onDelete={() => { - setSelectedLicenses([ - ...selectedLicenses.filter((sl) => sl !== license), - ]); + navigate({ + licenses: selectedLicenses.filter( + (sl) => sl !== license, + ), + }); }} /> ))} {(selectedFeatures.length > 0 || - selectGbfsVersions.length > 0 || + selectedGbfsVersions.length > 0 || selectedLicenses.length > 0 || isOfficialFeedSearch || selectedFeedTypes.gtfs_rt || @@ -570,7 +434,7 @@ export default function Feed(): React.ReactElement { )} - {feedStatus === 'loading' && ( + {isLoading && ( )} - {feedStatus === 'error' && ( + {isError && (

{tCommon('errors.generic')}

@@ -615,7 +479,7 @@ export default function Feed(): React.ReactElement {
)} - {feedsData !== undefined && feedStatus === 'loaded' && ( + {feedsData !== undefined && !isLoading && ( <> {feedsData?.results?.length === 0 && ( @@ -688,7 +552,8 @@ export default function Feed(): React.ReactElement { )} @@ -712,9 +577,8 @@ export default function Feed(): React.ReactElement { ? Math.ceil(feedsData.total / searchLimit) : 1 } - onChange={(event, value) => { - event.preventDefault(); - setActivePagination(value); + onChange={(_event, value) => { + navigate({ page: value }); }} /> diff --git a/src/app/[locale]/feeds/lib/useFeedsSearch.ts b/src/app/[locale]/feeds/lib/useFeedsSearch.ts new file mode 100644 index 00000000..6abbb510 --- /dev/null +++ b/src/app/[locale]/feeds/lib/useFeedsSearch.ts @@ -0,0 +1,290 @@ +'use client'; +import { useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { useSWRConfig } from 'swr'; +import { searchFeeds } from '../../../services/feeds'; +import { + type AllFeedsType, + type AllFeedsParams, +} from '../../../services/feeds/utils'; +import { getUserAccessToken } from '../../../services/profile-service'; +import { app } from '../../../../firebase'; +import { + getDataTypeParamFromSelectedFeedTypes, + getInitialSelectedFeedTypes, +} from '../../../screens/Feeds/utility'; + +const SEARCH_LIMIT = 20; + +// This is for client-side caching +const CACHE_TTL_MS = 60 * 30 * 1000; // 30 minutes - controls how long search results are cached in SWR + +/** + * Ensures a Firebase user exists (anonymous or authenticated) before + * SWR attempts to fetch. If no user is signed in, triggers anonymous + * sign-in — the same thing App.tsx does for legacy React Router pages. + * This is needed for the access token + * + * TODO: Revisit this logic to be used at a more global level without slowing down the initial load of all pages that don't require auth (e.g. about, contact). For example, we could move this logic to a context provider that's used only on the feeds page and its children. + */ +function useFirebaseAuthReady(): boolean { + const [isReady, setIsReady] = useState(() => app.auth().currentUser !== null); + + useEffect(() => { + const unsubscribe = app.auth().onAuthStateChanged((user) => { + if (user !== null) { + setIsReady(true); + } else { + // No user — trigger anonymous sign-in (mirrors App.tsx behavior) + setIsReady(false); + app.auth().signInAnonymously().catch(() => { + // Auth listener will handle the state update on success; + // if sign-in fails, isReady stays false and SWR won't fetch. + }); + } + }); + return unsubscribe; + }, []); + + return isReady; +} + + + +/** + * Derives all API query params from the URL search params. + * This is the single source of truth — no duplicated React state. + */ +export function deriveSearchParams(searchParams: URLSearchParams): { + searchQuery: string; + page: number; + feedTypes: Record; + isOfficial: boolean; + features: string[]; + gbfsVersions: string[]; + licenses: string[]; + hasTransitFeedsRedirect: boolean; +} { + const feedTypes = getInitialSelectedFeedTypes(searchParams); + + return { + searchQuery: searchParams.get('q') ?? '', + page: searchParams.get('o') !== null ? Number(searchParams.get('o')) : 1, + feedTypes, + isOfficial: searchParams.get('official') === 'true', + features: searchParams.get('features')?.split(',').filter(Boolean) ?? [], + gbfsVersions: searchParams.get('gbfs_versions')?.split(',').filter(Boolean) ?? [], + licenses: searchParams.get('licenses')?.split(',').filter(Boolean) ?? [], + hasTransitFeedsRedirect: searchParams.get('utm_source') === 'transitfeeds', + }; +} + +/** + * Derives boolean flags for which filter categories are enabled, + * based on the selected feed types. + */ +export function deriveFilterFlags(feedTypes: Record): { + areNoDataTypesSelected: boolean; + isOfficialTagFilterEnabled: boolean; + areFeatureFiltersEnabled: boolean; + areGBFSFiltersEnabled: boolean; +} { + const areNoDataTypesSelected = + !feedTypes.gtfs && !feedTypes.gtfs_rt && !feedTypes.gbfs; + return { + areNoDataTypesSelected, + isOfficialTagFilterEnabled: + feedTypes.gtfs || feedTypes.gtfs_rt || areNoDataTypesSelected, + areFeatureFiltersEnabled: + (!feedTypes.gtfs_rt && !feedTypes.gbfs) || feedTypes.gtfs, + areGBFSFiltersEnabled: + feedTypes.gbfs && !feedTypes.gtfs_rt && !feedTypes.gtfs, + }; +} + +/** + * Builds a stable SWR cache key from the derived search params. + * Returns null when we shouldn't fetch (e.g. no auth available). + */ +function buildSwrKey(derived: ReturnType): string { + const { + searchQuery, + page, + feedTypes, + isOfficial, + features, + gbfsVersions, + licenses, + } = derived; + const flags = deriveFilterFlags(feedTypes); + const cacheWindow = Math.floor(Date.now() / CACHE_TTL_MS); + + // Build a deterministic key representing the current search state + const params = new URLSearchParams(); + params.set('cw', String(cacheWindow)); + params.set('q', searchQuery); + params.set('page', String(page)); + params.set('dt', getDataTypeParamFromSelectedFeedTypes(feedTypes) ?? ''); + if (flags.isOfficialTagFilterEnabled && isOfficial) { + params.set('official', 'true'); + } + if (flags.areFeatureFiltersEnabled && features.length > 0) { + params.set('features', features.join(',')); + } + if (flags.areGBFSFiltersEnabled && gbfsVersions.length > 0) { + params.set('gbfs_versions', gbfsVersions.join(',')); + } + if (licenses.length > 0) { + params.set('licenses', licenses.join(',')); + } + return `feeds-search?${params.toString()}`; +} + +/** + * Fetcher function: obtains an access token and calls the search API. + */ +async function feedsFetcher( + derivedSearchParams: ReturnType, +): Promise { + const accessToken = await getUserAccessToken(); + const { + searchQuery, + page, + feedTypes, + isOfficial, + features, + gbfsVersions, + licenses, + } = derivedSearchParams; + const flags = deriveFilterFlags(feedTypes); + const offset = (page - 1) * SEARCH_LIMIT; + + const params: AllFeedsParams = { + query: { + limit: SEARCH_LIMIT, + offset, + search_query: searchQuery, + data_type: getDataTypeParamFromSelectedFeedTypes(feedTypes), + is_official: flags.isOfficialTagFilterEnabled + ? isOfficial || undefined + : undefined, + status: ['active', 'inactive', 'development', 'future'], + feature: flags.areFeatureFiltersEnabled ? features : undefined, + version: flags.areGBFSFiltersEnabled + ? gbfsVersions.join(',').replaceAll('v', '') + : undefined, + license_ids: + licenses.length > 0 ? licenses.join(',') : undefined, + }, + }; + + return await searchFeeds(params, accessToken); +} + +/** + * SWR hook for feeds search. The URL search params drive the cache key, + * so browser back/forward automatically triggers a re-fetch. + */ +export function useFeedsSearch(searchParams: URLSearchParams): { + feedsData: AllFeedsType | undefined; + isLoading: boolean; + isValidating: boolean; + isError: boolean; + searchLimit: number; +} { + const authReady = useFirebaseAuthReady(); + const { cache } = useSWRConfig(); + const derivedSearchParams = deriveSearchParams(searchParams); + const key = authReady ? buildSwrKey(derivedSearchParams) : null; + + const cachedState = key !== null ? cache.get(key) : undefined; + const hasCachedDataForKey = + cachedState !== undefined && + typeof cachedState === 'object' && + cachedState !== null && + 'data' in cachedState && + cachedState.data !== undefined; + + const { + data, + error, + isLoading, + isValidating: swrIsValidating, + } = useSWR( + key, + async () => await feedsFetcher(derivedSearchParams), + { + // Keep previous data visible while revalidating (no flash to skeleton) + keepPreviousData: true, + // Don't refetch on window focus for search results + revalidateOnFocus: false, + // Deduplicate identical requests within 2 seconds + dedupingInterval: 2000, + }, + ); + + return { + feedsData: data, + // True only on first load (no cached data yet) + isLoading: isLoading && data === undefined, + // True when SWR is fetching and this key has no cached data yet. + // This avoids showing loading UI when navigating back/forward to a cached search. + isValidating: swrIsValidating && !hasCachedDataForKey, + isError: error !== undefined, + searchLimit: SEARCH_LIMIT, + }; +} + +/** + * Builds a new URLSearchParams string from the given filter state, + * suitable for `router.push()`. + */ +export function buildSearchUrl( + pathname: string, + filters: { + searchQuery?: string; + page?: number; + feedTypes?: Record; + isOfficial?: boolean; + features?: string[]; + gbfsVersions?: string[]; + licenses?: string[]; + utmSource?: string | null; + }, +): string { + const params = new URLSearchParams(); + + if (filters.searchQuery) { + params.set('q', filters.searchQuery); + } + if (filters.page !== undefined && filters.page !== 1) { + params.set('o', String(filters.page)); + } + if (filters.feedTypes?.gtfs) { + params.set('gtfs', 'true'); + } + if (filters.feedTypes?.gtfs_rt) { + params.set('gtfs_rt', 'true'); + } + if (filters.feedTypes?.gbfs) { + params.set('gbfs', 'true'); + } + if (filters.features && filters.features.length > 0) { + params.set('features', filters.features.join(',')); + } + if (filters.gbfsVersions && filters.gbfsVersions.length > 0) { + params.set('gbfs_versions', filters.gbfsVersions.join(',')); + } + if (filters.licenses && filters.licenses.length > 0) { + params.set('licenses', filters.licenses.join(',')); + } + if (filters.isOfficial) { + params.set('official', 'true'); + } + if (filters.utmSource) { + params.set('utm_source', filters.utmSource); + } + + const qs = params.toString(); + return `${pathname}${qs.length > 0 ? `?${qs}` : ''}`; +} diff --git a/src/app/[locale]/feeds/page.tsx b/src/app/[locale]/feeds/page.tsx new file mode 100644 index 00000000..c33c41cb --- /dev/null +++ b/src/app/[locale]/feeds/page.tsx @@ -0,0 +1,29 @@ +import { type ReactElement, Suspense } from 'react'; +import { setRequestLocale } from 'next-intl/server'; +import { type Locale, routing } from '../../../i18n/routing'; +import FeedsScreen from './components/FeedsScreen'; +import FeedsScreenSkeleton from '../../screens/Feeds/FeedsScreenSkeleton'; + +export function generateStaticParams(): Array<{ + locale: Locale; +}> { + return routing.locales.map((locale) => ({ locale })); +} + +interface PageProps { + params: Promise<{ locale: string }>; +} + +export default async function FeedsPage({ + params, +}: PageProps): Promise { + const { locale } = await params; + + setRequestLocale(locale); + + return ( + }> + + + ); +} diff --git a/src/app/router/Router.tsx b/src/app/router/Router.tsx index d8ba70a6..a3862716 100644 --- a/src/app/router/Router.tsx +++ b/src/app/router/Router.tsx @@ -13,7 +13,6 @@ import FAQ from '../screens/FAQ'; import PostRegistration from '../screens/PostRegistration'; import TermsAndConditions from '../screens/TermsAndConditions'; import PrivacyPolicy from '../screens/PrivacyPolicy'; -import Feeds from '../screens/Feeds'; import { SIGN_OUT_TARGET } from '../constants/Navigation'; import { LOGIN_CHANNEL, diff --git a/yarn.lock b/yarn.lock index c42b451b..66e2aff6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11769,6 +11769,14 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swr@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.4.0.tgz#cd11e368cb13597f61ee3334428aa20b5e81f36e" + integrity sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw== + dependencies: + dequal "^2.0.3" + use-sync-external-store "^1.6.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" From 861c1f7f52596ae4a03a6facb9d7456f753ae329 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Fri, 27 Feb 2026 10:01:29 -0500 Subject: [PATCH 13/21] SSG pages away from legacy routing --- src/app/[locale]/contact-us/page.tsx | 26 ++++++++++++++++ src/app/[locale]/contribute-faq/page.tsx | 26 ++++++++++++++++ src/app/[locale]/faq/page.tsx | 26 ++++++++++++++++ src/app/[locale]/gbfs-validator/page.tsx | 31 +++++++++++++++++++ src/app/[locale]/privacy-policy/page.tsx | 26 ++++++++++++++++ .../[locale]/terms-and-conditions/page.tsx | 26 ++++++++++++++++ src/app/context/GbfsAuthProvider.tsx | 2 ++ src/app/router/Router.tsx | 21 +------------ src/app/screens/ContactUs.tsx | 2 ++ src/app/screens/FAQ.tsx | 2 ++ src/app/screens/FeedSubmissionFAQ.tsx | 2 ++ src/app/screens/GbfsValidator/index.tsx | 2 ++ src/app/screens/PrivacyPolicy.tsx | 2 ++ src/app/screens/TermsAndConditions.tsx | 2 ++ 14 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 src/app/[locale]/contact-us/page.tsx create mode 100644 src/app/[locale]/contribute-faq/page.tsx create mode 100644 src/app/[locale]/faq/page.tsx create mode 100644 src/app/[locale]/gbfs-validator/page.tsx create mode 100644 src/app/[locale]/privacy-policy/page.tsx create mode 100644 src/app/[locale]/terms-and-conditions/page.tsx diff --git a/src/app/[locale]/contact-us/page.tsx b/src/app/[locale]/contact-us/page.tsx new file mode 100644 index 00000000..a9b25a09 --- /dev/null +++ b/src/app/[locale]/contact-us/page.tsx @@ -0,0 +1,26 @@ +import { type ReactElement } from 'react'; +import { setRequestLocale } from 'next-intl/server'; +import { type Locale, routing } from '../../../i18n/routing'; +import ContactUs from '../../screens/ContactUs'; + +export const dynamic = 'force-static'; + +export function generateStaticParams(): Array<{ + locale: Locale; +}> { + return routing.locales.map((locale) => ({ locale })); +} + +interface PageProps { + params: Promise<{ locale: string }>; +} + +export default async function ContactUsPage({ + params, +}: PageProps): Promise { + const { locale } = await params; + + setRequestLocale(locale); + + return ; +} diff --git a/src/app/[locale]/contribute-faq/page.tsx b/src/app/[locale]/contribute-faq/page.tsx new file mode 100644 index 00000000..2fae896b --- /dev/null +++ b/src/app/[locale]/contribute-faq/page.tsx @@ -0,0 +1,26 @@ +import { type ReactElement } from 'react'; +import { setRequestLocale } from 'next-intl/server'; +import { type Locale, routing } from '../../../i18n/routing'; +import FeedSubmissionFAQ from '../../screens/FeedSubmissionFAQ'; + +export const dynamic = 'force-static'; + +export function generateStaticParams(): Array<{ + locale: Locale; +}> { + return routing.locales.map((locale) => ({ locale })); +} + +interface PageProps { + params: Promise<{ locale: string }>; +} + +export default async function ContributeFAQPage({ + params, +}: PageProps): Promise { + const { locale } = await params; + + setRequestLocale(locale); + + return ; +} diff --git a/src/app/[locale]/faq/page.tsx b/src/app/[locale]/faq/page.tsx new file mode 100644 index 00000000..8cd8ec2e --- /dev/null +++ b/src/app/[locale]/faq/page.tsx @@ -0,0 +1,26 @@ +import { type ReactElement } from 'react'; +import { setRequestLocale } from 'next-intl/server'; +import { type Locale, routing } from '../../../i18n/routing'; +import FAQ from '../../screens/FAQ'; + +export const dynamic = 'force-static'; + +export function generateStaticParams(): Array<{ + locale: Locale; +}> { + return routing.locales.map((locale) => ({ locale })); +} + +interface PageProps { + params: Promise<{ locale: string }>; +} + +export default async function FAQPage({ + params, +}: PageProps): Promise { + const { locale } = await params; + + setRequestLocale(locale); + + return ; +} diff --git a/src/app/[locale]/gbfs-validator/page.tsx b/src/app/[locale]/gbfs-validator/page.tsx new file mode 100644 index 00000000..f301ffd5 --- /dev/null +++ b/src/app/[locale]/gbfs-validator/page.tsx @@ -0,0 +1,31 @@ +import { type ReactElement } from 'react'; +import { setRequestLocale } from 'next-intl/server'; +import { type Locale, routing } from '../../../i18n/routing'; +import GbfsValidator from '../../screens/GbfsValidator'; +import { GbfsAuthProvider } from '../../context/GbfsAuthProvider'; + +export const dynamic = 'force-static'; + +export function generateStaticParams(): Array<{ + locale: Locale; +}> { + return routing.locales.map((locale) => ({ locale })); +} + +interface PageProps { + params: Promise<{ locale: string }>; +} + +export default async function GbfsValidatorPage({ + params, +}: PageProps): Promise { + const { locale } = await params; + + setRequestLocale(locale); + + return ( + + + + ); +} diff --git a/src/app/[locale]/privacy-policy/page.tsx b/src/app/[locale]/privacy-policy/page.tsx new file mode 100644 index 00000000..8468964f --- /dev/null +++ b/src/app/[locale]/privacy-policy/page.tsx @@ -0,0 +1,26 @@ +import { type ReactElement } from 'react'; +import { setRequestLocale } from 'next-intl/server'; +import { type Locale, routing } from '../../../i18n/routing'; +import PrivacyPolicy from '../../screens/PrivacyPolicy'; + +export const dynamic = 'force-static'; + +export function generateStaticParams(): Array<{ + locale: Locale; +}> { + return routing.locales.map((locale) => ({ locale })); +} + +interface PageProps { + params: Promise<{ locale: string }>; +} + +export default async function PrivacyPolicyPage({ + params, +}: PageProps): Promise { + const { locale } = await params; + + setRequestLocale(locale); + + return ; +} diff --git a/src/app/[locale]/terms-and-conditions/page.tsx b/src/app/[locale]/terms-and-conditions/page.tsx new file mode 100644 index 00000000..0d6e1a15 --- /dev/null +++ b/src/app/[locale]/terms-and-conditions/page.tsx @@ -0,0 +1,26 @@ +import { type ReactElement } from 'react'; +import { setRequestLocale } from 'next-intl/server'; +import { type Locale, routing } from '../../../i18n/routing'; +import TermsAndConditions from '../../screens/TermsAndConditions'; + +export const dynamic = 'force-static'; + +export function generateStaticParams(): Array<{ + locale: Locale; +}> { + return routing.locales.map((locale) => ({ locale })); +} + +interface PageProps { + params: Promise<{ locale: string }>; +} + +export default async function TermsAndConditionsPage({ + params, +}: PageProps): Promise { + const { locale } = await params; + + setRequestLocale(locale); + + return ; +} diff --git a/src/app/context/GbfsAuthProvider.tsx b/src/app/context/GbfsAuthProvider.tsx index 2b96c53d..7592c7b2 100644 --- a/src/app/context/GbfsAuthProvider.tsx +++ b/src/app/context/GbfsAuthProvider.tsx @@ -1,3 +1,5 @@ +'use client'; + import React, { createContext, useContext, useMemo, useState } from 'react'; import { type BasicAuth, diff --git a/src/app/router/Router.tsx b/src/app/router/Router.tsx index a3862716..2a24657f 100644 --- a/src/app/router/Router.tsx +++ b/src/app/router/Router.tsx @@ -9,10 +9,7 @@ import { ProtectedRoute } from './ProtectedRoute'; import CompleteRegistration from '../screens/CompleteRegistration'; import ChangePassword from '../screens/ChangePassword'; import ForgotPassword from '../screens/ForgotPassword'; -import FAQ from '../screens/FAQ'; import PostRegistration from '../screens/PostRegistration'; -import TermsAndConditions from '../screens/TermsAndConditions'; -import PrivacyPolicy from '../screens/PrivacyPolicy'; import { SIGN_OUT_TARGET } from '../constants/Navigation'; import { LOGIN_CHANNEL, @@ -22,7 +19,6 @@ import { import { useAppDispatch } from '../hooks'; import { logout } from '../store/profile-reducer'; import FeedSubmission from '../screens/FeedSubmission'; -import FeedSubmissionFAQ from '../screens/FeedSubmissionFAQ'; import FeedSubmitted from '../screens/FeedSubmitted'; import GTFSFeedAnalytics from '../screens/Analytics/GTFSFeedAnalytics'; import GTFSNoticeAnalytics from '../screens/Analytics/GTFSNoticeAnalytics'; @@ -30,9 +26,7 @@ import GTFSFeatureAnalytics from '../screens/Analytics/GTFSFeatureAnalytics'; import GBFSFeedAnalytics from '../screens/Analytics/GBFSFeedAnalytics'; import GBFSNoticeAnalytics from '../screens/Analytics/GBFSNoticeAnalytics'; import GBFSVersionAnalytics from '../screens/Analytics/GBFSVersionAnalytics'; -import ContactUs from '../screens/ContactUs'; -import GbfsValidator from '../screens/GbfsValidator'; -import { GbfsAuthProvider } from '../context/GbfsAuthProvider'; + export const AppRouter: React.FC = () => { const router = useRouter(); @@ -91,21 +85,8 @@ export const AppRouter: React.FC = () => { } /> } /> - } /> - } /> - - - - } - /> } /> } /> - } /> - } /> - } /> } /> } /> diff --git a/src/app/screens/ContactUs.tsx b/src/app/screens/ContactUs.tsx index 30e13715..9a9317c8 100644 --- a/src/app/screens/ContactUs.tsx +++ b/src/app/screens/ContactUs.tsx @@ -1,3 +1,5 @@ +'use client'; + import * as React from 'react'; import Box from '@mui/material/Box'; import Container from '@mui/material/Container'; diff --git a/src/app/screens/FAQ.tsx b/src/app/screens/FAQ.tsx index 4c9a4496..5373bf7c 100644 --- a/src/app/screens/FAQ.tsx +++ b/src/app/screens/FAQ.tsx @@ -1,3 +1,5 @@ +'use client'; + import * as React from 'react'; import Container from '@mui/material/Container'; import { Button, Typography } from '@mui/material'; diff --git a/src/app/screens/FeedSubmissionFAQ.tsx b/src/app/screens/FeedSubmissionFAQ.tsx index 82ff2690..96079c68 100644 --- a/src/app/screens/FeedSubmissionFAQ.tsx +++ b/src/app/screens/FeedSubmissionFAQ.tsx @@ -1,3 +1,5 @@ +'use client'; + import * as React from 'react'; import { Accordion, diff --git a/src/app/screens/GbfsValidator/index.tsx b/src/app/screens/GbfsValidator/index.tsx index f37cad28..46449fe0 100644 --- a/src/app/screens/GbfsValidator/index.tsx +++ b/src/app/screens/GbfsValidator/index.tsx @@ -1,3 +1,5 @@ +'use client'; + import { OpenInNew } from '@mui/icons-material'; import { Box, Button, Link, Typography, useTheme } from '@mui/material'; import React, { useEffect } from 'react'; diff --git a/src/app/screens/PrivacyPolicy.tsx b/src/app/screens/PrivacyPolicy.tsx index 829bc662..c955246a 100644 --- a/src/app/screens/PrivacyPolicy.tsx +++ b/src/app/screens/PrivacyPolicy.tsx @@ -1,3 +1,5 @@ +'use client'; + import React from 'react'; import { Box, Container, Typography, useTheme } from '@mui/material'; export default function PrivacyPolicy(): React.ReactElement { diff --git a/src/app/screens/TermsAndConditions.tsx b/src/app/screens/TermsAndConditions.tsx index 6d6e2cde..7e39ae6e 100644 --- a/src/app/screens/TermsAndConditions.tsx +++ b/src/app/screens/TermsAndConditions.tsx @@ -1,3 +1,5 @@ +'use client'; + import React from 'react'; import { Box, Button, Container, Typography, useTheme } from '@mui/material'; import CssBaseline from '@mui/material/CssBaseline'; From a3ba36c08f72b116bbbf94f8a1ae083b28a3982f Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Fri, 27 Feb 2026 10:12:50 -0500 Subject: [PATCH 14/21] delay feeds loading state for better UX --- src/app/screens/Feeds/AdvancedSearchTable.tsx | 15 +++++++++++++-- src/app/screens/Feeds/SearchTable.tsx | 12 +++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/app/screens/Feeds/AdvancedSearchTable.tsx b/src/app/screens/Feeds/AdvancedSearchTable.tsx index bf4f5bb7..b369e767 100644 --- a/src/app/screens/Feeds/AdvancedSearchTable.tsx +++ b/src/app/screens/Feeds/AdvancedSearchTable.tsx @@ -175,8 +175,19 @@ export default function AdvancedSearchTable({ const [popoverTitle, setPopoverTitle] = React.useState(); const router = useRouter(); const [isPending, startTransition] = React.useTransition(); + const [showLoading, setShowLoading] = React.useState(false); const theme = useTheme(); + // Show loading state if navigation or feed loading is taking longer than 300ms to avoid flashing effect for fast transitions + React.useEffect(() => { + if (!isPending) { + setShowLoading(false); + return; + } + const timer = setTimeout(() => setShowLoading(true), 300); + return () => clearTimeout(timer); + }, [isPending]); + const descriptionDividerStyle: SxProps = { py: 1, borderTop: `1px solid ${theme.palette.divider}`, @@ -188,7 +199,7 @@ export default function AdvancedSearchTable({ return ( <> - {(isPending) && ( + {showLoading && ( (null); + + React.useEffect(() => { + if (!isPending) { + setShowLoading(false); + return; + } + const timer = setTimeout(() => setShowLoading(true), 300); + return () => clearTimeout(timer); + }, [isPending]); const [providersPopoverData, setProvidersPopoverData] = React.useState< string[] | undefined >(undefined); @@ -83,7 +93,7 @@ export default function SearchTable({ // Reason for all component overrite is for SEO purposes. return ( <> - {isPending && ( + {showLoading && ( Date: Fri, 27 Feb 2026 10:15:27 -0500 Subject: [PATCH 15/21] lint fixes --- .../[feedDataType]/[feedId]/static/layout.tsx | 3 - src/app/[locale]/feeds/lib/useFeedsSearch.ts | 42 +- src/app/router/Router.tsx | 1 - src/app/screens/Feed/FeedView.tsx | 12 +- .../screens/Feed/components/GbfsVersions.tsx | 1 - src/app/screens/Feeds/AdvancedSearchTable.tsx | 24 +- src/app/screens/Feeds/FeedsScreenSkeleton.tsx | 57 ++- src/app/screens/Feeds/SearchTable.tsx | 401 +++++++++--------- 8 files changed, 293 insertions(+), 248 deletions(-) diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx index f5b80848..d5e52c5f 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx @@ -1,6 +1,4 @@ import { type ReactNode } from 'react'; -import { notFound } from 'next/navigation'; -import { fetchGuestFeedData } from '../lib/guest-feed-data'; /** * ISR caching: revalidate cached HTML every 14 days. @@ -31,6 +29,5 @@ export default async function StaticFeedLayout({ children, params, }: Props): Promise { - return <>{children}; } diff --git a/src/app/[locale]/feeds/lib/useFeedsSearch.ts b/src/app/[locale]/feeds/lib/useFeedsSearch.ts index 6abbb510..c8ac50c0 100644 --- a/src/app/[locale]/feeds/lib/useFeedsSearch.ts +++ b/src/app/[locale]/feeds/lib/useFeedsSearch.ts @@ -1,7 +1,6 @@ 'use client'; import { useEffect, useState } from 'react'; -import useSWR from 'swr'; -import { useSWRConfig } from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; import { searchFeeds } from '../../../services/feeds'; import { type AllFeedsType, @@ -24,7 +23,7 @@ const CACHE_TTL_MS = 60 * 30 * 1000; // 30 minutes - controls how long search re * SWR attempts to fetch. If no user is signed in, triggers anonymous * sign-in — the same thing App.tsx does for legacy React Router pages. * This is needed for the access token - * + * * TODO: Revisit this logic to be used at a more global level without slowing down the initial load of all pages that don't require auth (e.g. about, contact). For example, we could move this logic to a context provider that's used only on the feeds page and its children. */ function useFirebaseAuthReady(): boolean { @@ -37,10 +36,13 @@ function useFirebaseAuthReady(): boolean { } else { // No user — trigger anonymous sign-in (mirrors App.tsx behavior) setIsReady(false); - app.auth().signInAnonymously().catch(() => { - // Auth listener will handle the state update on success; - // if sign-in fails, isReady stays false and SWR won't fetch. - }); + app + .auth() + .signInAnonymously() + .catch(() => { + // Auth listener will handle the state update on success; + // if sign-in fails, isReady stays false and SWR won't fetch. + }); } }); return unsubscribe; @@ -49,8 +51,6 @@ function useFirebaseAuthReady(): boolean { return isReady; } - - /** * Derives all API query params from the URL search params. * This is the single source of truth — no duplicated React state. @@ -73,7 +73,8 @@ export function deriveSearchParams(searchParams: URLSearchParams): { feedTypes, isOfficial: searchParams.get('official') === 'true', features: searchParams.get('features')?.split(',').filter(Boolean) ?? [], - gbfsVersions: searchParams.get('gbfs_versions')?.split(',').filter(Boolean) ?? [], + gbfsVersions: + searchParams.get('gbfs_versions')?.split(',').filter(Boolean) ?? [], licenses: searchParams.get('licenses')?.split(',').filter(Boolean) ?? [], hasTransitFeedsRedirect: searchParams.get('utm_source') === 'transitfeeds', }; @@ -173,8 +174,7 @@ async function feedsFetcher( version: flags.areGBFSFiltersEnabled ? gbfsVersions.join(',').replaceAll('v', '') : undefined, - license_ids: - licenses.length > 0 ? licenses.join(',') : undefined, + license_ids: licenses.length > 0 ? licenses.join(',') : undefined, }, }; @@ -254,34 +254,34 @@ export function buildSearchUrl( ): string { const params = new URLSearchParams(); - if (filters.searchQuery) { + if (filters.searchQuery != null && filters.searchQuery !== '') { params.set('q', filters.searchQuery); } if (filters.page !== undefined && filters.page !== 1) { params.set('o', String(filters.page)); } - if (filters.feedTypes?.gtfs) { + if (filters.feedTypes?.gtfs === true) { params.set('gtfs', 'true'); } - if (filters.feedTypes?.gtfs_rt) { + if (filters.feedTypes?.gtfs_rt === true) { params.set('gtfs_rt', 'true'); } - if (filters.feedTypes?.gbfs) { + if (filters.feedTypes?.gbfs === true) { params.set('gbfs', 'true'); } - if (filters.features && filters.features.length > 0) { + if (filters.features != null && filters.features.length > 0) { params.set('features', filters.features.join(',')); } - if (filters.gbfsVersions && filters.gbfsVersions.length > 0) { + if (filters.gbfsVersions != null && filters.gbfsVersions.length > 0) { params.set('gbfs_versions', filters.gbfsVersions.join(',')); } - if (filters.licenses && filters.licenses.length > 0) { + if (filters.licenses != null && filters.licenses.length > 0) { params.set('licenses', filters.licenses.join(',')); } - if (filters.isOfficial) { + if (filters.isOfficial === true) { params.set('official', 'true'); } - if (filters.utmSource) { + if (filters.utmSource != null && filters.utmSource !== '') { params.set('utm_source', filters.utmSource); } diff --git a/src/app/router/Router.tsx b/src/app/router/Router.tsx index 2a24657f..85d8ea30 100644 --- a/src/app/router/Router.tsx +++ b/src/app/router/Router.tsx @@ -27,7 +27,6 @@ import GBFSFeedAnalytics from '../screens/Analytics/GBFSFeedAnalytics'; import GBFSNoticeAnalytics from '../screens/Analytics/GBFSNoticeAnalytics'; import GBFSVersionAnalytics from '../screens/Analytics/GBFSVersionAnalytics'; - export const AppRouter: React.FC = () => { const router = useRouter(); const dispatch = useAppDispatch(); diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 9822570a..a5d36684 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -227,12 +227,12 @@ export default async function FeedView({ )} - {`Page generated at: ${new Date().toUTCString().replace(' GMT', ' UTC')}`} + data-testid='page-generated' + variant={'caption'} + width={'100%'} + component={'div'} + > + {`Page generated at: ${new Date().toUTCString().replace(' GMT', ' UTC')}`} {feed.external_ids?.some((eId) => eId.source === 'tld') === true && ( diff --git a/src/app/screens/Feed/components/GbfsVersions.tsx b/src/app/screens/Feed/components/GbfsVersions.tsx index 5d0c4462..c604b8f3 100644 --- a/src/app/screens/Feed/components/GbfsVersions.tsx +++ b/src/app/screens/Feed/components/GbfsVersions.tsx @@ -1,7 +1,6 @@ 'use client'; import * as React from 'react'; -import { ContentBox } from '../../../components/ContentBox'; import { Box, Button, diff --git a/src/app/screens/Feeds/AdvancedSearchTable.tsx b/src/app/screens/Feeds/AdvancedSearchTable.tsx index b369e767..623d489d 100644 --- a/src/app/screens/Feeds/AdvancedSearchTable.tsx +++ b/src/app/screens/Feeds/AdvancedSearchTable.tsx @@ -5,7 +5,7 @@ import { Chip, CircularProgress, type SxProps, - Theme, + type Theme, Tooltip, Typography, useTheme, @@ -76,7 +76,7 @@ const DetailsContainer = ({ const renderGTFSDetails = ( gtfsFeed: SearchFeedItem, selectedFeatures: string[], - theme: Theme + theme: Theme, ): React.ReactElement => { const feedFeatures = gtfsFeed?.latest_dataset?.validation_report?.features ?? []; @@ -137,7 +137,7 @@ const renderGTFSRTDetails = ( const renderGBFSDetails = ( gbfsFeedSearchElement: SearchFeedItem, selectedGbfsVersions: string[], - theme: Theme + theme: Theme, ): React.ReactElement => { return ( @@ -164,7 +164,7 @@ export default function AdvancedSearchTable({ feedsData, selectedFeatures, selectedGbfsVersions, - isLoadingFeeds + isLoadingFeeds, }: AdvancedSearchTableProps): React.ReactElement { const t = useTranslations('feeds'); const tCommon = useTranslations('common'); @@ -184,8 +184,12 @@ export default function AdvancedSearchTable({ setShowLoading(false); return; } - const timer = setTimeout(() => setShowLoading(true), 300); - return () => clearTimeout(timer); + const timer = setTimeout(() => { + setShowLoading(true); + }, 300); + return () => { + clearTimeout(timer); + }; }, [isPending]); const descriptionDividerStyle: SxProps = { @@ -234,7 +238,7 @@ export default function AdvancedSearchTable({ }} > - router.push(`/feeds/${feed.data_type}/${feed.id}`), - ); + startTransition(() => { + router.push(`/feeds/${feed.data_type}/${feed.id}`); + }); }} > + {/* Page title */} - + {/* Search bar */} - - + + @@ -27,24 +43,45 @@ export default function FeedsScreenSkeleton(): React.ReactElement { {/* Filter sidebar */} - + {Array.from({ length: 5 }).map((_, i) => ( - + ))} - + {Array.from({ length: 4 }).map((_, i) => ( - + ))} {/* Results area */} {/* Active-filter chips row */} - + {/* Result count + view toggle */} setShowLoading(true), 300); - return () => clearTimeout(timer); + const timer = setTimeout(() => { + setShowLoading(true); + }, 300); + return () => { + clearTimeout(timer); + }; }, [isPending]); const [providersPopoverData, setProvidersPopoverData] = React.useState< string[] | undefined @@ -108,212 +112,217 @@ export default function SearchTable({ )} - - - - - {t('transitProvider')} - - {t('locations')} - - {t('feedDescription')} - - {t('dataType')} - - - - {feedsData?.results?.map((feed) => ( - { - // Navigation to Feed Detail Page can have a delay - // Show loading state to ease transition - // This will be further reviewed - if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; - e.preventDefault(); - startTransition(() => router.push(`/feeds/${feed.data_type}/${feed.id}`)); - }} - sx={{ - textDecoration: 'none', - backgroundColor: theme.palette.background.default, - '.feed-column': { - fontSize: '16px', - borderBottom: `1px solid ${theme.palette.divider}`, - }, - '&:hover, &:focus': { - backgroundColor: theme.palette.background.paper, - cursor: 'pointer', - }, - }} - > - - - { - setProvidersPopoverData(popoverData); - }} - setAnchorEl={(el) => { - setAnchorEl(el); - }} - > - {feed.official === true && ( - - )} - - - - {feed.locations != null && feed.locations.length > 1 ? ( - <> - {getCountryLocationSummaries(feed.locations).map( - (summary) => { - const tooltipText = `${summary.subdivisions.size} subdivisions and ${summary.municipalities.size} municipalities within ${summary.country}.`; - - return ( - - - - ); - }, - )} - - ) : ( - <> - {feed.locations?.[0] != null && ( - - )} - - )} - - - {feed.feed_name} - - - - {getDataTypeElement(feed.data_type)} - {feed.data_type === 'gtfs_rt' && ( - - )} - - + + + + {t('transitProvider')} + + {t('locations')} + + {t('feedDescription')} + + {t('dataType')} - ))} - - - {providersPopoverData !== undefined && ( - + - ( + { + // Navigation to Feed Detail Page can have a delay + // Show loading state to ease transition + // This will be further reviewed + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) + return; + e.preventDefault(); + startTransition(() => { + router.push(`/feeds/${feed.data_type}/${feed.id}`); + }); + }} + sx={{ + textDecoration: 'none', + backgroundColor: theme.palette.background.default, + '.feed-column': { + fontSize: '16px', + borderBottom: `1px solid ${theme.palette.divider}`, + }, + '&:hover, &:focus': { + backgroundColor: theme.palette.background.paper, + cursor: 'pointer', + }, + }} + > + + + { + setProvidersPopoverData(popoverData); + }} + setAnchorEl={(el) => { + setAnchorEl(el); + }} + > + {feed.official === true && ( + + )} + + + + {feed.locations != null && feed.locations.length > 1 ? ( + <> + {getCountryLocationSummaries(feed.locations).map( + (summary) => { + const tooltipText = `${summary.subdivisions.size} subdivisions and ${summary.municipalities.size} municipalities within ${summary.country}.`; + + return ( + + + + ); + }, + )} + + ) : ( + <> + {feed.locations?.[0] != null && ( + + )} + + )} + + + {feed.feed_name} + + + + {getDataTypeElement(feed.data_type)} + {feed.data_type === 'gtfs_rt' && ( + + )} + + + + ))} + + + {providersPopoverData !== undefined && ( + - - {t('transitProvider')} - {providersPopoverData[0]} - - + + + {t('transitProvider')} - {providersPopoverData[0]} + + - -
    - {providersPopoverData.slice(0, 10).map((provider) => ( -
  • {provider}
  • - ))} - {providersPopoverData.length > 10 && ( -
  • - - {t('seeDetailPageProviders', { - providersCount: providersPopoverData.length - 10, - })} - -
  • - )} -
-
-
- )} -
+ +
    + {providersPopoverData.slice(0, 10).map((provider) => ( +
  • {provider}
  • + ))} + {providersPopoverData.length > 10 && ( +
  • + + {t('seeDetailPageProviders', { + providersCount: providersPopoverData.length - 10, + })} + +
  • + )} +
+
+ + )} + ); } From 06a112a1d4fe2dd7d592e1b58aadef9f8310308f Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Fri, 27 Feb 2026 10:18:43 -0500 Subject: [PATCH 16/21] test fix --- src/app/screens/Feeds/SearchTable.spec.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/screens/Feeds/SearchTable.spec.tsx b/src/app/screens/Feeds/SearchTable.spec.tsx index 5ea19b41..5c89092f 100644 --- a/src/app/screens/Feeds/SearchTable.spec.tsx +++ b/src/app/screens/Feeds/SearchTable.spec.tsx @@ -3,6 +3,11 @@ import { render, cleanup, screen, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { type AllFeedsType } from '../../services/feeds/utils'; +jest.mock('../../../i18n/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), + usePathname: () => '/', +})); + const mockFeedsData: AllFeedsType = { total: 2004, results: [ From 1eadd89d4f18920fff12ae36570c48690ec63f37 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Fri, 27 Feb 2026 11:15:19 -0500 Subject: [PATCH 17/21] update the cypress test with new title --- cypress/e2e/home.cy.ts | 2 +- cypress/e2e/signin.cy.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/home.cy.ts b/cypress/e2e/home.cy.ts index 290c89da..1ac51c6a 100644 --- a/cypress/e2e/home.cy.ts +++ b/cypress/e2e/home.cy.ts @@ -6,7 +6,7 @@ describe('Home page', () => { it('should render page header', () => { cy.get('[data-testid=websiteTile]') .should('exist') - .contains('Mobility Database'); + .contains('MobilityDatabase'); }); it('should render home page title', () => { diff --git a/cypress/e2e/signin.cy.ts b/cypress/e2e/signin.cy.ts index 014609a1..8362b99f 100644 --- a/cypress/e2e/signin.cy.ts +++ b/cypress/e2e/signin.cy.ts @@ -6,7 +6,7 @@ describe('Sign In page', () => { it('should render page header', () => { cy.get('[data-testid=websiteTile]') .should('exist') - .contains('Mobility Database'); + .contains('MobilityDatabase'); }); it('should render signin', () => { From c6b244f6a93c39a4e45ffd55908142e8c498aa0c Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 3 Mar 2026 07:51:55 -0500 Subject: [PATCH 18/21] improved loading state on initial feeds search --- src/app/[locale]/feeds/lib/useFeedsSearch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/feeds/lib/useFeedsSearch.ts b/src/app/[locale]/feeds/lib/useFeedsSearch.ts index c8ac50c0..541924c7 100644 --- a/src/app/[locale]/feeds/lib/useFeedsSearch.ts +++ b/src/app/[locale]/feeds/lib/useFeedsSearch.ts @@ -225,8 +225,8 @@ export function useFeedsSearch(searchParams: URLSearchParams): { return { feedsData: data, - // True only on first load (no cached data yet) - isLoading: isLoading && data === undefined, + // True on first load (no cached data yet) OR while waiting for Firebase Auth to initialize + isLoading: !authReady || (isLoading && data === undefined), // True when SWR is fetching and this key has no cached data yet. // This avoids showing loading UI when navigating back/forward to a cached search. isValidating: swrIsValidating && !hasCachedDataForKey, From 5d0732187764708632bf6c4688f382f62815fff8 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 3 Mar 2026 07:56:14 -0500 Subject: [PATCH 19/21] gbfs versions styling fix --- src/app/screens/Feed/components/GbfsVersions.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/screens/Feed/components/GbfsVersions.tsx b/src/app/screens/Feed/components/GbfsVersions.tsx index c604b8f3..c15cff80 100644 --- a/src/app/screens/Feed/components/GbfsVersions.tsx +++ b/src/app/screens/Feed/components/GbfsVersions.tsx @@ -120,6 +120,7 @@ export default function GbfsVersions({ display: 'flex', flexDirection: 'column', justifyContent: 'space-between', + backgroundColor: theme.palette.background.default, }} width={{ xs: '100%' }} > From 583884ef89ac97f7cc2fd9ad98155452819d3790 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 3 Mar 2026 08:49:10 -0500 Subject: [PATCH 20/21] fixing links --- src/app/[locale]/about/components/AboutPage.tsx | 15 ++++++--------- src/app/[locale]/components/HomePage.tsx | 9 ++++++--- src/app/screens/FAQ.tsx | 3 +++ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/app/[locale]/about/components/AboutPage.tsx b/src/app/[locale]/about/components/AboutPage.tsx index bdf1aac9..d50a3430 100644 --- a/src/app/[locale]/about/components/AboutPage.tsx +++ b/src/app/[locale]/about/components/AboutPage.tsx @@ -2,6 +2,7 @@ import { Container, Typography, Button } from '@mui/material'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { type ReactElement } from 'react'; import { getTranslations } from 'next-intl/server'; +import Link from 'next/link'; export default async function AboutPage(): Promise { const t = await getTranslations('about'); @@ -72,15 +73,11 @@ export default async function AboutPage(): Promise {
  • {t('benefits.mirrored')}
  • {t('benefits.boundingBoxes')}
  • - + + +
  • {t('benefits.openSource')}
  • diff --git a/src/app/[locale]/components/HomePage.tsx b/src/app/[locale]/components/HomePage.tsx index e456f42f..60596632 100644 --- a/src/app/[locale]/components/HomePage.tsx +++ b/src/app/[locale]/components/HomePage.tsx @@ -10,6 +10,7 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import SearchBox from './SearchBox'; import { getTranslations } from 'next-intl/server'; import '../../styles/TextShimmer.css'; +import Link from 'next/link'; interface ActionBoxProps { IconComponent: React.ElementType; @@ -35,9 +36,11 @@ const ActionBox = ({ }} > - + + +
    ); diff --git a/src/app/screens/FAQ.tsx b/src/app/screens/FAQ.tsx index 5373bf7c..8f5c523d 100644 --- a/src/app/screens/FAQ.tsx +++ b/src/app/screens/FAQ.tsx @@ -5,6 +5,7 @@ import Container from '@mui/material/Container'; import { Button, Typography } from '@mui/material'; import { ColoredContainer } from '../styles/PageLayout.style'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import Link from 'next/link'; export default function FAQ(): React.ReactElement { return ( @@ -54,6 +55,7 @@ export default function FAQ(): React.ReactElement { variant='text' className='line-start inline' href={'/contribute'} + component={Link} > add a feed @@ -72,6 +74,7 @@ export default function FAQ(): React.ReactElement { variant='text' className='line-start inline' href={'/sign-up'} + component={Link} > create an account. From 714a7f2bf634e3febfafc0012a4c8674ba353d79 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 3 Mar 2026 08:54:32 -0500 Subject: [PATCH 21/21] contact us styling center --- src/app/screens/ContactUs.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/screens/ContactUs.tsx b/src/app/screens/ContactUs.tsx index 9a9317c8..6b4e2481 100644 --- a/src/app/screens/ContactUs.tsx +++ b/src/app/screens/ContactUs.tsx @@ -56,6 +56,7 @@ export default function ContactUs(): React.ReactElement { mt: 2, display: 'flex', flexWrap: 'wrap', + justifyContent: 'center', }} >