diff --git a/messages/en.json b/messages/en.json index 9ddc67d0..09e888f0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -247,6 +247,7 @@ "isAuthRequiredDetails": " Select \"Yes\" if a user has to login or provide credentials to download the feed", "detailPageDescription": "Explore the {formattedName} {dataTypeVerbose} feed details with access to a quality data insights", "mapPageDescription": "Explore the {formattedName} {dataTypeVerbose} feed on an interactive map showing routes, stops, and transit coverage.", + "sealPageDescription": "See how the {formattedName} {dataTypeVerbose} feed performs against each Seal of Reliability criterion.", "officialFeed": "Official Feed", "officialFeedTooltip": "The transit provider has confirmed this feed should be shared with riders. This has been confirmed either by the transit provider providing the feed on their website or from personalized confirmation with the Mobility Database team.", "officialFeedTooltipShort": "Verified feed: Confirmed by the transit provider or the Mobility Database team for rider use.", diff --git a/messages/fr.json b/messages/fr.json index db0b88fa..f3a76031 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -247,6 +247,7 @@ "isAuthRequiredDetails": " Select \"Yes\" if a user has to login or provide credentials to download the feed", "detailPageDescription": "Explore the {formattedName} {dataTypeVerbose} feed details with access to a quality data insights", "mapPageDescription": "Explore the {formattedName} {dataTypeVerbose} feed on an interactive map showing routes, stops, and transit coverage.", + "sealPageDescription": "Découvrez comment le flux {dataTypeVerbose} de {formattedName} se comporte face à chaque critère du Sceau de fiabilité.", "officialFeed": "Official Feed", "officialFeedTooltip": "The transit provider has confirmed this feed should be shared with riders. This has been confirmed either by the transit provider providing the feed on their website or from personalized confirmation with the Mobility Database team.", "officialFeedTooltipShort": "Verified feed: Confirmed by the transit provider or the Mobility Database team for rider use.", diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx index b439df82..03828400 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx @@ -4,9 +4,12 @@ import { fetchCompleteFeedData } from '../../lib/feed-data'; import { fetchAuthedSealAnalysisData } from '../../lib/seal-analysis-data'; import { getLatestDataset } from '../../../../../../screens/Feed/Feed.functions'; import { notFound } from 'next/navigation'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { getTranslations } from 'next-intl/server'; +import { generateSealFeedMetadata } from '../../lib/generate-feed-metadata'; interface Props { - params: Promise<{ feedDataType: string; feedId: string }>; + params: Promise<{ locale: string; feedDataType: string; feedId: string }>; } /** @@ -15,6 +18,22 @@ interface Props { */ export const dynamic = 'force-dynamic'; +export async function generateMetadata( + { params }: Props, + parent: ResolvingMetadata, +): Promise { + const { locale, feedId, feedDataType } = await params; + const t = await getTranslations({ locale }); + + // Same cache as the page component - no extra API call. + const feedData = await fetchCompleteFeedData(feedDataType, feedId); + + return generateSealFeedMetadata({ + feed: feedData?.feed, + t, + }); +} + export default async function AuthedFeedReliabilityPage({ params, }: Props): Promise { diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts index 146aa977..50d12cae 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts @@ -11,6 +11,8 @@ import { generateDescriptionMetaTag, generateMapPageTitle, generateMapDescriptionMetaTag, + generateSealPageTitle, + generateSealDescriptionMetaTag, } from '../../../../../screens/Feed/Feed.functions'; /** @@ -394,3 +396,54 @@ export function generateMapFeedMetadata({ }, }; } + +/** + * Shared metadata generation logic for feed Seal of Reliability pages + * (authed and static). + * + * @param feed - The feed data + * @param t - Translation function + */ +export function generateSealFeedMetadata({ + feed, + t, +}: GenerateFeedMetadataParams): Metadata { + if (feed == null) { + return { + title: 'Feed Not Found | Mobility Database', + }; + } + const feedDataType = feed.data_type; + const feedId = feed.id; + const sortedProviders = formatProvidersSorted(feed?.provider ?? ''); + const title = generateSealPageTitle( + sortedProviders, + (feed as { feed_name?: string })?.feed_name, + ); + const description = generateSealDescriptionMetaTag( + t, + sortedProviders, + (feed as { feed_name?: string })?.feed_name, + ); + const url = `https://mobilitydatabase.org/feeds/${feedDataType}/${feedId}/seal-of-reliability`; + + return { + title, + description, + openGraph: { + title, + description, + url, + siteName: 'Mobility Database', + type: 'website', + }, + twitter: { + card: 'summary', + title, + description, + }, + alternates: { + canonical: url, + }, + }; +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx index 6f2ad2fd..8eb348e3 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx @@ -1,7 +1,10 @@ import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView'; import { type ReactElement } from 'react'; import { notFound } from 'next/navigation'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { fetchGuestFeedData } from '../../lib/guest-feed-data'; +import { generateSealFeedMetadata } from '../../lib/generate-feed-metadata'; import { fetchGuestSealAnalysisData } from '../../lib/seal-analysis-data'; import { getLatestDataset } from '../../../../../../screens/Feed/Feed.functions'; @@ -22,6 +25,23 @@ interface Props { */ export const dynamic = 'force-dynamic'; +export async function generateMetadata( + { params }: Props, + parent: ResolvingMetadata, +): Promise { + const { feedId, feedDataType } = await params; + + const [t, feedData] = await Promise.all([ + getTranslations(), + fetchGuestFeedData(feedDataType, feedId), + ]); + + return generateSealFeedMetadata({ + feed: feedData.feed, + t, + }); +} + /** * Seal of Reliability breakdown page (GUEST version). * diff --git a/src/app/screens/Feed/Feed.functions.tsx b/src/app/screens/Feed/Feed.functions.tsx index b59c9903..0e55d294 100644 --- a/src/app/screens/Feed/Feed.functions.tsx +++ b/src/app/screens/Feed/Feed.functions.tsx @@ -141,6 +141,45 @@ export function generateMapDescriptionMetaTag( return t('feeds.mapPageDescription', { formattedName, dataTypeVerbose }); } +/** + * The Seal of Reliability only exists for GTFS Schedule feeds - both seal + * pages throw for any other data type - so there is no data type to branch on. + */ +export function generateSealPageTitle( + sortedProviders: string[], + feedName?: string, +): string { + let newDocTitle = getFeedFormattedName(sortedProviders, feedName); + + if (newDocTitle !== '') { + newDocTitle += ' GTFS Schedule Feed Seal of Reliability - '; + } + + newDocTitle += 'Mobility Database'; + return newDocTitle; +} + +/** + * See generateSealPageTitle - GTFS Schedule is the only applicable data type. + */ +export function generateSealDescriptionMetaTag( + t: (key: string, options?: Record) => string, + sortedProviders: string[], + feedName?: string, +): string { + const formattedName = getFeedFormattedName(sortedProviders, feedName); + if ( + sortedProviders.length === 0 && + (feedName === undefined || feedName === '') + ) { + return ''; + } + return t('feeds.sealPageDescription', { + formattedName, + dataTypeVerbose: t('common.gtfsSchedule'), + }); +} + export const formatServiceDateRange = ( dateStart: string, dateEnd: string, diff --git a/src/app/screens/Feed/Feed.spec.tsx b/src/app/screens/Feed/Feed.spec.tsx index a4605b83..e6817479 100644 --- a/src/app/screens/Feed/Feed.spec.tsx +++ b/src/app/screens/Feed/Feed.spec.tsx @@ -10,6 +10,7 @@ import { generatePageTitle, generateDescriptionMetaTag, generateMapPageTitle, + generateSealPageTitle, generateMapDescriptionMetaTag, } from './Feed.functions'; import FeedTitle from './components/FeedTitle'; @@ -298,6 +299,19 @@ describe('Feed page', () => { ); }); + it('should generate the correct seal of reliability page title', () => { + const titleAllInfo = generateSealPageTitle( + ['Department of Transport', 'Public Transport'], + 'Darwin public bus network', + ); + expect(titleAllInfo).toEqual( + 'Department of Transport, Darwin public bus network GTFS Schedule Feed Seal of Reliability - Mobility Database', + ); + + const titleAllEmpty = generateSealPageTitle([], ''); + expect(titleAllEmpty).toEqual('Mobility Database'); + }); + it('should generate the correct map page description', () => { const mockT = jest.fn((key, params) => { switch (key) {