diff --git a/web-app/public/locales/en/common.json b/web-app/public/locales/en/common.json index bda3045b3..52934bf9c 100644 --- a/web-app/public/locales/en/common.json +++ b/web-app/public/locales/en/common.json @@ -1,54 +1,54 @@ { - "copyToClipboard": "Copy to clipboard", - "copied": "Copied!", - "name": "Name", - "email": "Email", - "organization": "Organization", - "signOut": "Sign out", - "unknown": "Unknown", - "search": "Search", - "feeds": "Feeds", - "gtfsSchedule": "GTFS Schedule", - "gtfsRealtime": "GTFS Realtime", - "gtfs": "GTFS Schedule", - "gtfs_rt": "GTFS Realtime", - "gbfs": "GBFS", - "gtfsRealtimeEntities": { - "tripUpdates": "Trip Updates", - "vehiclePositions": "Vehicle Positions", - "serviceAlerts": "Service Alerts" - }, - "loading": "Loading...", - "download": "Download", - "updated": "Updated", - "errors": { - "generic": "We are unable to complete your request at the moment." - }, - "others": "others", - "apiKey": "API Key", - "httpHeader": "HTTP Header", - "back": "Back", - "and": "and", - "next": "Next", - "form": { - "yes": "Yes", - "no": "No", - "required": "This field is required", - "submit": "Submit", - "select": "Select", - "notSure": "Not sure" - }, - "country": "Country", - "chooseCountry": "Choose a country", - "region": "Region", - "municipality": "Municipality", - "feedback": { - "errors": "errors", - "noErrors": "No errors", - "noWarnings": "No warnings", - "warnings": "warnings", - "infoNotices": "info notices" - }, + "copyToClipboard": "Copy to clipboard", + "copied": "Copied!", + "name": "Name", + "email": "Email", + "organization": "Organization", + "signOut": "Sign out", + "unknown": "Unknown", + "search": "Search", + "feeds": "Feeds", + "gtfsSchedule": "GTFS Schedule", + "gtfsRealtime": "GTFS Realtime", + "gtfs": "GTFS Schedule", + "gtfs_rt": "GTFS Realtime", + "gbfs": "GBFS", + "gtfsRealtimeEntities": { + "tripUpdates": "Trip Updates", + "vehiclePositions": "Vehicle Positions", + "serviceAlerts": "Service Alerts" + }, + "loading": "Loading...", + "download": "Download", + "updated": "Updated", + "errors": { + "generic": "We are unable to complete your request at the moment." + }, + "others": "others", + "apiKey": "API Key", + "httpHeader": "HTTP Header", + "back": "Back", + "and": "and", + "next": "Next", + "form": { + "yes": "Yes", + "no": "No", + "required": "This field is required", + "submit": "Submit", + "select": "Select", + "notSure": "Not sure" + }, + "country": "Country", + "chooseCountry": "Choose a country", + "region": "Region", + "municipality": "Municipality", + "feedback": { + "errors": "errors", + "noErrors": "No errors", + "noWarnings": "No warnings", + "warnings": "warnings", + "infoNotices": "info notices" + }, "gtfsSpec": { "routeType": { "0": { @@ -96,5 +96,11 @@ "validators": "Validators", "gbfsValidator": "GBFS Validator", "gtfsValidator": "GTFS Validator", - "gtfsRtValidator": "GTFS RT Validator" + "gtfsRtValidator": "GTFS RT Validator", + "start": "Start", + "end": "End", + "showLess": "Show less", + "showMore": "Show {{count}} more", + "moreInfo": "More Info", + "license": "License" } diff --git a/web-app/public/locales/en/feeds.json b/web-app/public/locales/en/feeds.json index ce11de532..82a8765f1 100644 --- a/web-app/public/locales/en/feeds.json +++ b/web-app/public/locales/en/feeds.json @@ -54,6 +54,7 @@ "seeFullList": "See full list", "hideFullList": "Hide full list", "producer": "Producer", + "agency": "Agency", "producerDownloadUrl": "Producer download URL", "copyDownloadUrl": "Copy download URL", "producerUrlCopied": "Producer url copied to clipboard", @@ -233,5 +234,37 @@ "tfs": "Automatically imported from old TransitFeeds website. Pattern is tfs-", "tld": "Imported from https://www.transit.land/documentation/rest-api/feeds. Pattern is tld-" } + }, + "license": { + "dialogTitle": "License Details", + "loadingError": "Error loading license details", + "spdxTooltip": "The Software Package Data Exchange (SPDX) is an open standard for communicating software bill of material information, including licenses.", + "licenseTooltip": "License was added by the Mobility Database team based on either 1) a submission authorized by the transit provider 2) review of the transit providers website.", + "permission": "Permission", + "permissionSubtitle": "What you can do with this license", + "condition": "Condition", + "conditionSubtitle": "What you must do to comply", + "limitation": "Limitation", + "limitationSubtitle": "What this license does not provide", + "noPermission": "No permissions", + "noCondition": "No conditions", + "noLimitation": "No limitations", + "noRules": "No usage rules specified.", + "contributeMessage": "To contribute to the clarity of the rules for this license entry, please submit a pull request to:" + }, + "noAgencyProvided": "No agency provided", + "feedSummary": { + "viewAllAgencies": "View All {{count}} Agencies", + "andMore": "and more", + "plusMore": "+ {{count}} more", + "showLocationDetails": "Show Details ({{count}} Locations)", + "routes": "Routes", + "routesCount": "{{count}} routes", + "viewOnMap": "View On Map", + "autoDiscoveryUrl": "Auto-Discovery URL", + "producerUrl": "Producer URL", + "providerUrl": "Provider URL", + "systemId": "System ID", + "feedAuthentication": "Feed Authentication" } } diff --git a/web-app/src/app/Theme.ts b/web-app/src/app/Theme.ts index 9415939b3..e73207f9c 100644 --- a/web-app/src/app/Theme.ts +++ b/web-app/src/app/Theme.ts @@ -71,6 +71,12 @@ const palette = { light: '#D7D4FF', contrastText: '#FFFFFF', }, + info: { + main: '#01579B', + }, + warning: { + main: '#E65100', + }, background: { default: '#ffffff', paper: '#F8F5F5', @@ -98,6 +104,12 @@ const darkPalette = { dark: '#3846A6', contrastText: '#FFFFFF', }, + info: { + main: '#4FC3F7', + }, + warning: { + main: '#FFB74D', + }, background: { default: '#121212', paper: '#1E1E1E', diff --git a/web-app/src/app/components/CoveredAreaMap.tsx b/web-app/src/app/components/CoveredAreaMap.tsx index c6520d338..e1110cf34 100644 --- a/web-app/src/app/components/CoveredAreaMap.tsx +++ b/web-app/src/app/components/CoveredAreaMap.tsx @@ -280,10 +280,7 @@ const CoveredAreaMap: React.FC = ({ p: 2, backgroundColor: theme.palette.background.default, borderRadius: '5px', - border: - feed?.data_type === 'gbfs' - ? `2px solid ${theme.palette.primary.dark}` - : 'none', // Temporary until gbfs summary redesign + border: 'none', }} > diff --git a/web-app/src/app/screens/Feed/FeedSummary.styles.ts b/web-app/src/app/screens/Feed/FeedSummary.styles.ts index 0791a22e8..ebf2ea6f5 100644 --- a/web-app/src/app/screens/Feed/FeedSummary.styles.ts +++ b/web-app/src/app/screens/Feed/FeedSummary.styles.ts @@ -22,7 +22,7 @@ export const FeedLinkElement = styled(Box)(({ theme }) => ({ width: 'calc(100% - 16px)', marginLeft: '16px', marginBottom: '16px', - '&:last-child': { + '&:last-of-type': { marginBottom: 0, }, })); diff --git a/web-app/src/app/screens/Feed/components/CopyLinkElement.tsx b/web-app/src/app/screens/Feed/components/CopyLinkElement.tsx index 978aea976..22557ee67 100644 --- a/web-app/src/app/screens/Feed/components/CopyLinkElement.tsx +++ b/web-app/src/app/screens/Feed/components/CopyLinkElement.tsx @@ -17,10 +17,11 @@ import EmailIcon from '@mui/icons-material/Email'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; export interface CopyLinkElementProps { - title: string; + title?: string; url: string; - linkType?: 'download' | 'external' | 'email'; + linkType?: 'download' | 'external' | 'email' | 'internal' | 'label'; titleInfo?: string; + internalClickAction?: () => void; } export default function CopyLinkElement({ @@ -28,6 +29,7 @@ export default function CopyLinkElement({ url, linkType, titleInfo, + internalClickAction, }: CopyLinkElementProps): ReactElement { const [snackbarOpen, setSnackbarOpen] = useState(false); @@ -51,36 +53,60 @@ export default function CopyLinkElement({ return ( - - - {titleInfo != undefined && ( - - - - )} - {chipIcon != undefined && chipLabel != undefined && ( - - )} - + {title != null && ( + + {linkType === 'label' ? ( + + {title} + + ) : linkType === 'internal' && internalClickAction != undefined ? ( + + ) : ( + + )} + {titleInfo != undefined && ( + + + + )} + {chipIcon != undefined && chipLabel != undefined && ( + + )} + + )} - {feed?.source_info?.authentication_type !== 0 && ( - - - - - {t('authenticationType')} - - - {feed?.source_info?.authentication_type === 1 && - t('common:apiKey')} - {feed?.source_info?.authentication_type === 2 && - t('common:httpHeader')} - - - - )} - - {hasAuthenticationInfo && - feed?.source_info?.authentication_info_url != undefined && ( - - )} - - ); -} diff --git a/web-app/src/app/screens/Feed/components/GtfsFeedSummary.tsx b/web-app/src/app/screens/Feed/components/FeedSummary.tsx similarity index 70% rename from web-app/src/app/screens/Feed/components/GtfsFeedSummary.tsx rename to web-app/src/app/screens/Feed/components/FeedSummary.tsx index 32aee514b..c8c2ef09e 100644 --- a/web-app/src/app/screens/Feed/components/GtfsFeedSummary.tsx +++ b/web-app/src/app/screens/Feed/components/FeedSummary.tsx @@ -1,10 +1,12 @@ import { useMemo, useState } from 'react'; import { type components } from '../../../services/feeds/types'; +import LicenseDialog from './LicenseDialog'; import { getCountryLocationSummaries, getLocationName, type GTFSFeedType, type GTFSRTFeedType, + type GBFSFeedType, } from '../../../services/feeds/utils'; import { Box, @@ -36,6 +38,7 @@ 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 GavelIcon from '@mui/icons-material/Gavel'; import { getFeedStatusData } from '../../../utils/feedStatusConsts'; import { useSelector } from 'react-redux'; import { Link as RouterLink } from 'react-router-dom'; @@ -56,23 +59,26 @@ import CopyLinkElement from './CopyLinkElement'; import { formatDateShort } from '../../../utils/date'; import ExternalIds from './ExternalIds'; -export interface GtfsFeedSummaryProps { - feed: GTFSFeedType | GTFSRTFeedType | undefined; +export interface FeedSummaryProps { + feed: GTFSFeedType | GTFSRTFeedType | GBFSFeedType | undefined; sortedProviders: string[]; latestDataset?: components['schemas']['GtfsDataset'] | undefined; + autoDiscoveryUrl?: string; } -export default function GtfsFeedSummary({ +export default function FeedSummary({ feed, sortedProviders, latestDataset, -}: GtfsFeedSummaryProps): React.ReactElement { + autoDiscoveryUrl, +}: FeedSummaryProps): React.ReactElement { const { t } = useTranslation('feeds'); const theme = useTheme(); const [openLocationDetails, setOpenLocationDetails] = useState< 'summary' | 'fullList' | undefined >(undefined); const [openProvidersDetails, setOpenProvidersDetails] = useState(false); + const [openLicenseDetails, setOpenLicenseDetails] = useState(false); const [showAllFeatures, setShowAllFeatures] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down('md')); @@ -97,12 +103,9 @@ export default function GtfsFeedSummary({ }; 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; + const relatedLinks = (feed as GTFSFeedType)?.related_links; + const hasOtherLinks = relatedLinks != null && relatedLinks?.length > 0; + return hasOtherLinks; }; return ( @@ -110,7 +113,7 @@ export default function GtfsFeedSummary({ - Agency + {feed?.data_type === 'gbfs' ? t('producer') : t('agency')} @@ -119,7 +122,7 @@ export default function GtfsFeedSummary({ : t('noAgencyProvided')} {sortedProviders.length > 4 && ( - and more + {t('feedSummary.andMore')} )} @@ -132,7 +135,9 @@ export default function GtfsFeedSummary({ }} sx={{ pl: 0 }} > - View All {sortedProviders.length} Agencies + {t('feedSummary.viewAllAgencies', { + count: sortedProviders.length, + })} )} @@ -154,7 +159,7 @@ export default function GtfsFeedSummary({ )} - {feed?.external_ids != null && feed.external_ids.length > 0 && ( + {feed?.external_ids != undefined && feed.external_ids.length > 0 && ( )} @@ -171,7 +176,7 @@ export default function GtfsFeedSummary({ > - Feed Details + {t('feeds:feedSummary.routes')} - Locations + {t('locations')} - {feed?.locations != null && feed?.locations?.length === 1 && ( - - {getLocationName(feed?.locations)} - - )} + {feed?.locations != undefined && + feed?.locations?.length === 1 && ( + + {getLocationName(feed?.locations)} + + )} - {feed?.locations != null && + {feed?.locations != undefined && feed?.locations?.length > 1 && uniqueCountries.slice(0, 4).map((country, index) => ( - + {uniqueCountries.length - 4} more + {t('feedSummary.plusMore', { + count: uniqueCountries.length - 4, + })} )} ))} - {feed?.locations?.length != null && feed.locations.length > 1 && ( - - )} + {feed?.locations?.length != undefined && + feed.locations.length > 1 && ( + + )} {totalRoutes != undefined && routeTypes != undefined && ( @@ -263,7 +274,7 @@ export default function GtfsFeedSummary({ variant='subtitle2' sx={{ fontWeight: 700, color: 'text.secondary' }} > - Routes + {t('feedSummary.routes')} - {totalRoutes} routes + + {t('feedSummary.routesCount', { count: totalRoutes })} + @@ -323,7 +336,9 @@ export default function GtfsFeedSummary({ variant='subtitle2' sx={{ fontWeight: 700, color: 'text.secondary' }} > - Producer Url + {feed?.data_type === 'gbfs' && autoDiscoveryUrl != undefined + ? t('feedSummary.autoDiscoveryUrl') + : t('feedSummary.producerUrl')} - {feed.source_info.producer_url} + {feed.data_type === 'gbfs' + ? autoDiscoveryUrl + : feed.source_info.producer_url} - + {feed.data_type === 'gbfs' ? ( + + ) : ( + + )} )} - {feed?.feed_contact_email != null && + + {(feed as GBFSFeedType)?.provider_url != undefined && + (feed as GBFSFeedType)?.provider_url !== '' && ( + <> + + + {feed?.data_type === 'gbfs' + ? t('feedSummary.producerUrl') + : t('feedSummary.providerUrl')} + + + {(feed as GBFSFeedType)?.provider_url} + + + + )} + {(feed as GBFSFeedType)?.system_id != undefined && ( + + + {t('feedSummary.systemId')} + + + {(feed as GBFSFeedType)?.system_id} + + + )} + {feed?.feed_contact_email != undefined && feed?.feed_contact_email !== '' && ( - Feed Contact Email + {t('feedContactEmail')} )} @@ -397,7 +459,7 @@ export default function GtfsFeedSummary({ - Feed Authentication + {t('feedSummary.feedAuthentication')} @@ -429,7 +491,7 @@ export default function GtfsFeedSummary({ - Service Date Range + {t('serviceDateRange')} @@ -449,7 +511,7 @@ export default function GtfsFeedSummary({ > - Start + {t('common:start')} {formatDateShort( @@ -460,8 +522,11 @@ export default function GtfsFeedSummary({ @@ -479,36 +544,37 @@ export default function GtfsFeedSummary({ height: '1px', width: '100%', background: `radial-gradient(circle,${getFeedStatusData( - feed?.status ?? '', + (feed as GTFSFeedType)?.status ?? '', theme, t, )?.color} 54%, rgba(255, 255, 255, 0) 100%)`, }} > {/* TODO: nice to have, a placement of the chip relative to the current date */} - {feed?.status !== undefined && feed.status === 'active' && ( - - - - )} + {(feed as GTFSFeedType)?.status != undefined && + (feed as GTFSFeedType)?.status === 'active' && ( + + + + )} - End + {t('common:end')} {formatDateShort( @@ -526,8 +592,8 @@ export default function GtfsFeedSummary({ - Features - + {t('features')} + {showAllFeatures - ? 'Show less' - : `Show ${allFeatures.length - 6} more`} + ? t('common:showLess') + : t('common:showMore', { + count: allFeatures.length - 6, + })} )} @@ -601,23 +669,77 @@ export default function GtfsFeedSummary({ )} + {feed?.source_info?.license_url != undefined && + feed?.source_info?.license_url !== '' && ( + + + + + {t('common:license')} + + {feed?.source_info?.license_is_spdx != undefined && + feed.source_info.license_is_spdx && ( + + + + )} + + + {feed?.source_info?.license_id != undefined && + feed.source_info.license_id !== '' ? ( + <> + + + + ) : ( + + {feed?.source_info?.license_url} + + )} + + )} + {hasRelatedLinks() && ( - Related Links + {t('relatedLinks')} - {feed?.source_info?.license_url != undefined && - feed?.source_info?.license_url !== '' && ( - - )} - - {feed?.related_links?.map((link, index) => ( + {(feed as GTFSFeedType)?.related_links?.map((link, index) => ( { setOpenLocationDetails(undefined); }} - open={openLocationDetails !== undefined} + open={openLocationDetails != undefined} > Feed Locations theme.palette.grey[500], })} > - {feed?.locations != null && ( + {feed?.locations != undefined && ( + { + setOpenLicenseDetails(false); + }} + licenseId={feed?.source_info?.license_id} + /> ); } diff --git a/web-app/src/app/screens/Feed/components/GbfsFeedInfo.tsx b/web-app/src/app/screens/Feed/components/GbfsFeedInfo.tsx deleted file mode 100644 index b4a71ba49..000000000 --- a/web-app/src/app/screens/Feed/components/GbfsFeedInfo.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import * as React from 'react'; -import { ContentBox } from '../../../components/ContentBox'; -import { Box, Typography, useTheme, Link } from '@mui/material'; -import { type GBFSFeedType } from '../../../services/feeds/utils'; -import { useTranslation } from 'react-i18next'; - -import PublicIcon from '@mui/icons-material/Public'; -import LinkIcon from '@mui/icons-material/Link'; -import DatasetIcon from '@mui/icons-material/Dataset'; -import Locations from '../../../components/Locations'; -import SettingsIcon from '@mui/icons-material/Settings'; -import StoreIcon from '@mui/icons-material/Store'; -import FeedAuthenticationSummaryInfo from './FeedAuthenticationSummaryInfo'; -import { boxElementStyle, StyledTitleContainer } from '../Feed.styles'; - -export interface GbfsFeedInfoProps { - feed: GBFSFeedType; - autoDiscoveryUrl?: string; -} - -export default function GbfsFeedInfo({ - feed, - autoDiscoveryUrl, -}: GbfsFeedInfoProps): React.ReactElement { - const { t } = useTranslation('feeds'); - const theme = useTheme(); - - return ( - - - - - - {t('locations')} - - - - {feed?.locations != null && } - - - - - - - - {t('producer')} - - - - {feed?.provider} - - - - {autoDiscoveryUrl != undefined && ( - - - - - {t('gbfs:autoDiscoveryUrl')} - - - - {autoDiscoveryUrl} - - - )} - - - - - - System ID - - - {feed?.system_id} - - - - - - - {t('dataType')} - - - GBFS - - - - - ); -} diff --git a/web-app/src/app/screens/Feed/components/LicenseDialog.tsx b/web-app/src/app/screens/Feed/components/LicenseDialog.tsx new file mode 100644 index 000000000..306064207 --- /dev/null +++ b/web-app/src/app/screens/Feed/components/LicenseDialog.tsx @@ -0,0 +1,272 @@ +import React, { useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + IconButton, + Typography, + Box, + CircularProgress, + Link, + Alert, + Grid, + Chip, + Tooltip, + useTheme, + useMediaQuery, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { useSelector, useDispatch } from 'react-redux'; +import { + selectActiveLicense, + selectLicenseStatus, + selectLicenseErrors, +} from '../../../store/license-selectors'; +import { loadingLicense } from '../../../store/license-reducer'; +import { useTranslation } from 'react-i18next'; + +export interface LicenseDialogProps { + open: boolean; + onClose: () => void; + licenseId: string | undefined; +} + +export default function LicenseDialog({ + open, + onClose, + licenseId, +}: LicenseDialogProps): React.ReactElement { + const { t } = useTranslation('feeds'); + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('md')); + const dispatch = useDispatch(); + const status = useSelector(selectLicenseStatus); + const license = useSelector(selectActiveLicense); + const errors = useSelector(selectLicenseErrors); + + useEffect(() => { + if (open && licenseId != undefined) { + dispatch(loadingLicense({ licenseId })); + } + }, [open, licenseId, dispatch]); + + const rulesData = [ + { + title: t('license.permission'), + subtitle: t('license.permissionSubtitle'), + rules: license?.license_rules?.filter( + (rule) => rule.type === 'permission', + ), + color: 'success', + emptyMessage: t('license.noPermission'), + }, + { + title: t('license.condition'), + subtitle: t('license.conditionSubtitle'), + rules: license?.license_rules?.filter( + (rule) => rule.type === 'condition', + ), + color: 'warning', + emptyMessage: t('license.noCondition'), + }, + { + title: t('license.limitation'), + subtitle: t('license.limitationSubtitle'), + rules: license?.license_rules?.filter( + (rule) => rule.type === 'limitation', + ), + color: 'error', + emptyMessage: t('license.noLimitation'), + }, + ]; + + return ( + + + {t('license.dialogTitle')} + theme.palette.grey[500], + }} + > + + + + + {status === 'loading' && ( + + + + )} + + {status === 'error' && ( + + {errors.DatabaseAPI?.message ?? t('license.loadingError')} + + )} + + {status === 'loaded' && license != null && ( + + + + + {license.name ?? license.id} + + {license?.is_spdx != undefined && license.is_spdx && ( + + + + )} + + {license.url != undefined && license.url != '' && ( + + + {license.url} + + + )} + {license.description != undefined && + license.description != '' && ( + + {license.description} + + )} + + + {license.license_rules != undefined && + license.license_rules.length > 0 ? ( + <> + + {rulesData.map((ruleData) => { + const hasRules = + ruleData.rules != undefined && + ruleData.rules.length > 0; + return ( + + + {ruleData.title} + + + {ruleData.subtitle} + + {hasRules ? ( + + {ruleData.rules?.map((rule, index) => ( + + + + + {rule.label ?? rule.name} + + + + {rule.description} + + + + ))} + + ) : ( + + {ruleData.emptyMessage} + + )} + + ); + })} + + + ) : ( + <> + {t('license.noRules')} + + {t('license.contributeMessage')} + + + https://github.com/MobilityData/licenses-aas/blob/main/data/licenses/ + {license.id}.json + + + )} + + + )} + + + ); +} diff --git a/web-app/src/app/screens/Feed/index.tsx b/web-app/src/app/screens/Feed/index.tsx index b6ca18295..e2707ffb4 100644 --- a/web-app/src/app/screens/Feed/index.tsx +++ b/web-app/src/app/screens/Feed/index.tsx @@ -61,11 +61,10 @@ import { type GTFSRTFeedType, } from '../../services/feeds/utils'; import DownloadIcon from '@mui/icons-material/Download'; -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'; +import FeedSummary from './components/FeedSummary'; const wrapComponent = ( feedLoadingStatus: string, @@ -567,20 +566,6 @@ export default function Feed(): React.ReactElement { )} {feed?.data_type === 'gbfs' && <>{gbfsOpenFeedUrlElement()}} - {feed?.source_info?.license_url != undefined && - feed?.source_info?.license_url !== '' && ( - - )} - {feed.data_type === 'gbfs' && ( - - )} - {(feed.data_type === 'gtfs' || feed.data_type === 'gtfs_rt') && ( - - )} + {feed?.data_type === 'gtfs_rt' && relatedFeeds != undefined && ( diff --git a/web-app/src/app/services/feeds/index.ts b/web-app/src/app/services/feeds/index.ts index 461548fe5..45214b5eb 100644 --- a/web-app/src/app/services/feeds/index.ts +++ b/web-app/src/app/services/feeds/index.ts @@ -287,3 +287,26 @@ export const searchFeeds = async ( client.eject(authMiddleware); }); }; + +export const getLicense = async ( + id: string, + accessToken: string, +): Promise< + | paths['/v1/licenses/{id}']['get']['responses'][200]['content']['application/json'] + | undefined +> => { + const authMiddleware = generateAuthMiddlewareWithToken(accessToken); + client.use(authMiddleware); + return await client + .GET('/v1/licenses/{id}', { params: { path: { id } } }) + .then((response) => { + const data = response.data; + return data; + }) + .catch(function (error) { + throw error; + }) + .finally(() => { + client.eject(authMiddleware); + }); +}; diff --git a/web-app/src/app/store/license-reducer.ts b/web-app/src/app/store/license-reducer.ts new file mode 100644 index 000000000..94a8feae5 --- /dev/null +++ b/web-app/src/app/store/license-reducer.ts @@ -0,0 +1,78 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { + type LicenseErrors, + LicenseErrorSource, + type LicenseError, +} from '../types'; +import { type components } from '../services/feeds/types'; + +export type License = components['schemas']['LicenseWithRules']; + +interface LicenseState { + status: 'loading' | 'loaded' | 'error'; + activeLicenseId: string | undefined; + data: Record; + errors: LicenseErrors; +} + +const initialState: LicenseState = { + status: 'loaded', + activeLicenseId: undefined, + data: {}, + errors: { + [LicenseErrorSource.DatabaseAPI]: null, + }, +}; + +export const licenseSlice = createSlice({ + name: 'licenseProfile', + initialState, + reducers: { + loadingLicense: (state, action: PayloadAction<{ licenseId: string }>) => { + state.status = 'loading'; + state.activeLicenseId = action.payload.licenseId; + state.errors = { + ...state.errors, + DatabaseAPI: initialState.errors.DatabaseAPI, + }; + }, + loadingLicenseSuccess: ( + state, + action: PayloadAction<{ license: License; fetchedAt: number }>, + ) => { + state.status = 'loaded'; + if (action.payload.license.id != null) { + state.data[action.payload.license.id] = { + license: action.payload.license, + fetchedAt: action.payload.fetchedAt, + }; + state.activeLicenseId = action.payload.license.id; + } + state.errors = { + ...state.errors, + DatabaseAPI: initialState.errors.DatabaseAPI, + }; + }, + loadingLicenseFail: (state, action: PayloadAction) => { + state.status = 'error'; + state.errors.DatabaseAPI = action.payload; + }, + resetLicense: (state) => { + state.status = 'loaded'; + state.activeLicenseId = undefined; + state.errors = { + ...state.errors, + DatabaseAPI: initialState.errors.DatabaseAPI, + }; + }, + }, +}); + +export const { + loadingLicense, + loadingLicenseSuccess, + loadingLicenseFail, + resetLicense, +} = licenseSlice.actions; + +export default licenseSlice.reducer; diff --git a/web-app/src/app/store/license-selectors.ts b/web-app/src/app/store/license-selectors.ts new file mode 100644 index 000000000..5d5634031 --- /dev/null +++ b/web-app/src/app/store/license-selectors.ts @@ -0,0 +1,24 @@ +import { type RootState } from './store'; +import { type License } from './license-reducer'; +import { type LicenseErrors } from '../types'; + +export const selectLicenseStatus = ( + state: RootState, +): 'loading' | 'loaded' | 'error' => state.licenseProfile.status; + +export const selectActiveLicenseId = (state: RootState): string | undefined => + state.licenseProfile.activeLicenseId; + +export const selectLicenseData = ( + state: RootState, +): Record => + state.licenseProfile.data; + +export const selectActiveLicense = (state: RootState): License | undefined => { + const activeId = state.licenseProfile.activeLicenseId; + if (activeId == null || activeId === '') return undefined; + return state.licenseProfile.data[activeId]?.license; +}; + +export const selectLicenseErrors = (state: RootState): LicenseErrors => + state.licenseProfile.errors; diff --git a/web-app/src/app/store/reducers.ts b/web-app/src/app/store/reducers.ts index 2271181ce..01e5733d2 100644 --- a/web-app/src/app/store/reducers.ts +++ b/web-app/src/app/store/reducers.ts @@ -7,6 +7,7 @@ import GTFSAnalyticsReducer from './gtfs-analytics-reducer'; import GBFSAnalyticsReducer from './gbfs-analytics-reducer'; import supportingFilesReducer from './supporting-files-reducer'; import gbfsValidatorReducer from './gbfs-validator-reducer'; +import licenseReducer from './license-reducer'; const rootReducer = combineReducers({ userProfile: profileReducer, @@ -17,6 +18,7 @@ const rootReducer = combineReducers({ gbfsAnalytics: GBFSAnalyticsReducer, supportingFiles: supportingFilesReducer, gbfsValidator: gbfsValidatorReducer, + licenseProfile: licenseReducer, }); export default rootReducer; diff --git a/web-app/src/app/store/saga/license-saga.ts b/web-app/src/app/store/saga/license-saga.ts new file mode 100644 index 000000000..190dd0eb4 --- /dev/null +++ b/web-app/src/app/store/saga/license-saga.ts @@ -0,0 +1,69 @@ +import { + type StrictEffect, + call, + takeLatest, + put, + select, +} from 'redux-saga/effects'; +import { + loadingLicenseFail, + loadingLicenseSuccess, + type License, +} from '../license-reducer'; +import { getAppError } from '../../utils/error'; +import { + LICENSE_PROFILE_LOADING_LICENSE, + type LicenseError, +} from '../../types'; +import { type PayloadAction } from '@reduxjs/toolkit'; +import { getLicense } from '../../services/feeds'; +import { getUserAccessToken } from '../../services'; +import { selectLicenseData } from '../license-selectors'; + +export function* getLicenseSaga({ + payload: { licenseId }, +}: PayloadAction<{ licenseId: string }>): Generator { + try { + const licensesData = (yield select(selectLicenseData)) as Record< + string, + { license: License; fetchedAt: number } + >; + // License data rarely changes, but we use a 1-hour cache duration to ensure + // that any updates (e.g., legal changes, corrections) are picked up within a reasonable time. + // This balances minimizing network requests with keeping data reasonably fresh. + const cachedLicense = licensesData[licenseId]; + const now = Date.now(); + const oneHour = 60 * 60 * 1000; + + if (cachedLicense != null && now - cachedLicense.fetchedAt < oneHour) { + yield put( + loadingLicenseSuccess({ + license: cachedLicense.license, + fetchedAt: cachedLicense.fetchedAt, + }), + ); + return; + } + + if (licenseId !== undefined) { + const accessToken = (yield call(getUserAccessToken)) as string; + const license = (yield call( + getLicense, + licenseId, + accessToken, + )) as License; + yield put( + loadingLicenseSuccess({ + license, + fetchedAt: Date.now(), + }), + ); + } + } catch (error) { + yield put(loadingLicenseFail(getAppError(error) as LicenseError)); + } +} + +export function* watchLicense(): Generator { + yield takeLatest(LICENSE_PROFILE_LOADING_LICENSE, getLicenseSaga); +} diff --git a/web-app/src/app/store/saga/root-saga.ts b/web-app/src/app/store/saga/root-saga.ts index 16c4c55a0..a46ba8326 100644 --- a/web-app/src/app/store/saga/root-saga.ts +++ b/web-app/src/app/store/saga/root-saga.ts @@ -8,6 +8,7 @@ import { watchGTFSFetchFeedMetrics } from './gtfs-analytics-saga'; import { watchGBFSFetchFeedMetrics } from './gbfs-analytics-saga'; import { watchSupportingFiles } from './supporting-files-saga'; import { watchGbfsValidator } from './gbfs-validator-saga'; +import { watchLicense } from './license-saga'; const rootSaga = function* (): Generator { yield all([ @@ -20,6 +21,7 @@ const rootSaga = function* (): Generator { watchGBFSFetchFeedMetrics(), watchSupportingFiles(), watchGbfsValidator(), + watchLicense(), ]); }; diff --git a/web-app/src/app/types.ts b/web-app/src/app/types.ts index 2378f8a46..b2e85c994 100644 --- a/web-app/src/app/types.ts +++ b/web-app/src/app/types.ts @@ -62,6 +62,11 @@ export const FEED_PROFILE_LOADING_RELATED_FEEDS = `${FEED_PROFILE}/loadingRelate export const FEED_PROFILE_LOADING_RELATED_FEEDS_SUCCESS = `${FEED_PROFILE}/loadingRelatedFeedsSuccess`; export const FEED_PROFILE_LOADING_RELATED_FEEDS_FAIL = `${FEED_PROFILE}/loadingRelatedFeedsFail`; +export const LICENSE_PROFILE = 'licenseProfile'; +export const LICENSE_PROFILE_LOADING_LICENSE = `${LICENSE_PROFILE}/loadingLicense`; +export const LICENSE_PROFILE_LOADING_LICENSE_SUCCESS = `${LICENSE_PROFILE}/loadingLicenseSuccess`; +export const LICENSE_PROFILE_LOADING_LICENSE_FAIL = `${LICENSE_PROFILE}/loadingLicenseFail`; + export const FEEDS_RESET_FEEDS = `feeds/resetFeeds`; export const FEEDS_LOADING_FEEDS = `feeds/loadingFeeds`; export const FEEDS_LOADING_FEEDS_SUCCESS = `feeds/loadingFeedsSuccess`; @@ -87,6 +92,10 @@ export enum FeedErrorSource { DatabaseAPI = 'DatabaseAPI', } +export enum LicenseErrorSource { + DatabaseAPI = 'DatabaseAPI', +} + export interface ProfileError { code: string | 'unknown'; message: string; @@ -99,6 +108,12 @@ export interface FeedError { source?: FeedErrorSource; } +export interface LicenseError { + code: string | 'unknown'; + message: string; + source?: LicenseErrorSource; +} + export type ProfileErrors = { [Property in ProfileErrorSource]: ProfileError | null; }; @@ -107,6 +122,10 @@ export type FeedsErrors = { [Property in FeedErrorSource]: FeedError | null; }; +export type LicenseErrors = { + [Property in LicenseErrorSource]: LicenseError | null; +}; + export type FeedErrors = { [Property in FeedErrorSource]: FeedError | null; };