diff --git a/web-app/src/app/Theme.ts b/web-app/src/app/Theme.ts index aa7f60166..9415939b3 100644 --- a/web-app/src/app/Theme.ts +++ b/web-app/src/app/Theme.ts @@ -27,6 +27,10 @@ declare module '@mui/material/styles' { routeTextColor?: string; }; } + + interface TypeText { + lightContrast?: string; + } } declare module '@mui/material/Typography' { @@ -62,10 +66,10 @@ const palette = { contrastText: '#f9faff', }, secondary: { - main: '#96a1ff', // original mobility data purple - dark: '#4a5fe8', - light: '#e7e8ff', - contrastText: '#f9faff', + main: '#5E56F7', + dark: '#2B1EB8', + light: '#D7D4FF', + contrastText: '#FFFFFF', }, background: { default: '#ffffff', @@ -75,6 +79,7 @@ const palette = { primary: '#474747', secondary: 'rgba(71, 71, 71, 0.8)', disabled: 'rgba(0,0,0,0.3)', + lightContrast: '#1D1717', }, divider: 'rgba(0, 0, 0, 0.23)', boxShadow: '0px 1px 4px 2px rgba(0,0,0,0.2)', @@ -88,10 +93,10 @@ const darkPalette = { contrastText: '#1D1717', }, secondary: { - main: '#3959fa', - dark: '#002eea', - light: '#989ffc', - contrastText: '#ffffff', + light: '#C4CCFF', + main: '#5E6DD9', + dark: '#3846A6', + contrastText: '#FFFFFF', }, background: { default: '#121212', @@ -101,6 +106,7 @@ const darkPalette = { primary: '#E3E3E3', secondary: 'rgba(255, 255, 255, 0.7)', disabled: 'rgba(255, 255, 255, 0.3)', + lightContrast: '#1D1717', }, divider: 'rgba(255, 255, 255, 0.23)', boxShadow: '0px 1px 4px 2px rgba(0,0,0,0.6)', diff --git a/web-app/src/app/components/CoveredAreaMap.tsx b/web-app/src/app/components/CoveredAreaMap.tsx index d3016b81e..92c9aa3de 100644 --- a/web-app/src/app/components/CoveredAreaMap.tsx +++ b/web-app/src/app/components/CoveredAreaMap.tsx @@ -13,7 +13,6 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { Link } from 'react-router-dom'; import MapIcon from '@mui/icons-material/Map'; import TravelExploreIcon from '@mui/icons-material/TravelExplore'; -import { ContentBox } from './ContentBox'; import { WarningContentBox } from './WarningContentBox'; import { mapBoxPositionStyle } from '../screens/Feed/Feed.styles'; import { @@ -269,44 +268,30 @@ const CoveredAreaMap: React.FC = ({ ]); return ( - - - {view === 'gtfsVisualizationView' && - config.enableGtfsVisualizationMap && ( - - )} - {feed?.data_type === 'gbfs' ? ( + + + {t('coveredAreaTitle') + ' - ' + t(view)} + + {feed?.data_type === 'gbfs' && ( {latestAutodiscoveryUrl != undefined && ( + )} + + {config.enableGtfsVisualizationMap && ( + + + + + + )} + {config.enableDetailedCoveredArea && ( + + + + + + )} + - + - )} - - - - - - + + )} {(feed?.data_type === 'gtfs' || feed?.data_type === 'gbfs') && @@ -402,7 +406,7 @@ const CoveredAreaMap: React.FC = ({ )} )} - + ); }; diff --git a/web-app/src/app/components/FeedStatus.tsx b/web-app/src/app/components/FeedStatus.tsx index 46266bf3e..60519c070 100644 --- a/web-app/src/app/components/FeedStatus.tsx +++ b/web-app/src/app/components/FeedStatus.tsx @@ -1,50 +1,11 @@ import { Box, Chip, Tooltip, useTheme } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { type TFunction } from 'i18next'; +import { getFeedStatusData } from '../utils/feedStatusConsts'; export interface FeedStatusProps { status: string; -} - -interface FeedStatusData { - toolTip: string; - label: string; - themeColor: 'success' | 'info' | 'warning' | 'error'; - toolTipLong: string; -} - -function getFeedStatusData( - status: string, - t: TFunction, -): FeedStatusData | undefined { - const data: Record = { - active: { - toolTip: t('feedStatus.active.toolTip'), - label: t('feedStatus.active.label'), - themeColor: 'success', - toolTipLong: t('feedStatus.active.toolTipLong'), - }, - future: { - toolTip: t('feedStatus.future.toolTip'), - label: t('feedStatus.future.label'), - themeColor: 'info', - toolTipLong: t('feedStatus.future.toolTipLong'), - }, - inactive: { - toolTip: t('feedStatus.inactive.toolTip'), - label: t('feedStatus.inactive.label'), - themeColor: 'warning', - toolTipLong: t('feedStatus.inactive.toolTipLong'), - }, - deprecated: { - toolTip: t('feedStatus.deprecated.toolTip'), - label: t('feedStatus.deprecated.label'), - themeColor: 'error', - toolTipLong: t('feedStatus.deprecated.toolTipLong'), - }, - }; - - return data[status]; + chipSize?: 'small' | 'medium'; + disableTooltip?: boolean; } export const FeedStatusIndicator = ( @@ -52,7 +13,7 @@ export const FeedStatusIndicator = ( ): JSX.Element => { const { t } = useTranslation('feeds'); const theme = useTheme(); - const statusData = getFeedStatusData(props.status, t); + const statusData = getFeedStatusData(props.status, theme, t); return ( <> {statusData != undefined && ( @@ -76,12 +37,24 @@ export const FeedStatusChip = ( props: React.PropsWithChildren, ): JSX.Element => { const { t } = useTranslation('feeds'); - const statusData = getFeedStatusData(props.status, t); + const theme = useTheme(); + const statusData = getFeedStatusData(props.status, theme, t); return ( <> {statusData != undefined && ( - - + + )} diff --git a/web-app/src/app/components/Locations.tsx b/web-app/src/app/components/Locations.tsx index 37448ea9b..377377b2c 100644 --- a/web-app/src/app/components/Locations.tsx +++ b/web-app/src/app/components/Locations.tsx @@ -7,16 +7,24 @@ import { } from 'material-react-table'; import { Box, Tabs, Tab, Typography, Chip, Tooltip } from '@mui/material'; import { getEmojiFlag, type TCountryCode } from 'countries-list'; -import { type EntityLocations, getLocationName } from '../services/feeds/utils'; +import { + type EntityLocations, + getCountryLocationSummaries, + getLocationName, +} from '../services/feeds/utils'; export interface LocationTableProps { locations: EntityLocations; + startingTab?: 'summary' | 'fullList'; } export default function Locations({ locations, + startingTab = 'summary', }: LocationTableProps): React.ReactElement { - const [activeTab, setActiveTab] = useState(0); + const [activeTab, setActiveTab] = useState( + startingTab === 'fullList' ? 1 : 0, + ); const [tableFilters, setTableFilters] = useState([]); const [showFilters, setShowFilters] = useState(false); @@ -34,12 +42,8 @@ export default function Locations({ ); const uniqueCountries = useMemo(() => { - const countriesSet = new Set(); - tableData.forEach((loc) => { - countriesSet.add(loc.country_code); // Use raw country code - }); - return Array.from(countriesSet); - }, [tableData]); + return getCountryLocationSummaries(locations ?? []); + }, [locations]); const columns = useMemo>>( () => [ @@ -103,11 +107,11 @@ export default function Locations({ overscan: 10, }, renderBottomToolbar: () => '', - muiTableContainerProps: { sx: { maxHeight: '250px' } }, + muiTableContainerProps: { sx: { maxHeight: '600px' } }, }); return ( - + { @@ -119,22 +123,18 @@ export default function Locations({ {activeTab === 0 && ( - + {locations.length === 1 ? ( {getLocationName(locations)} ) : ( - {uniqueCountries.map((countryCode) => { + {uniqueCountries.map((country) => { const subdivisions = new Set(); const municipalities = new Set(); - const countryName = tableData.find( - (loc) => loc.country_code === countryCode, - )?.country; - tableData - .filter((loc) => loc.country_code === countryCode) + .filter((loc) => loc.country_code === country.country_code) .forEach((loc) => { subdivisions.add(loc.subdivision); municipalities.add(loc.municipality); @@ -143,13 +143,15 @@ export default function Locations({ const tooltipText = `${subdivisions.size} subdivisions and ${municipalities.size} municipalities within this country.\nClick for more details.`; return ( - + { setActiveTab(1); setTableFilters([ - { id: 'country', value: countryName ?? '' }, + { id: 'country', value: country.country ?? '' }, ]); setShowFilters(true); }} @@ -164,7 +166,7 @@ export default function Locations({ )} {activeTab === 1 && ( - + )} diff --git a/web-app/src/app/screens/Feed/Feed.styles.ts b/web-app/src/app/screens/Feed/Feed.styles.ts index 139b6cdfc..18d264ca3 100644 --- a/web-app/src/app/screens/Feed/Feed.styles.ts +++ b/web-app/src/app/screens/Feed/Feed.styles.ts @@ -11,7 +11,7 @@ export const feedDetailContentContainerStyle = (props: { xs: props.isGtfsRT ? 'column' : 'column-reverse', md: props.isGtfsRT ? 'row' : 'row-reverse', }, - gap: 3, + gap: 2, flexWrap: 'nowrap', justifyContent: 'space-between', mb: 4, @@ -22,6 +22,7 @@ export const ctaContainerStyle: SxProps = (theme) => ({ my: 3, width: '100%', display: 'flex', + flexWrap: { xs: 'wrap', sm: 'nowrap' }, gap: 1, borderTop: `1px solid ${theme.palette.divider}`, pt: 3, @@ -36,13 +37,17 @@ export const featureChipsStyle: SxProps = (theme) => ({ }, }); -export const mapBoxPositionStyle = { +export const mapBoxPositionStyle: SxProps = (theme) => ({ position: 'relative', width: 'calc(100% + 32px)', + height: '400px', flexGrow: 1, mb: '-16px', mx: '-16px', -}; + [theme.breakpoints.up('md')]: { + height: 'auto', + }, +}); export const boxElementStyle: SxProps = { width: '100%', @@ -50,17 +55,6 @@ export const boxElementStyle: SxProps = { mb: 1, }; -export const boxElementStyleTransitProvider: SxProps = { - width: '100%', - mt: 2, - borderBottom: 'none', -}; - -export const boxElementStyleProducerURL: SxProps = { - width: '100%', - mb: 1, -}; - export const StyledTitleContainer = styled(Box)(({ theme }) => ({ display: 'flex', gap: theme.spacing(1), diff --git a/web-app/src/app/screens/Feed/FeedSummary.styles.ts b/web-app/src/app/screens/Feed/FeedSummary.styles.ts new file mode 100644 index 000000000..0791a22e8 --- /dev/null +++ b/web-app/src/app/screens/Feed/FeedSummary.styles.ts @@ -0,0 +1,28 @@ +import { Box, Card, styled, Typography } from '@mui/material'; + +export const GroupCard = styled(Card)(({ theme }) => ({ + background: theme.palette.background.default, + border: 'none', + padding: theme.spacing(2), + marginBottom: theme.spacing(2), + '&:last-of-type': { + marginBottom: 0, + }, +})); + +export const GroupHeader = styled(Typography)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + marginBottom: theme.spacing(1), + alignItems: 'center', + color: theme.palette.text.secondary, +})); + +export const FeedLinkElement = styled(Box)(({ theme }) => ({ + width: 'calc(100% - 16px)', + marginLeft: '16px', + marginBottom: '16px', + '&:last-child': { + marginBottom: 0, + }, +})); diff --git a/web-app/src/app/screens/Feed/components/AssociatedFeeds.tsx b/web-app/src/app/screens/Feed/components/AssociatedFeeds.tsx index 969436501..303977e2c 100644 --- a/web-app/src/app/screens/Feed/components/AssociatedFeeds.tsx +++ b/web-app/src/app/screens/Feed/components/AssociatedFeeds.tsx @@ -122,8 +122,8 @@ export default function AssociatedGTFSRTFeeds({ {feeds === undefined && Loading...} @@ -146,7 +146,7 @@ export default function AssociatedGTFSRTFeeds({ {gtfsRtFeeds === undefined && Loading...} diff --git a/web-app/src/app/screens/Feed/components/CopyLinkElement.tsx b/web-app/src/app/screens/Feed/components/CopyLinkElement.tsx new file mode 100644 index 000000000..978aea976 --- /dev/null +++ b/web-app/src/app/screens/Feed/components/CopyLinkElement.tsx @@ -0,0 +1,121 @@ +import { type ReactElement, useState } from 'react'; +import { FeedLinkElement } from '../FeedSummary.styles'; +import { + Box, + Button, + Chip, + IconButton, + Link, + Snackbar, + Tooltip, + Typography, +} from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import DownloadIcon from '@mui/icons-material/Download'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import EmailIcon from '@mui/icons-material/Email'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; + +export interface CopyLinkElementProps { + title: string; + url: string; + linkType?: 'download' | 'external' | 'email'; + titleInfo?: string; +} + +export default function CopyLinkElement({ + title, + url, + linkType, + titleInfo, +}: CopyLinkElementProps): ReactElement { + const [snackbarOpen, setSnackbarOpen] = useState(false); + + let chipIcon: JSX.Element | undefined; + let chipLabel: string | undefined; + switch (linkType) { + case 'download': + chipIcon = ; + chipLabel = 'Download'; + break; + case 'external': + chipIcon = ; + chipLabel = 'External Link'; + break; + case 'email': + chipIcon = ; + chipLabel = 'Email'; + break; + } + const formattedUrl = linkType === 'email' ? `mailto:${url}` : url; + + return ( + + + + {titleInfo != undefined && ( + + + + )} + {chipIcon != undefined && chipLabel != undefined && ( + + )} + + + + {url} + + + { + setSnackbarOpen(true); + void navigator.clipboard.writeText(url); + }} + sx={{ svg: { width: '0.875em', height: '0.875em' } }} + size='small' + aria-label='copy link' + > + + + + { + setSnackbarOpen(false); + }} + message={'URL copied to clipboard'} + /> + + + ); +} diff --git a/web-app/src/app/screens/Feed/components/FeedSummary.tsx b/web-app/src/app/screens/Feed/components/FeedSummary.tsx deleted file mode 100644 index 13692f247..000000000 --- a/web-app/src/app/screens/Feed/components/FeedSummary.tsx +++ /dev/null @@ -1,495 +0,0 @@ -import * as React from 'react'; -import { ContentBox } from '../../../components/ContentBox'; -import { - Box, - Button, - Chip, - Grid, - Typography, - Snackbar, - IconButton, - Tooltip, - useTheme, - Link, -} from '@mui/material'; -import { ContentCopy, ContentCopyOutlined } from '@mui/icons-material'; -import { - type GTFSFeedType, - type GTFSRTFeedType, -} from '../../../services/feeds/utils'; -import { type components } from '../../../services/feeds/types'; -import { useTranslation } from 'react-i18next'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import { getFeatureComponentDecorators } from '../../../utils/consts'; -import PublicIcon from '@mui/icons-material/Public'; -import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'; -import LinkIcon from '@mui/icons-material/Link'; -import DatasetIcon from '@mui/icons-material/Dataset'; -import LayersIcon from '@mui/icons-material/Layers'; -import EmailIcon from '@mui/icons-material/Email'; -import DateRangeIcon from '@mui/icons-material/DateRange'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import AccessTimeIcon from '@mui/icons-material/AccessTime'; -import { FeedStatusIndicator } from '../../../components/FeedStatus'; -import Locations from '../../../components/Locations'; -import { formatServiceDateRange } from '../Feed.functions'; -import { - boxElementStyle, - StyledTitleContainer, - boxElementStyleTransitProvider, - ResponsiveListItem, - boxElementStyleProducerURL, - featureChipsStyle, - StyledListItem, -} from '../Feed.styles'; -import FeedAuthenticationSummaryInfo from './FeedAuthenticationSummaryInfo'; -import CommuteIcon from '@mui/icons-material/Commute'; -import { Link as RouterLink } from 'react-router-dom'; -import TravelExploreIcon from '@mui/icons-material/TravelExplore'; -import { useRemoteConfig } from '../../../context/RemoteConfigProvider'; -import { useSelector } from 'react-redux'; -import { - selectGtfsDatasetRoutesTotal, - selectGtfsDatasetRouteTypes, -} from '../../../store/selectors'; -import { getRouteTypeTranslatedName } from '../../../constants/RouteTypes'; -import ReactGA from 'react-ga4'; - -export interface FeedSummaryProps { - feed: GTFSFeedType | GTFSRTFeedType | undefined; - sortedProviders: string[]; - latestDataset?: components['schemas']['GtfsDataset'] | undefined; - width: Record; -} - -export default function FeedSummary({ - feed, - sortedProviders, - latestDataset, - width, -}: FeedSummaryProps): React.ReactElement { - const { t } = useTranslation('feeds'); - const theme = useTheme(); - const [snackbarOpen, setSnackbarOpen] = React.useState(false); - const [showAllProviders, setShowAllProviders] = React.useState(false); - const providersToDisplay = showAllProviders - ? sortedProviders - : sortedProviders.slice(0, 4); - const { config } = useRemoteConfig(); - const totalRoutes = useSelector(selectGtfsDatasetRoutesTotal); - const routeTypes = useSelector(selectGtfsDatasetRouteTypes); - - const handleOpenDetailedMapClick = (): void => { - ReactGA.event({ - category: 'engagement', - action: 'gtfs_visualization_open_detailed_map', - label: 'Open Detailed Map', - }); - }; - - return ( - - - - - - {t('locations')} - - - - {feed?.locations != null && } - - - - - - - {t('transitProvider')} - - - -
    - {providersToDisplay.map((provider) => - providersToDisplay.length <= 1 ? ( - {provider} - ) : ( - - {provider} - - ), - )} -
- - {!showAllProviders && sortedProviders.length > 4 && ( - - )} - - {showAllProviders && ( - - )} -
-
- {feed?.data_type === 'gtfs' && config.enableGtfsVisualizationMap && ( - - - {/* TODO: get back to this */} - - - Number of Routes - - - - {totalRoutes ?? '---'} - - - - - - - - )} - {feed?.data_type === 'gtfs' && config.enableGtfsVisualizationMap && ( - - - {/* TODO: get back to this */} - - - Type of Routes - - - - {routeTypes != null && routeTypes.length > 0 - ? routeTypes - .map((routeType) => getRouteTypeTranslatedName(routeType, t)) - .join(', ') - : '---'} - - - - - - - - )} - {latestDataset?.agency_timezone != undefined && ( - - - - - Agency Timezone - - - - {latestDataset.agency_timezone} - - - )} - {latestDataset?.service_date_range_start != undefined && - latestDataset.service_date_range_end != undefined && ( - - - - - {t('serviceDateRange')} - - - - - - - - - {formatServiceDateRange( - latestDataset?.service_date_range_start, - latestDataset?.service_date_range_end, - latestDataset.agency_timezone, - )} - - - - )} - - - - - {t('producerDownloadUrl')} - - - - - {feed?.source_info?.producer_url != undefined && ( - - )} - { - if (feed?.source_info?.producer_url !== undefined) { - setSnackbarOpen(true); - void navigator.clipboard - .writeText(feed?.source_info?.producer_url) - .then((value) => {}); - } - }} - /> - - { - setSnackbarOpen(false); - }} - message={t('producerUrlCopied')} - /> - - - - - - - - {t('dataType')} - - - - {feed?.data_type === 'gtfs' && t('common:gtfsSchedule')} - {feed?.data_type === 'gtfs_rt' && t('common:gtfsRealtime')} - - - - - - {feed?.data_type === 'gtfs' && - feed?.feed_contact_email != undefined && - feed?.feed_contact_email.length > 0 && ( - - - - - {t('feedContactEmail')} - - - {feed?.feed_contact_email != undefined && - feed?.feed_contact_email.length > 0 && ( - - )} - - )} - {feed?.related_links != null && feed.related_links?.length > 0 && ( - - - - - {t('relatedLinks')} - - - - -
    - {feed.related_links.map((relatedLink) => { - const url = relatedLink.url; - const code = relatedLink.code ?? ''; - const description = relatedLink.description; - - return ( -
  • - - - - {description != null && ( - - - - - - )} - - - - {url != null && ( - - )} - - { - if (url != null) { - setSnackbarOpen(true); - void navigator.clipboard - .writeText(url) - .then(() => {}); - } - }} - /> - - -
  • - ); - })} -
-
-
- )} - {latestDataset?.validation_report?.features != undefined && ( - - - - - {t('features')} - - - - - - - - - - {latestDataset.validation_report?.features?.map((feature) => ( - - - - ))} - - - )} -
- ); -} diff --git a/web-app/src/app/screens/Feed/components/GbfsFeedInfo.tsx b/web-app/src/app/screens/Feed/components/GbfsFeedInfo.tsx index a0490dcb7..b4a71ba49 100644 --- a/web-app/src/app/screens/Feed/components/GbfsFeedInfo.tsx +++ b/web-app/src/app/screens/Feed/components/GbfsFeedInfo.tsx @@ -73,6 +73,7 @@ export default function GbfsFeedInfo({ variant='body1' target='_blank' rel='noreferrer' + sx={{ wordWrap: 'break-word' }} > {autoDiscoveryUrl} diff --git a/web-app/src/app/screens/Feed/components/GbfsVersions.tsx b/web-app/src/app/screens/Feed/components/GbfsVersions.tsx index 6989a170b..54ec50774 100644 --- a/web-app/src/app/screens/Feed/components/GbfsVersions.tsx +++ b/web-app/src/app/screens/Feed/components/GbfsVersions.tsx @@ -130,6 +130,7 @@ export default function GbfsVersions({ mb: 1, fontWeight: 'bold', display: 'flex', + flexWrap: 'wrap', alignItems: 'center', gap: 2, }} diff --git a/web-app/src/app/screens/Feed/components/GtfsFeedSummary.tsx b/web-app/src/app/screens/Feed/components/GtfsFeedSummary.tsx new file mode 100644 index 000000000..2a339df25 --- /dev/null +++ b/web-app/src/app/screens/Feed/components/GtfsFeedSummary.tsx @@ -0,0 +1,704 @@ +import { useMemo, useState } from 'react'; +import { type components } from '../../../services/feeds/types'; +import { + getCountryLocationSummaries, + getLocationName, + type GTFSFeedType, + type GTFSRTFeedType, +} from '../../../services/feeds/utils'; +import { + Box, + Button, + Chip, + Dialog, + DialogTitle, + Grid, + IconButton, + Link, + Tooltip, + Typography, + useMediaQuery, + useTheme, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { GroupCard, GroupHeader } from '../FeedSummary.styles'; +import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; +import LinkIcon from '@mui/icons-material/Link'; +import DatasetIcon from '@mui/icons-material/Dataset'; +import LayersIcon from '@mui/icons-material/Layers'; +import EmailIcon from '@mui/icons-material/Email'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import LockIcon from '@mui/icons-material/Lock'; +import DownloadIcon from '@mui/icons-material/Download'; +import CloseIcon from '@mui/icons-material/Close'; +import BusinessIcon from '@mui/icons-material/Business'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import { FeedStatusChip } from '../../../components/FeedStatus'; +import { getEmojiFlag, type TCountryCode } from 'countries-list'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import { getFeedStatusData } from '../../../utils/feedStatusConsts'; +import { useSelector } from 'react-redux'; +import { Link as RouterLink } from 'react-router-dom'; +import ReactGA from 'react-ga4'; +import { + selectGtfsDatasetRoutesTotal, + selectGtfsDatasetRouteTypes, +} from '../../../store/supporting-files-selectors'; +import { getRouteTypeTranslatedName } from '../../../constants/RouteTypes'; +import { + featureChipsStyle, + ResponsiveListItem, + StyledListItem, +} from '../Feed.styles'; +import { getFeatureComponentDecorators } from '../../../utils/consts'; +import Locations from '../../../components/Locations'; +import CopyLinkElement from './CopyLinkElement'; +import { formatDateShort } from '../../../utils/date'; + +export interface GtfsFeedSummaryProps { + feed: GTFSFeedType | GTFSRTFeedType | undefined; + sortedProviders: string[]; + latestDataset?: components['schemas']['GtfsDataset'] | undefined; +} + +export default function GtfsFeedSummary({ + feed, + sortedProviders, + latestDataset, +}: GtfsFeedSummaryProps): React.ReactElement { + const { t } = useTranslation('feeds'); + const theme = useTheme(); + const [openLocationDetails, setOpenLocationDetails] = useState< + 'summary' | 'fullList' | undefined + >(undefined); + const [openProvidersDetails, setOpenProvidersDetails] = useState(false); + const [showAllFeatures, setShowAllFeatures] = useState(false); + + const fullScreen = useMediaQuery(theme.breakpoints.down('md')); + const totalRoutes = useSelector(selectGtfsDatasetRoutesTotal); + const routeTypes = useSelector(selectGtfsDatasetRouteTypes); + + const uniqueCountries = useMemo(() => { + return getCountryLocationSummaries(feed?.locations ?? []).map( + (country) => + `${getEmojiFlag(country.country_code as TCountryCode)} ${ + country.country ?? '' + }`, + ); + }, [feed]); + + const handleOpenDetailedMapClick = (): void => { + ReactGA.event({ + category: 'engagement', + action: 'gtfs_visualization_open_detailed_map', + label: 'Open Detailed Map', + }); + }; + + const hasRelatedLinks = (): boolean => { + const hasLicenseUrl = + feed?.source_info?.license_url != undefined && + feed?.source_info?.license_url !== ''; + const hasOtherLinks = + feed?.related_links != null && feed.related_links?.length > 0; + return hasLicenseUrl || hasOtherLinks; + }; + + return ( + <> + + + + Agency + + + + {sortedProviders.length > 0 + ? sortedProviders.slice(0, 4).join(', ') + : t('noAgencyProvided')} + {sortedProviders.length > 4 && ( + + and more + + )} + + {sortedProviders.length > 4 && ( + + )} + + {latestDataset?.agency_timezone != undefined && ( + + + <> + + {latestDataset.agency_timezone} + + + + )} + + + + + + + + Feed Details + + + + + + + Locations + + + + {feed?.locations != null && feed?.locations?.length === 1 && ( + + {getLocationName(feed?.locations)} + + )} + + {feed?.locations != null && + feed?.locations?.length > 1 && + uniqueCountries.slice(0, 4).map((country, index) => ( + + {country} + {index < 3 && index !== uniqueCountries.length - 1 && ', '} + {uniqueCountries.length > 4 && index === 3 && ( + + )} + + ))} + + + {feed?.locations?.length != null && feed.locations.length > 1 && ( + + )} + + + {totalRoutes != undefined && routeTypes != undefined && ( + <> + + Routes + + + + {routeTypes?.map((routeType, index) => { + return ( + + + {getRouteTypeTranslatedName(routeType, t)} + {index < routeTypes.length - 1 ? ',' : ''} + + + ); + })} + + + {totalRoutes} routes + + + + + + )} + + {feed?.source_info?.producer_url != undefined && + feed?.source_info?.producer_url !== '' && ( + <> + + + Producer Url + + + + {feed.source_info.producer_url} + + + + + + + + )} + {feed?.feed_contact_email != null && + feed?.feed_contact_email !== '' && ( + + + Feed Contact Email + + + + )} + + + {feed?.source_info?.authentication_info_url != undefined && + feed.source_info.authentication_type !== 0 && + feed?.source_info.authentication_info_url.trim() !== '' && ( + + + + Feed Authentication + + + + {feed?.source_info?.authentication_type === 1 && + t('common:apiKey')} + {feed?.source_info?.authentication_type === 2 && + t('common:httpHeader')} + + {feed?.source_info?.authentication_info_url != undefined && ( + + )} + + + )} + + {latestDataset?.service_date_range_start != undefined && + latestDataset.service_date_range_end != undefined && ( + + + + Service Date Range + + + + + + + + + + Start + + + {formatDateShort( + latestDataset.service_date_range_start, + latestDataset.agency_timezone, + )} + + + + + + {/* TODO: nice to have, a placement of the chip relative to the current date */} + {feed?.status !== undefined && feed.status === 'active' && ( + + + + )} + + + + + + + End + + + {formatDateShort( + latestDataset.service_date_range_end, + latestDataset.agency_timezone, + )} + + + + + )} + + {latestDataset?.validation_report?.features != undefined && + latestDataset?.validation_report?.features.length > 0 && ( + + + + Features + + + + + + + {(() => { + const allFeatures = + latestDataset?.validation_report?.features ?? []; + const visible = showAllFeatures + ? allFeatures + : allFeatures.slice(0, 6); + return ( + <> + + {visible.map((feature, index) => { + const featureDecorators = + getFeatureComponentDecorators(feature); + return ( + + + + + + ); + })} + {allFeatures.length > 6 && ( + + + + )} + + + ); + })()} + + )} + + {hasRelatedLinks() && ( + + + + Related Links + + + {feed?.source_info?.license_url != undefined && + feed?.source_info?.license_url !== '' && ( + + )} + + {feed?.related_links?.map((link, index) => ( + + ))} + + )} + { + setOpenLocationDetails(undefined); + }} + open={openLocationDetails !== undefined} + > + Feed Locations + { + setOpenLocationDetails(undefined); + }} + sx={() => ({ + position: 'absolute', + right: 8, + top: 8, + })} + > + + + {feed?.locations != null && ( + + )} + + + { + setOpenProvidersDetails(false); + }} + open={openProvidersDetails} + > + Agencies + { + setOpenProvidersDetails(false); + }} + sx={() => ({ + position: 'absolute', + right: 8, + top: 8, + })} + > + + + +
    + {sortedProviders.map((provider) => + sortedProviders.length <= 1 ? ( + {provider} + ) : ( + + {provider} + + ), + )} +
+
+
+ + ); +} diff --git a/web-app/src/app/screens/Feed/index.tsx b/web-app/src/app/screens/Feed/index.tsx index 8765e4630..b6ca18295 100644 --- a/web-app/src/app/screens/Feed/index.tsx +++ b/web-app/src/app/screens/Feed/index.tsx @@ -13,7 +13,6 @@ import { } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import { ChevronLeft } from '@mui/icons-material'; - import { useAppDispatch } from '../../hooks'; import { loadingFeed, loadingRelatedFeeds } from '../../store/feed-reducer'; import { @@ -37,7 +36,6 @@ import { selectLatestDatasetsData, } from '../../store/dataset-selectors'; import PreviousDatasets from './components/PreviousDatasets'; -import FeedSummary from './components/FeedSummary'; import DataQualitySummary from './components/DataQualitySummary'; import AssociatedFeeds from './components/AssociatedFeeds'; import { WarningContentBox } from '../../components/WarningContentBox'; @@ -67,6 +65,7 @@ import GbfsFeedInfo from './components/GbfsFeedInfo'; import GbfsVersions from './components/GbfsVersions'; import generateFeedStructuredData from './StructuredData.functions'; import ReactGA from 'react-ga4'; +import GtfsFeedSummary from './components/GtfsFeedSummary'; const wrapComponent = ( feedLoadingStatus: string, @@ -297,7 +296,7 @@ export default function Feed(): React.ReactElement { mt: 2, display: 'flex', justifyContent: 'space-between', - gap: 3, + gap: 2, flexWrap: { xs: 'wrap', sm: 'nowrap' }, }} > @@ -556,7 +555,7 @@ export default function Feed(): React.ReactElement { )} {latestDataset?.validation_report?.url_html != undefined && (