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', () => { diff --git a/package.json b/package.json index d70d1141..b7cb35fb 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "redux-persist": "^6.0.0", "redux-saga": "^1.2.3", "server-only": "^0.0.1", + "swr": "^2.4.0", "yup": "^1.3.2" }, "scripts": { 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 ( 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/[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]/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]/authed/loading.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/loading.tsx new file mode 100644 index 00000000..eb1b6f77 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/loading.tsx @@ -0,0 +1,110 @@ +import { Box, Container, Skeleton } from '@mui/material'; + +/** + * Loading page streams content as it becomes available, providing a faster time-to-interactive and better user experience. + * NextJs automatically automatically detects user agents to choose between blocking and streaming behavior. + * Since streaming is server-rendered, it does not impact SEO + * ref: https://nextjs.org/docs/app/api-reference/file-conventions/loading + */ + +export default function Loading(): React.ReactElement { + return ( + + + {/* Breadcrumb skeleton */} + + + {/* Feed title skeleton */} + + + {/* Provider info skeleton */} + + + {/* Status chip skeleton */} + + + {/* Divider line */} + + + {/* Action buttons skeleton */} + + + + + + {/* Main content area skeleton */} + + {/* Left panel skeleton */} + + + {/* Right panel skeleton */} + + + + + ); +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx index 77bf1735..c0916fe8 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx @@ -1,5 +1,6 @@ import FullMapView from '../../../../../../screens/Feed/components/FullMapView'; import { type ReactElement } from 'react'; +import { notFound } from 'next/navigation'; import { fetchCompleteFeedData } from '../../lib/feed-data'; interface Props { @@ -31,7 +32,7 @@ export default async function AuthedFullMapViewPage({ const feedData = await fetchCompleteFeedData(feedDataType, feedId); if (feedData == null) { - return
    Feed not found
    ; + notFound(); } return ; diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx index 8850599d..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,18 +29,5 @@ 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}; } diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/loading.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/loading.tsx new file mode 100644 index 00000000..eb1b6f77 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/loading.tsx @@ -0,0 +1,110 @@ +import { Box, Container, Skeleton } from '@mui/material'; + +/** + * Loading page streams content as it becomes available, providing a faster time-to-interactive and better user experience. + * NextJs automatically automatically detects user agents to choose between blocking and streaming behavior. + * Since streaming is server-rendered, it does not impact SEO + * ref: https://nextjs.org/docs/app/api-reference/file-conventions/loading + */ + +export default function Loading(): React.ReactElement { + return ( + + + {/* Breadcrumb skeleton */} + + + {/* Feed title skeleton */} + + + {/* Provider info skeleton */} + + + {/* Status chip skeleton */} + + + {/* Divider line */} + + + {/* Action buttons skeleton */} + + + + + + {/* Main content area skeleton */} + + {/* Left panel skeleton */} + + + {/* Right panel skeleton */} + + + + + ); +} diff --git a/src/app/screens/Feeds/index.tsx b/src/app/[locale]/feeds/components/FeedsScreen.tsx similarity index 57% rename from src/app/screens/Feeds/index.tsx rename to src/app/[locale]/feeds/components/FeedsScreen.tsx index 316b5cf4..46cfff56 100644 --- a/src/app/screens/Feeds/index.tsx +++ b/src/app/[locale]/feeds/components/FeedsScreen.tsx @@ -1,7 +1,6 @@ 'use client'; import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useCallback, useRef, useEffect, useState } from 'react'; import { Box, Button, @@ -10,6 +9,7 @@ import { CssBaseline, Grid, InputAdornment, + LinearProgress, Pagination, Skeleton, TableContainer, @@ -20,273 +20,126 @@ import { useTheme, } from '@mui/material'; import { Search } from '@mui/icons-material'; -import { selectUserProfile } from '../../store/profile-selectors'; -import { useAppDispatch } from '../../hooks'; -import { loadingFeeds } from '../../store/feeds-reducer'; -import { - selectFeedsData, - selectFeedsStatus, -} from '../../store/feeds-selectors'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; -import SearchTable from './SearchTable'; +import SearchTable from '../../../screens/Feeds/SearchTable'; import { useTranslations } from 'next-intl'; -import { - getDataTypeParamFromSelectedFeedTypes, - getInitialSelectedFeedTypes, -} from './utility'; import { chipHolderStyles, searchBarStyles, stickyHeaderStyles, -} from './Feeds.styles'; -import { ColoredContainer } from '../../styles/PageLayout.style'; -import AdvancedSearchTable from './AdvancedSearchTable'; +} from '../../../screens/Feeds/Feeds.styles'; +import { ColoredContainer } from '../../../styles/PageLayout.style'; +import AdvancedSearchTable from '../../../screens/Feeds/AdvancedSearchTable'; import ViewHeadlineIcon from '@mui/icons-material/ViewHeadline'; import GridViewIcon from '@mui/icons-material/GridView'; -import { SearchFilters } from './SearchFilters'; +import { SearchFilters } from '../../../screens/Feeds/SearchFilters'; +import { + useFeedsSearch, + deriveSearchParams, + deriveFilterFlags, + buildSearchUrl, +} from '../lib/useFeedsSearch'; -export default function Feed(): React.ReactElement { +export default function FeedsScreen(): React.ReactElement { const theme = useTheme(); const t = useTranslations('feeds'); const tCommon = useTranslations('common'); const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); - const [searchLimit] = useState(20); // leaving possibility to edit in future - const [selectedFeedTypes, setSelectedFeedTypes] = useState( - getInitialSelectedFeedTypes(searchParams), - ); - const [isSticky, setIsSticky] = useState(false); - const [activeSearch, setActiveSearch] = useState(searchParams.get('q') ?? ''); - const [isOfficialFeedSearch, setIsOfficialFeedSearch] = useState( - Boolean(searchParams.get('official')) ?? false, - ); - const [searchQuery, setSearchQuery] = useState(activeSearch); - const [selectedFeatures, setSelectedFeatures] = useState( - searchParams.get('features')?.split(',') ?? [], - ); - const [selectGbfsVersions, setSelectGbfsVersions] = useState( - searchParams.get('gbfs_versions')?.split(',') ?? [], - ); - const [selectedLicenses, setSelectedLicenses] = useState( - searchParams.get('licenses')?.split(',') ?? [], - ); - const [activePagination, setActivePagination] = useState( - searchParams.get('o') !== null ? Number(searchParams.get('o')) : 1, - ); + // Derive all state from URL - single source of truth + const derivedPageState = deriveSearchParams(searchParams); + const { + searchQuery: activeSearch, + page: activePagination, + feedTypes: selectedFeedTypes, + isOfficial: isOfficialFeedSearch, + features: selectedFeatures, + gbfsVersions: selectedGbfsVersions, + licenses: selectedLicenses, + hasTransitFeedsRedirect: hasTransitFeedsRedirectParam, + } = derivedPageState; + + const { + isOfficialTagFilterEnabled, + areFeatureFiltersEnabled, + areGBFSFiltersEnabled, + } = deriveFilterFlags(selectedFeedTypes); + + // SWR-powered data fetching - keyed off URL params + const { feedsData, isLoading, isValidating, isError, searchLimit } = + useFeedsSearch(searchParams); + + // Local state only for the text input (not committed to URL until submit) + const [searchInputValue, setSearchInputValue] = useState(activeSearch); const [searchView, setSearchView] = useState<'simple' | 'advanced'>( 'advanced', ); - const user = useSelector(selectUserProfile); - const dispatch = useAppDispatch(); - const feedsData = useSelector(selectFeedsData); - const feedStatus = useSelector(selectFeedsStatus); - - const hasTransitFeedsRedirectParam = - searchParams.get('utm_source') === 'transitfeeds'; - - // features i/o - const areNoDataTypesSelected = - !selectedFeedTypes.gtfs && - !selectedFeedTypes.gtfs_rt && - !selectedFeedTypes.gbfs; - const isOfficialTagFilterEnabled = - selectedFeedTypes.gtfs || - selectedFeedTypes.gtfs_rt || - areNoDataTypesSelected; - const areFeatureFiltersEnabled = - (!selectedFeedTypes.gtfs_rt && !selectedFeedTypes.gbfs) || - selectedFeedTypes.gtfs; - const areGBFSFiltersEnabled = - selectedFeedTypes.gbfs && - !selectedFeedTypes.gtfs_rt && - !selectedFeedTypes.gtfs; - - const getPaginationOffset = (activePagination?: number): number => { - const paginationParam = - searchParams.get('o') !== null ? Number(searchParams.get('o')) : 1; - const pagination = activePagination ?? paginationParam; - const paginationOffset = (pagination - 1) * searchLimit; - return paginationOffset; - }; - - useEffect(() => { - if (user == null) return; - - const paginationOffset = getPaginationOffset(activePagination); - dispatch( - loadingFeeds({ - params: { - query: { - limit: searchLimit, - offset: paginationOffset, - search_query: activeSearch, - data_type: getDataTypeParamFromSelectedFeedTypes(selectedFeedTypes), - is_official: isOfficialTagFilterEnabled - ? isOfficialFeedSearch || undefined - : undefined, - // Fixed status values for now, until a status filter is implemented - // Filtering out deprecated feeds - status: ['active', 'inactive', 'development', 'future'], - feature: areFeatureFiltersEnabled ? selectedFeatures : undefined, - version: areGBFSFiltersEnabled - ? selectGbfsVersions.join(',').replaceAll('v', '') - : undefined, - license_ids: - selectedLicenses.length > 0 - ? selectedLicenses.join(',') - : undefined, - }, - }, - }), - ); - }, [ - user, - activeSearch, - activePagination, - selectedFeedTypes, - searchLimit, - isOfficialFeedSearch, - selectedFeatures, - selectGbfsVersions, - selectedLicenses, - ]); - - useEffect(() => { - const newSearchParams = new URLSearchParams(); - if (activeSearch !== '') { - newSearchParams.set('q', activeSearch); - } - - if (activePagination !== 1) { - newSearchParams.set('o', activePagination.toString()); - } - if (selectedFeedTypes.gtfs) { - newSearchParams.set('gtfs', 'true'); - } - if (selectedFeedTypes.gtfs_rt) { - newSearchParams.set('gtfs_rt', 'true'); - } - if (selectedFeedTypes.gbfs) { - newSearchParams.set('gbfs', 'true'); - } - if (selectedFeatures.length > 0) { - newSearchParams.set('features', selectedFeatures.join(',')); - } - if (selectGbfsVersions.length > 0) { - newSearchParams.set('gbfs_versions', selectGbfsVersions.join(',')); - } - if (selectedLicenses.length > 0) { - newSearchParams.set('licenses', selectedLicenses.join(',')); - } - if (isOfficialFeedSearch) { - newSearchParams.set('official', 'true'); - } - if (searchParams.get('utm_source') === 'transitfeeds') { - newSearchParams.set('utm_source', 'transitfeeds'); - } - if (searchParams.toString() !== newSearchParams.toString()) { - const queryString = newSearchParams.toString(); - router.push( - `${pathname}${queryString.length > 0 ? `?${queryString}` : ''}`, - ); - } - }, [ - activeSearch, - activePagination, - selectedFeedTypes, - selectedFeatures, - selectGbfsVersions, - selectedLicenses, - isOfficialFeedSearch, - ]); + const [isSticky, setIsSticky] = useState(false); - // When url updates, it will update the state of the search page - // This is to ensure that the search page is in sync with the url + // Keep the text input in sync when URL changes (e.g. browser back) useEffect(() => { - const newQuery = searchParams.get('q') ?? ''; - if (newQuery !== searchQuery) { - setSearchQuery(newQuery); - setActiveSearch(newQuery); - } - const newOffset = - searchParams.get('o') !== null ? Number(searchParams.get('o')) : 1; - if (newOffset !== activePagination) { - setActivePagination(newOffset); - } - - const newFeatures = searchParams.get('features')?.split(',') ?? []; - if (newFeatures.join(',') !== selectedFeatures.join(',')) { - setSelectedFeatures([...newFeatures]); - } - - const newGbfsVersions = searchParams.get('gbfs_versions')?.split(',') ?? []; - if (newGbfsVersions.join(',') !== selectGbfsVersions.join(',')) { - setSelectGbfsVersions([...newGbfsVersions]); - } - - const newLicenses = searchParams.get('licenses')?.split(',') ?? []; - if (newLicenses.join(',') !== selectedLicenses.join(',')) { - setSelectedLicenses([...newLicenses]); - } - - const newSearchOfficial = Boolean(searchParams.get('official')) ?? false; - if (newSearchOfficial !== isOfficialFeedSearch) { - setIsOfficialFeedSearch(newSearchOfficial); - } - - const newFeedTypes = getInitialSelectedFeedTypes(searchParams); - if (newFeedTypes.gtfs !== selectedFeedTypes.gtfs) { - setSelectedFeedTypes({ - ...selectedFeedTypes, - gtfs: newFeedTypes.gtfs, - }); - } - - if (newFeedTypes.gtfs_rt !== selectedFeedTypes.gtfs_rt) { - setSelectedFeedTypes({ - ...selectedFeedTypes, - gtfs_rt: newFeedTypes.gtfs_rt, - }); - } - - if (newFeedTypes.gbfs !== selectedFeedTypes.gbfs) { - setSelectedFeedTypes({ - ...selectedFeedTypes, - gbfs: newFeedTypes.gbfs, + setSearchInputValue(activeSearch); + }, [activeSearch]); + + // --- Navigation helper: push new URL with updated filters --- + const utmSource = searchParams.get('utm_source'); + const navigate = useCallback( + (overrides: Parameters[1]) => { + const url = buildSearchUrl(pathname, { + searchQuery: activeSearch, + page: activePagination, + feedTypes: selectedFeedTypes, + isOfficial: isOfficialFeedSearch, + features: selectedFeatures, + gbfsVersions: selectedGbfsVersions, + licenses: selectedLicenses, + utmSource, + ...overrides, }); - } - }, [searchParams]); + router.push(url); + }, + [ + pathname, + activeSearch, + activePagination, + selectedFeedTypes, + isOfficialFeedSearch, + selectedFeatures, + selectedGbfsVersions, + selectedLicenses, + utmSource, + router, + ], + ); const getSearchResultNumbers = (): string => { - if (feedsData?.total !== undefined && feedsData?.total > 0) { - const offset = getPaginationOffset(activePagination); + if (feedsData?.total !== undefined && feedsData.total > 0) { + const paginationOffset = (activePagination - 1) * searchLimit; + const offset = paginationOffset; const limit = offset + searchLimit; - const startResult = 1 + offset; - const endResult = limit > feedsData?.total ? feedsData.total : limit; - const totalResults = feedsData?.total ?? ''; + const endResult = limit > feedsData.total ? feedsData.total : limit; + const totalResults = feedsData.total ?? ''; return t('resultsFor', { startResult, endResult, totalResults }); - } else { - return ''; } + return ''; }; function clearAllFilters(): void { - setActivePagination(1); - setSelectedFeedTypes({ - gtfs: false, - gtfs_rt: false, - gbfs: false, + navigate({ + page: 1, + feedTypes: { gtfs: false, gtfs_rt: false, gbfs: false }, + features: [], + gbfsVersions: [], + licenses: [], + isOfficial: false, }); - setSelectedFeatures([]); - setSelectGbfsVersions([]); - setSelectedLicenses([]); - setIsOfficialFeedSearch(false); } - const containerRef = React.useRef(null); + // --- Sticky header observer --- + const containerRef = useRef(null); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { @@ -305,7 +158,7 @@ export default function Feed(): React.ReactElement { }, []); const handleViewChange = ( - event: React.MouseEvent, + _event: React.MouseEvent, newSearchView: 'simple' | 'advanced' | null, ): void => { if (newSearchView != null) { @@ -319,6 +172,7 @@ export default function Feed(): React.ReactElement { maxWidth={false} sx={{ overflowX: 'initial', + position: 'relative', }} > @@ -355,8 +209,10 @@ export default function Feed(): React.ReactElement { component='form' onSubmit={(event) => { event.preventDefault(); - setActivePagination(1); - setActiveSearch(searchQuery.trim()); + navigate({ + searchQuery: searchInputValue.trim(), + page: 1, + }); }} sx={searchBarStyles} > @@ -364,10 +220,10 @@ export default function Feed(): React.ReactElement { sx={{ width: 'calc(100% - 85px)', }} - value={searchQuery} + value={searchInputValue} placeholder={t('searchPlaceholder')} onChange={(e) => { - setSearchQuery(e.target.value); + setSearchInputValue(e.target.value); }} InputProps={{ startAdornment: ( @@ -376,8 +232,10 @@ export default function Feed(): React.ReactElement { cursor: 'pointer', }} onClick={() => { - setActivePagination(1); - setActiveSearch(searchQuery.trim()); + navigate({ + searchQuery: searchInputValue.trim(), + page: 1, + }); }} position='start' > @@ -395,8 +253,19 @@ export default function Feed(): 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/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'); +} diff --git a/src/app/[locale]/feeds/lib/useFeedsSearch.ts b/src/app/[locale]/feeds/lib/useFeedsSearch.ts new file mode 100644 index 00000000..541924c7 --- /dev/null +++ b/src/app/[locale]/feeds/lib/useFeedsSearch.ts @@ -0,0 +1,290 @@ +'use client'; +import { useEffect, useState } from 'react'; +import useSWR, { 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 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, + 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 != 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 === true) { + params.set('gtfs', 'true'); + } + if (filters.feedTypes?.gtfs_rt === true) { + params.set('gtfs_rt', 'true'); + } + if (filters.feedTypes?.gbfs === true) { + params.set('gbfs', 'true'); + } + if (filters.features != null && filters.features.length > 0) { + params.set('features', filters.features.join(',')); + } + if (filters.gbfsVersions != null && filters.gbfsVersions.length > 0) { + params.set('gbfs_versions', filters.gbfsVersions.join(',')); + } + if (filters.licenses != null && filters.licenses.length > 0) { + params.set('licenses', filters.licenses.join(',')); + } + if (filters.isOfficial === true) { + params.set('official', 'true'); + } + if (filters.utmSource != null && 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/[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/components/HeaderMobileDrawer.tsx b/src/app/components/HeaderMobileDrawer.tsx index 3eabc7ef..ece73a0a 100644 --- a/src/app/components/HeaderMobileDrawer.tsx +++ b/src/app/components/HeaderMobileDrawer.tsx @@ -27,7 +27,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { useRemoteConfig } from '../context/RemoteConfigProvider'; import { useTranslations } from 'next-intl'; -const websiteTile = 'Mobility Database'; +const websiteTile = 'MobilityDatabase'; interface DrawerContentProps { isAuthenticated: boolean; @@ -50,27 +50,43 @@ export default function DrawerContent({ return ( { router.push('/'); }} > - - - - MobilityData logo - + {theme.palette.mode === 'light' ? ( + + + MobilityData logo + + ) : ( + + + MobilityData logo + + )} + ))} - + {config.gbfsValidator && ( { const router = useRouter(); @@ -92,30 +84,8 @@ export const AppRouter: React.FC = () => { } /> } /> - } /> - } /> - } /> - - - - } - /> - } - /> - } - /> } /> } /> - } /> - } /> - } /> } /> } /> diff --git a/src/app/screens/ContactUs.tsx b/src/app/screens/ContactUs.tsx index 0a01b6c4..6b4e2481 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'; @@ -54,6 +56,7 @@ export default function ContactUs(): React.ReactElement { mt: 2, display: 'flex', flexWrap: 'wrap', + justifyContent: 'center', }} > @@ -107,7 +110,7 @@ export default function ContactUs(): React.ReactElement { @@ -70,6 +74,7 @@ export default function FAQ(): React.ReactElement { variant='text' className='line-start inline' href={'/sign-up'} + component={Link} > create an account. diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index a42c8b57..a5d36684 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 && ( endpoint.name === 'gbfs', )?.url; return ( - @@ -311,7 +309,7 @@ export default function GbfsVersions({ )} - + ); })}
    diff --git a/src/app/screens/Feed/components/ScrollToTop.tsx b/src/app/screens/Feed/components/ScrollToTop.tsx new file mode 100644 index 00000000..925e23e8 --- /dev/null +++ b/src/app/screens/Feed/components/ScrollToTop.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { useEffect } from 'react'; + +/** + * Scrolls the window to the top on mount. + * Fixes the case where navigating from a scrolled search results page + * leaves the feed detail page rendered at a non-zero scroll position. + */ +export default function ScrollToTop(): null { + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'instant' }); + }, []); + + return null; +} 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/Feeds/AdvancedSearchTable.tsx b/src/app/screens/Feeds/AdvancedSearchTable.tsx index 37cce108..623d489d 100644 --- a/src/app/screens/Feeds/AdvancedSearchTable.tsx +++ b/src/app/screens/Feeds/AdvancedSearchTable.tsx @@ -3,7 +3,9 @@ import { Card, CardActionArea, Chip, + CircularProgress, type SxProps, + type Theme, Tooltip, Typography, useTheme, @@ -24,12 +26,14 @@ import OfficialChip from '../../components/OfficialChip'; import { getFeatureComponentDecorators } from '../../utils/consts'; import PopoverList from './PopoverList'; import ProviderTitle from './ProviderTitle'; -import Link from 'next/link'; +import NextLinkComposed from 'next/link'; +import { useRouter } from '../../../i18n/navigation'; export interface AdvancedSearchTableProps { feedsData: AllFeedsType | undefined; selectedFeatures: string[] | undefined; selectedGbfsVersions: string[] | undefined; + isLoadingFeeds: boolean; } interface DetailsContainerProps { @@ -72,8 +76,8 @@ const DetailsContainer = ({ const renderGTFSDetails = ( gtfsFeed: SearchFeedItem, selectedFeatures: string[], + theme: Theme, ): React.ReactElement => { - const theme = useTheme(); const feedFeatures = gtfsFeed?.latest_dataset?.validation_report?.features ?? []; return ( @@ -133,8 +137,8 @@ const renderGTFSRTDetails = ( const renderGBFSDetails = ( gbfsFeedSearchElement: SearchFeedItem, selectedGbfsVersions: string[], + theme: Theme, ): React.ReactElement => { - const theme = useTheme(); return ( {gbfsFeedSearchElement.versions?.map((version: string, index: number) => ( @@ -160,6 +164,7 @@ export default function AdvancedSearchTable({ feedsData, selectedFeatures, selectedGbfsVersions, + isLoadingFeeds, }: AdvancedSearchTableProps): React.ReactElement { const t = useTranslations('feeds'); const tCommon = useTranslations('common'); @@ -168,8 +173,25 @@ export default function AdvancedSearchTable({ undefined, ); 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}`, @@ -181,6 +203,21 @@ export default function AdvancedSearchTable({ return ( <> + {showLoading && ( + + + + )} {feedsData?.results?.map((feed, index) => { if (feed == null) { return <>; @@ -201,9 +238,21 @@ export default function AdvancedSearchTable({ }} > { + // 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}`); + }); + }} > - {renderGTFSDetails(feed, selectedFeatures ?? [])} + {renderGTFSDetails(feed, selectedFeatures ?? [], theme)} )} {feed.data_type === 'gtfs_rt' && ( @@ -352,7 +401,7 @@ export default function AdvancedSearchTable({ {feed.data_type === 'gbfs' && ( - {renderGBFSDetails(feed, selectedGbfsVersions ?? [])} + {renderGBFSDetails(feed, selectedGbfsVersions ?? [], theme)} )}
    diff --git a/src/app/screens/Feeds/FeedsScreenSkeleton.tsx b/src/app/screens/Feeds/FeedsScreenSkeleton.tsx new file mode 100644 index 00000000..73b04c0a --- /dev/null +++ b/src/app/screens/Feeds/FeedsScreenSkeleton.tsx @@ -0,0 +1,109 @@ +'use client'; +import * as React from 'react'; +import { Box, Container, Grid, Skeleton } from '@mui/material'; +import { ColoredContainer } from '../../styles/PageLayout.style'; + +/** + * Loading skeleton that mirrors the FeedsScreen layout. + * Used as the Suspense fallback in the /feeds page. + */ +export default function FeedsScreenSkeleton(): React.ReactElement { + return ( + + {/* Page title */} + + + + + {/* Search bar */} + + + + + + + + + + {/* Filter sidebar */} + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + {/* Results area */} + + {/* Active-filter chips row */} + + {/* Result count + view toggle */} + + {/* Table body */} + + {/* Pagination */} + + + + + + ); +} 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: [ diff --git a/src/app/screens/Feeds/SearchTable.tsx b/src/app/screens/Feeds/SearchTable.tsx index 2d2b83ce..5564bc9c 100644 --- a/src/app/screens/Feeds/SearchTable.tsx +++ b/src/app/screens/Feeds/SearchTable.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Box, Chip, + CircularProgress, Popper, Table, TableBody, @@ -20,7 +21,8 @@ import { } from '../../services/feeds/utils'; import { useTranslations } from 'next-intl'; import GtfsRtEntities from './GtfsRtEntities'; -import Link from 'next/link'; +import NextLinkComposed from 'next/link'; +import { useRouter } from '../../../i18n/navigation'; import { getEmojiFlag, type TCountryCode } from 'countries-list'; import OfficialChip from '../../components/OfficialChip'; import ProviderTitle from './ProviderTitle'; @@ -69,7 +71,23 @@ export default function SearchTable({ feedsData, }: SearchTableProps): React.ReactElement { const theme = useTheme(); + const router = useRouter(); + const [isPending, startTransition] = React.useTransition(); + const [showLoading, setShowLoading] = React.useState(false); const [anchorEl, setAnchorEl] = React.useState(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); @@ -78,202 +96,233 @@ export default function SearchTable({ // Reason for all component overrite is for SEO purposes. return ( - - - - - {t('transitProvider')} - - {t('locations')} - - {t('feedDescription')} - - {t('dataType')} - - - + {showLoading && ( + + + + )} +
    - {feedsData?.results?.map((feed) => ( - - - - { - 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, + })} + +
    • + )} +
    +
    + + )} + + ); } 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'; 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()); } } diff --git a/src/app/styles/PageLayout.style.ts b/src/app/styles/PageLayout.style.ts index 029445ac..357e8dce 100644 --- a/src/app/styles/PageLayout.style.ts +++ b/src/app/styles/PageLayout.style.ts @@ -5,4 +5,5 @@ export const ColoredContainer = styled(Container)(({ theme }) => ({ borderRadius: '6px', paddingTop: theme.spacing(3), paddingBottom: theme.spacing(3), + position: 'relative', })); 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"