diff --git a/messages/en.json b/messages/en.json
index 0d31f83b..d5ec9866 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -471,5 +471,37 @@
"nearby_stations": "Nearby Stations"
},
"unableToDetectVersions": "Unable to detect versions within this feed."
+ },
+ "home": {
+ "title": "Explore and Access Global Transit Data",
+ "servingOver": "Currently serving over",
+ "feeds": "transportation data feeds from over",
+ "fromOver": "from over",
+ "countries": "countries.",
+ "or": "or",
+ "browseFeeds": "Browse Feeds",
+ "addFeed": "Add a feed",
+ "signUpApi": "Sign up for the API",
+ "description": "The Mobility Database is an open data catalog including over 4000 GTFS, GTFS Realtime, and GBFS feeds in over 75 countries. Whether you're a transportation operator, a researcher studying public transit and shared mobility trends, or a maps app needing reliable data to use with your application, the Mobility Database has everything you need in one central location.",
+ "validatorIntro": "Our database integrates with",
+ "gtfsValidator": "the Canonical GTFS Schedule Validator",
+ "and": "and",
+ "gbfsValidator": "the GBFS Validator",
+ "validatorOutro": "to provide detailed data quality reports on every feed."
+ },
+ "about": {
+ "title": "About",
+ "description": "The Mobility Database is an open catalog including over 4000 GTFS, GTFS Realtime, and GBFS feeds in over 75 countries. It integrates with the Canonical GTFS Schedule and GBFS Validators to share data quality reports for each feed.\n\nThis database is hosted and maintained by MobilityData, the global non-profit organization dedicated to the advancement of open transportation data standards.",
+ "learnMore": "Learn more about MobilityData",
+ "whyUse": "Why Use the Mobility Database?",
+ "whyUseAnswer": "The Mobility Database provides free access to historical and current GTFS, GTFS Realtime, and GBFS feeds from around the world. These feeds are checked for updates every day, ensuring that the data you're looking at is the most recent data available.",
+ "gtfsValidator": "the Canonical GTFS Schedule Validator",
+ "gbfsValidator": "the GBFS Validator.",
+ "benefits": {
+ "mirrored": "Mirrored versions of operator-hosted GTFS Schedule feeds to avoid operator website downtimes and geoblocking",
+ "boundingBoxes": "Bounding boxes that help to visualize or filter in the API by a select region",
+ "addFeeds": "A simple, easy-to-use form to add new feeds",
+ "openSource": "An open source community actively working to improve the tools"
+ }
}
}
diff --git a/messages/fr.json b/messages/fr.json
index e5103d8d..563e9037 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -471,5 +471,37 @@
"nearby_stations": "Nearby Stations"
},
"unableToDetectVersions": "Unable to detect versions within this feed."
+ },
+ "home": {
+ "title": "Explorez et accédez aux données de transport mondiales",
+ "servingOver": "Actuellement plus de",
+ "feeds": "flux de données de transport de plus de",
+ "fromOver": "de plus de",
+ "countries": "pays.",
+ "or": "ou",
+ "browseFeeds": "Parcourir les flux",
+ "addFeed": "Ajouter un flux",
+ "signUpApi": "S'inscrire à l'API",
+ "description": "La Mobility Database est un catalogue de données ouvertes comprenant plus de 4000 flux GTFS, GTFS Realtime et GBFS dans plus de 75 pays. Que vous soyez un opérateur de transport, un chercheur étudiant les tendances du transport public et de la mobilité partagée, ou une application de cartes ayant besoin de données fiables, la Mobility Database a tout ce dont vous avez besoin en un seul endroit.",
+ "validatorIntro": "Notre base de données s'intègre avec",
+ "gtfsValidator": "le validateur canonique GTFS Schedule",
+ "and": "et",
+ "gbfsValidator": "le validateur GBFS",
+ "validatorOutro": "pour fournir des rapports détaillés sur la qualité des données de chaque flux."
+ },
+ "about": {
+ "title": "À propos",
+ "description": "La Mobility Database est un catalogue ouvert comprenant plus de 4000 flux GTFS, GTFS Realtime et GBFS dans plus de 75 pays. Elle s'intègre avec les validateurs canoniques GTFS Schedule et GBFS pour partager des rapports de qualité des données pour chaque flux.\n\nCette base de données est hébergée et maintenue par MobilityData, l'organisation mondiale à but non lucratif dédiée à l'avancement des standards de données de transport ouvertes.",
+ "learnMore": "En savoir plus sur MobilityData",
+ "whyUse": "Pourquoi utiliser la Mobility Database ?",
+ "whyUseAnswer": "La Mobility Database fournit un accès gratuit aux flux GTFS, GTFS Realtime et GBFS historiques et actuels du monde entier. Ces flux sont vérifiés quotidiennement pour les mises à jour, garantissant que les données que vous consultez sont les plus récentes disponibles.",
+ "gtfsValidator": "le validateur canonique GTFS Schedule",
+ "gbfsValidator": "le validateur GBFS.",
+ "benefits": {
+ "mirrored": "Versions miroirs des flux GTFS Schedule hébergés par les opérateurs pour éviter les temps d'arrêt et le blocage géographique",
+ "boundingBoxes": "Boîtes englobantes pour visualiser ou filtrer par région sélectionnée dans l'API",
+ "addFeeds": "Un formulaire simple et facile à utiliser pour ajouter de nouveaux flux",
+ "openSource": "Une communauté open source travaillant activement à améliorer les outils"
+ }
}
}
diff --git a/package.json b/package.json
index 8e5b8d85..d94f6a8c 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,6 @@
"react-draggable": "^4.5.0",
"react-ga4": "^2.1.0",
"react-google-recaptcha": "^3.1.0",
- "react-helmet-async": "^2.0.5",
"react-hook-form": "^7.52.1",
"react-leaflet": "^4.2.1",
"react-map-gl": "^8.0.4",
@@ -56,6 +55,7 @@
},
"scripts": {
"build:prod": "next build",
+ "build:analyze": "next experimental-analyze",
"start:dev": "next dev",
"start:dev:mock": "NEXT_PUBLIC_API_MOCKING=enabled next dev -p 3001",
"start:prod": "next build && next start",
@@ -73,6 +73,9 @@
"generate:gbfs-validator-types:output": "npm exec -- openapi-typescript ./external_types/GbfsValidator.yaml -o $npm_config_output_path && eslint $npm_config_output_path --fix",
"generate:gbfs-validator-types": "npm run generate:gbfs-validator-types:output -- --output-file=src/app/services/feeds/gbfs-validator-types.ts"
},
+ "resolutions": {
+ "tar": "^7.5.7"
+ },
"eslintConfig": {
"extends": [
"react-app",
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 9b87a34e..686b5edf 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -10,12 +10,19 @@ import { Suspense, useEffect, useState } from 'react';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import AppContainer from './AppContainer';
-import { Helmet, HelmetProvider } from 'react-helmet-async';
-function App(): React.ReactElement {
+interface AppProps {
+ locale?: string;
+}
+
+function App({ locale }: AppProps): React.ReactElement {
const dispatch = useDispatch();
const [isAppReady, setIsAppReady] = useState(false);
+ // Determine basename for BrowserRouter based on locale
+ // Non-default locales (e.g., 'fr') need their prefix as basename
+ const basename = locale != null && locale !== 'en' ? `/${locale}` : undefined;
+
useEffect(() => {
app.auth().onAuthStateChanged((user) => {
if (user != null) {
@@ -29,24 +36,14 @@ function App(): React.ReactElement {
}, [dispatch]);
return (
-
-
-
-
-
-
- {/* BrowserRouter will be deprecated in favor of Next AppRouter */}
-
- {isAppReady ? : null}
-
-
-
-
+
+
+ {/* BrowserRouter will be deprecated in favor of Next AppRouter */}
+
+ {isAppReady ? : null}
+
+
+
);
}
diff --git a/src/app/AppContainer.tsx b/src/app/AppContainer.tsx
index d3225531..e165ab6d 100644
--- a/src/app/AppContainer.tsx
+++ b/src/app/AppContainer.tsx
@@ -4,14 +4,12 @@ import * as React from 'react';
import { Box, LinearProgress } from '@mui/material';
import type ContextProviderProps from './interface/ContextProviderProps';
import { useLocation } from 'react-router-dom';
-import { Helmet } from 'react-helmet-async';
import { selectLoadingApp } from './store/selectors';
import { useSelector } from 'react-redux';
const AppContainer: React.FC = ({ children }) => {
const isAppLoading = useSelector(selectLoadingApp);
const location = useLocation();
- const canonicalUrl = window.location.origin + location.pathname;
React.useLayoutEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
@@ -19,9 +17,6 @@ const AppContainer: React.FC = ({ children }) => {
return (
<>
-
-
-
{isAppLoading ? (
diff --git a/src/app/[[...slug]]/page.tsx b/src/app/[[...slug]]/page.tsx
deleted file mode 100644
index bd9e010c..00000000
--- a/src/app/[[...slug]]/page.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-'use client';
-
-// This page is temporary to ease the migration to Next.js App Router
-// It will be deprecated once the migration is fully complete
-import { type ReactNode } from 'react';
-import dynamic from 'next/dynamic';
-
-const App = dynamic(async () => await import('../App'), { ssr: false });
-
-export default function Page(): ReactNode {
- return ;
-}
diff --git a/src/app/[locale]/[...slug]/page.tsx b/src/app/[locale]/[...slug]/page.tsx
new file mode 100644
index 00000000..aefe4020
--- /dev/null
+++ b/src/app/[locale]/[...slug]/page.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+// This page is temporary to ease the migration to Next.js App Router
+// It will be deprecated once the migration is fully complete
+import { type ReactNode, use } from 'react';
+import dynamic from 'next/dynamic';
+
+const App = dynamic(async () => await import('../../App'), { ssr: false });
+
+interface PageProps {
+ params: Promise<{
+ locale: string;
+ slug: string[];
+ }>;
+}
+
+export default function Page({ params }: PageProps): ReactNode {
+ const { locale } = use(params);
+
+ // Pass locale to App so BrowserRouter can use correct basename
+ return ;
+}
diff --git a/src/app/about/page.tsx b/src/app/[locale]/about/components/AboutPage.tsx
similarity index 55%
rename from src/app/about/page.tsx
rename to src/app/[locale]/about/components/AboutPage.tsx
index e13f6015..760b8ce8 100644
--- a/src/app/about/page.tsx
+++ b/src/app/[locale]/about/components/AboutPage.tsx
@@ -1,12 +1,14 @@
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';
+
+export default async function AboutPage(): Promise {
+ const t = await getTranslations('about');
-export default function Page(): ReactElement {
return (
- About
- {/* ColoredContainer: This component uses style which is a client use only. Investigate pattern for SSR optimal Theme rendering */}
+ {t('title')}
- The Mobility Database is an open catalog including over 4000 GTFS,
- GTFS Realtime, and GBFS feeds in over 75 countries. It integrates with
- the Canonical GTFS Schedule and GBFS Validators to share data quality
- reports for each feed.
-
- This database is hosted and maintained by MobilityData, the global
- non-profit organization dedicated to the advancement of open
- transportation data standards.
+ {t('description')}
- Why Use the Mobility Database?
+ {t('whyUse')}
- The Mobility Database provides free access to historical and current
- GTFS, GTFS Realtime, and GBFS feeds from around the world. These feeds
- are checked for updates every day, ensuring that the data you’re
- looking at is the most recent data available.
+ {t('whyUseAnswer')}
In addition to our database, we develop and maintain other tools that
integrate with it such as
@@ -62,7 +54,7 @@ export default function Page(): ReactElement {
target='_blank'
endIcon={}
>
- the Canonical GTFS Schedule Validator
+ {t('gtfsValidator')}
and
}
>
- the GBFS Validator.
+ {t('gbfsValidator')}
Additional benefits of using the Mobility Database include
-
- Mirrored versions of operator-hosted GTFS Schedule feeds to avoid
- operator website downtimes and geoblocking
-
-
- Bounding boxes that help to visualize or filter in the API by a
- select region
-
+
{t('benefits.mirrored')}
+
{t('benefits.boundingBoxes')}
-
- An open source community actively working to improve the tools
-
+
{t('benefits.openSource')}
diff --git a/src/app/[locale]/about/page.tsx b/src/app/[locale]/about/page.tsx
new file mode 100644
index 00000000..dbdd0a3f
--- /dev/null
+++ b/src/app/[locale]/about/page.tsx
@@ -0,0 +1,26 @@
+import { type ReactElement } from 'react';
+import { setRequestLocale } from 'next-intl/server';
+import { type AVAILABLE_LOCALES, routing } from '../../../i18n/routing';
+import AboutPage from './components/AboutPage';
+
+export const dynamic = 'force-static';
+
+export function generateStaticParams(): Array<{
+ locale: (typeof AVAILABLE_LOCALES)[number];
+}> {
+ return routing.locales.map((locale) => ({ locale }));
+}
+
+interface PageProps {
+ params: Promise<{ locale: string }>;
+}
+
+export default async function About({
+ params,
+}: PageProps): Promise {
+ const { locale } = await params;
+
+ setRequestLocale(locale);
+
+ return ;
+}
diff --git a/src/app/[locale]/components/HomePage.tsx b/src/app/[locale]/components/HomePage.tsx
new file mode 100644
index 00000000..dcf79915
--- /dev/null
+++ b/src/app/[locale]/components/HomePage.tsx
@@ -0,0 +1,223 @@
+import { type ReactElement } from 'react';
+import { Box, Typography, Button, Container, Divider } from '@mui/material';
+import {
+ Search,
+ CheckCircleOutlineOutlined,
+ PowerOutlined,
+} from '@mui/icons-material';
+import { WEB_VALIDATOR_LINK } from '../../constants/Navigation';
+import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+import SearchBox from './SearchBox';
+import { getTranslations } from 'next-intl/server';
+import '../../styles/TextShimmer.css';
+
+interface ActionBoxProps {
+ IconComponent: React.ElementType;
+ iconHeight: string;
+ buttonHref: string;
+ buttonText: string;
+}
+
+const ActionBox = ({
+ IconComponent,
+ iconHeight,
+ buttonHref,
+ buttonText,
+}: ActionBoxProps): React.ReactElement => (
+
+
+
+
+);
+
+/**
+ * Home page component that fetches translations directly.
+ * Used by [locale]/page.tsx
+ */
+export default async function HomePage(): Promise {
+ const t = await getTranslations('home');
+
+ return (
+
+
+
+ {t('title')}
+
+
+ {t('servingOver')}
+
+ 4000
+
+ {t('feeds')}
+
+ 75
+
+ {t('countries')}
+
+
+
+
+
+ {t('or')}
+
+
+
+
+
+
+
+
+
+
+ About Our Platform
+
+ {t('description')}
+
+ {t('validatorIntro')}
+ }
+ aria-label='GTFS Validator - Opens in new tab'
+ >
+ {t('gtfsValidator')}
+
+ {t('and')}
+ }
+ aria-label='GBFS Validator - Opens in new tab'
+ >
+ {t('gbfsValidator')}
+
+ {t('validatorOutro')}
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/components/SearchBox.tsx b/src/app/[locale]/components/SearchBox.tsx
new file mode 100644
index 00000000..40236eac
--- /dev/null
+++ b/src/app/[locale]/components/SearchBox.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import * as React from 'react';
+import { Box, Button, TextField, InputAdornment } from '@mui/material';
+import { Search } from '@mui/icons-material';
+import { useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { useRouter } from 'next/navigation';
+
+export default function SearchBox(): React.ReactElement {
+ const [searchInputValue, setSearchInputValue] = useState('');
+ const tCommon = useTranslations('common');
+ const router = useRouter();
+
+ const handleSearch = (): void => {
+ const encodedURI = encodeURIComponent(searchInputValue.trim());
+ if (encodedURI.length === 0) {
+ router.push('/feeds');
+ } else {
+ router.push(`/feeds?q=${encodedURI}`);
+ }
+ };
+
+ const handleKeyDown = (
+ event: React.KeyboardEvent,
+ ): void => {
+ if (event.key === 'Enter') {
+ handleSearch();
+ }
+ };
+
+ return (
+
+ {
+ setSearchInputValue(e.target.value);
+ }}
+ onKeyDown={handleKeyDown}
+ placeholder='e.g. "New York" or "Carris Metropolitana"'
+ slotProps={{
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ },
+ }}
+ />
+
+
+ );
+}
diff --git a/src/app/feeds/[feedDataType]/[feedId]/loading.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/loading.tsx
similarity index 100%
rename from src/app/feeds/[feedDataType]/[feedId]/loading.tsx
rename to src/app/[locale]/feeds/[feedDataType]/[feedId]/loading.tsx
diff --git a/src/app/feeds/[feedDataType]/[feedId]/map/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/map/page.tsx
similarity index 78%
rename from src/app/feeds/[feedDataType]/[feedId]/map/page.tsx
rename to src/app/[locale]/feeds/[feedDataType]/[feedId]/map/page.tsx
index 5d87c663..8dbf4d13 100644
--- a/src/app/feeds/[feedDataType]/[feedId]/map/page.tsx
+++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/map/page.tsx
@@ -1,4 +1,4 @@
-import FullMapView from '../../../../screens/Feed/components/FullMapView';
+import FullMapView from '../../../../../screens/Feed/components/FullMapView';
import { type ReactElement } from 'react';
export default function FullMapViewPage(): ReactElement {
diff --git a/src/app/feeds/[feedDataType]/[feedId]/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/page.tsx
similarity index 95%
rename from src/app/feeds/[feedDataType]/[feedId]/page.tsx
rename to src/app/[locale]/feeds/[feedDataType]/[feedId]/page.tsx
index d7774e13..897c93ae 100644
--- a/src/app/feeds/[feedDataType]/[feedId]/page.tsx
+++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/page.tsx
@@ -1,6 +1,6 @@
import { type ReactElement } from 'react';
import { cache } from 'react';
-import FeedView from '../../../screens/Feed/FeedView';
+import FeedView from '../../../../screens/Feed/FeedView';
import {
getFeed,
getGtfsFeed,
@@ -9,20 +9,20 @@ import {
getGtfsFeedDatasets,
getGtfsFeedRoutes,
getGtfsFeedAssociatedGtfsRtFeeds,
-} from '../../../services/feeds';
+} from '../../../../services/feeds';
import { notFound } from 'next/navigation';
import type { Metadata, ResolvingMetadata } from 'next';
-import { getSSRAccessToken } from '../../../utils/auth-server';
+import { getSSRAccessToken } from '../../../../utils/auth-server';
import {
type GTFSFeedType,
type GTFSRTFeedType,
-} from '../../../services/feeds/utils';
+} from '../../../../services/feeds/utils';
import {
formatProvidersSorted,
generatePageTitle,
generateDescriptionMetaTag,
-} from '../../../screens/Feed/Feed.functions';
-import generateFeedStructuredData from '../../../screens/Feed/StructuredData.functions';
+} from '../../../../screens/Feed/Feed.functions';
+import generateFeedStructuredData from '../../../../screens/Feed/StructuredData.functions';
import { getTranslations } from 'next-intl/server';
interface Props {
diff --git a/src/app/feeds/[feedDataType]/page.tsx b/src/app/[locale]/feeds/[feedDataType]/page.tsx
similarity index 89%
rename from src/app/feeds/[feedDataType]/page.tsx
rename to src/app/[locale]/feeds/[feedDataType]/page.tsx
index 7ff203b7..7677bdf4 100644
--- a/src/app/feeds/[feedDataType]/page.tsx
+++ b/src/app/[locale]/feeds/[feedDataType]/page.tsx
@@ -6,8 +6,8 @@
*/
import { type ReactElement } from 'react';
-import { getFeed } from '../../services/feeds';
-import { getSSRAccessToken } from '../../utils/auth-server';
+import { getFeed } from '../../../services/feeds';
+import { getSSRAccessToken } from '../../../utils/auth-server';
import { notFound, redirect } from 'next/navigation';
interface Props {
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
new file mode 100644
index 00000000..fc1270a4
--- /dev/null
+++ b/src/app/[locale]/layout.tsx
@@ -0,0 +1,115 @@
+import { SpeedInsights } from '@vercel/speed-insights/next';
+import { Analytics } from '@vercel/analytics/next';
+import ThemeRegistry from '../registry';
+import { Providers } from '../providers';
+import { type ReactElement } from 'react';
+import { NextIntlClientProvider, hasLocale } from 'next-intl';
+import { getMessages, setRequestLocale } from 'next-intl/server';
+import { notFound } from 'next/navigation';
+import { getRemoteConfigValues } from '../../lib/remote-config.server';
+import { Mulish, IBM_Plex_Mono } from 'next/font/google';
+import Footer from '../components/Footer';
+import Header from '../components/Header';
+import { Container } from '@mui/material';
+import { type AVAILABLE_LOCALES, routing } from '../../i18n/routing';
+
+export const metadata = {
+ title: 'Mobility Database',
+ description:
+ "Access GTFS, GTFS Realtime, GBFS transit data with over 4,000 feeds from 70+ countries on the web's leading transit data platform.",
+ robots:
+ process.env.VERCEL_ENV === 'production'
+ ? 'index, follow'
+ : 'noindex, nofollow',
+};
+
+export const viewport = {
+ width: 'device-width',
+ initialScale: 1,
+ maximumScale: 5,
+};
+
+const mulish = Mulish({
+ weight: ['400', '500', '700'],
+ subsets: ['latin'],
+ display: 'swap',
+ variable: '--font-mulish',
+});
+
+const ibmPlexMono = IBM_Plex_Mono({
+ weight: ['400', '500', '700'],
+ subsets: ['latin'],
+ display: 'swap',
+ variable: '--font-ibm-plex-mono',
+});
+
+/**
+ * Generate static params for all locales.
+ * This enables static generation for locale-prefixed routes.
+ */
+export function generateStaticParams(): Array<{
+ locale: (typeof AVAILABLE_LOCALES)[number];
+}> {
+ return routing.locales.map((locale) => ({ locale }));
+}
+
+interface LocaleLayoutProps {
+ children: React.ReactNode;
+ params: Promise<{ locale: (typeof AVAILABLE_LOCALES)[number] }>;
+}
+
+/**
+ * Root layout for all locale-based pages.
+ * Provides i18n context, theme, and app shell (header/footer).
+ */
+export default async function LocaleLayout({
+ children,
+ params,
+}: LocaleLayoutProps): Promise {
+ const { locale } = await params;
+
+ // Validate the locale
+ if (!hasLocale(routing.locales, locale)) {
+ notFound();
+ }
+
+ // Enable static rendering for this locale
+ setRequestLocale(locale);
+
+ const [messages, remoteConfig] = await Promise.all([
+ getMessages(),
+ getRemoteConfigValues(),
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx
new file mode 100644
index 00000000..2f36cf03
--- /dev/null
+++ b/src/app/[locale]/page.tsx
@@ -0,0 +1,59 @@
+import { type ReactElement } from 'react';
+import { setRequestLocale } from 'next-intl/server';
+import { type AVAILABLE_LOCALES, routing } from '../../i18n/routing';
+import HomePage from './components/HomePage';
+import { type Metadata } from 'next';
+
+export const dynamic = 'force-static';
+
+export function generateStaticParams(): Array<{
+ locale: (typeof AVAILABLE_LOCALES)[number];
+}> {
+ return routing.locales.map((locale) => ({ locale }));
+}
+
+interface PageProps {
+ params: Promise<{ locale: (typeof AVAILABLE_LOCALES)[number] }>;
+}
+
+export const metadata: Metadata = {
+ title: 'Mobility Database',
+ description:
+ 'Discover open public transit data worldwide. Mobility Database provides GTFS, GTFS-RT, and GBFS feeds to help developers, cities, and agencies build better mobility tools.',
+ applicationName: 'Mobility Database',
+
+ metadataBase: new URL('https://mobilitydatabase.org'),
+
+ alternates: {
+ canonical: '/',
+ },
+ openGraph: {
+ type: 'website',
+ url: 'https://mobilitydatabase.org',
+ siteName: 'Mobility Database',
+ title: 'Mobility Database',
+ description:
+ 'Discover open public transit data worldwide. Find GTFS, GTFS-RT, and GBFS feeds to build better mobility applications.',
+ },
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ 'max-image-preview': 'large',
+ 'max-snippet': -1,
+ 'max-video-preview': -1,
+ },
+ },
+};
+
+export default async function Home({
+ params,
+}: PageProps): Promise {
+ const { locale } = await params;
+
+ setRequestLocale(locale);
+
+ return ;
+}
diff --git a/src/app/components/Context.tsx b/src/app/components/Context.tsx
index ff4551b1..6e9abd8b 100644
--- a/src/app/components/Context.tsx
+++ b/src/app/components/Context.tsx
@@ -22,7 +22,7 @@ const AppContent: React.FC = ({ children }) => {
dispatch(resetProfileErrors());
}, []);
return (
-
+
{children}
);
diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx
index 50059614..03722786 100644
--- a/src/app/components/Header.tsx
+++ b/src/app/components/Header.tsx
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
+import dynamic from 'next/dynamic';
import {
AppBar,
Box,
@@ -30,8 +31,8 @@ import {
gbfsMetricsNavItems,
} from '../constants/Navigation';
import type NavigationItem from '../interface/Navigation';
-import { usePathname, useRouter, useSearchParams } from 'next/navigation';
-import LogoutConfirmModal from './LogoutConfirmModal';
+import { usePathname, useRouter } from 'next/navigation';
+import Image from 'next/image';
import { BikeScooterOutlined, OpenInNew } from '@mui/icons-material';
import { useRemoteConfig } from '../context/RemoteConfigProvider';
import { NestedMenuItem } from 'mui-nested-menu';
@@ -40,7 +41,6 @@ import DepartureBoardIcon from '@mui/icons-material/DepartureBoard';
import { fontFamily } from '../Theme';
import { defaultRemoteConfigValues } from '../interface/RemoteConfig';
import { animatedButtonStyling } from './Header.style';
-import DrawerContent from './HeaderMobileDrawer';
import ThemeToggle from './ThemeToggle';
import { useTranslations, useLocale } from 'next-intl';
import { useSelector } from 'react-redux';
@@ -49,16 +49,44 @@ import {
selectUserEmail,
} from '../store/profile-selectors';
+// Lazy load components not needed for initial render
+const LogoutConfirmModal = dynamic(
+ async () => await import('./LogoutConfirmModal'),
+ {
+ ssr: false,
+ },
+);
+const DrawerContent = dynamic(
+ async () => await import('./HeaderMobileDrawer'),
+ {
+ ssr: false,
+ },
+);
+
+// Hook to safely access search params only on client
+function useClientSearchParams(): URLSearchParams | null {
+ const [searchParams, setSearchParams] =
+ React.useState(null);
+
+ React.useEffect(() => {
+ if (typeof window !== 'undefined') {
+ setSearchParams(new URLSearchParams(window.location.search));
+ }
+ }, []);
+
+ return searchParams;
+}
+
export default function DrawerAppBar(): React.ReactElement {
- const searchParams = useSearchParams();
+ const clientSearchParams = useClientSearchParams();
const hasTransitFeedsRedirectParam =
- searchParams.get('utm_source') === 'transitfeeds';
+ clientSearchParams?.get('utm_source') === 'transitfeeds';
+
const theme = useTheme();
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = React.useState(false);
- const [hasTransitFeedsRedirect, setHasTransitFeedsRedirect] = React.useState(
- hasTransitFeedsRedirectParam,
- );
+ const [hasTransitFeedsRedirect, setHasTransitFeedsRedirect] =
+ React.useState(false);
const [openDialog, setOpenDialog] = React.useState(false);
const [activeTab, setActiveTab] = React.useState('');
const [navigationItems, setNavigationItems] = React.useState<
@@ -68,6 +96,12 @@ export default function DrawerAppBar(): React.ReactElement {
const { config } = useRemoteConfig();
const t = useTranslations('common');
+ React.useEffect(() => {
+ if (hasTransitFeedsRedirectParam) {
+ setHasTransitFeedsRedirect(true);
+ }
+ }, [hasTransitFeedsRedirectParam]);
+
React.useEffect(() => {
setActiveTab(pathname ?? '');
}, [pathname]);
@@ -100,7 +134,7 @@ export default function DrawerAppBar(): React.ReactElement {
};
const container =
- window !== undefined ? () => window.document.body : undefined;
+ typeof window !== 'undefined' ? () => window.document.body : undefined;
const [anchorEl, setAnchorEl] = React.useState(null);
@@ -159,22 +193,14 @@ export default function DrawerAppBar(): React.ReactElement {
}}
className='btn-link'
>
-
-
-
-
-
+ {
setHasTransitFeedsRedirect(false);
- if (hasTransitFeedsRedirectParam) {
+ if (hasTransitFeedsRedirectParam && clientSearchParams != null) {
// Remove utm_source from URL
- const newSearchParams = new URLSearchParams(searchParams);
+ const newSearchParams = new URLSearchParams(clientSearchParams);
newSearchParams.delete('utm_source');
const newPath = `${pathname}?${newSearchParams.toString()}`;
router.replace(newPath);
diff --git a/src/app/components/ThemeToggle.tsx b/src/app/components/ThemeToggle.tsx
index aaefd056..b1ec9cd4 100644
--- a/src/app/components/ThemeToggle.tsx
+++ b/src/app/components/ThemeToggle.tsx
@@ -4,15 +4,11 @@ import Brightness7Icon from '@mui/icons-material/Brightness7';
import { useTheme } from '../context/ThemeProvider';
const ThemeToggle = (): React.ReactElement => {
- const { toggleTheme } = useTheme();
+ const { mode, toggleTheme } = useTheme();
return (
- {localStorage.getItem('theme') === 'dark' ? (
-
- ) : (
-
- )}
+ {mode === 'dark' ? : }
);
};
diff --git a/src/app/context/ThemeProvider.tsx b/src/app/context/ThemeProvider.tsx
index 016a2e16..f9790436 100644
--- a/src/app/context/ThemeProvider.tsx
+++ b/src/app/context/ThemeProvider.tsx
@@ -1,6 +1,6 @@
'use client';
-import { createContext, useState, useMemo, useContext } from 'react';
+import { createContext, useState, useMemo, useContext, useEffect } from 'react';
import {
ThemeProvider as MuiThemeProvider,
CssBaseline,
@@ -9,33 +9,48 @@ import {
import { getTheme, ThemeModeEnum } from '../Theme';
import type ContextProviderProps from '../interface/ContextProviderProps';
-const ThemeContext = createContext({ toggleTheme: () => {} });
+// TODO: Revisit theme for best SSR practices
-function getInitialThemeMode(prefersDarkMode: boolean): ThemeModeEnum {
- if (typeof window !== 'undefined' && localStorage.getItem('theme') != null) {
- return localStorage.getItem('theme') as ThemeModeEnum;
- } else {
- return prefersDarkMode ? ThemeModeEnum.dark : ThemeModeEnum.light;
- }
-}
+const ThemeContext = createContext({
+ mode: ThemeModeEnum.light,
+ toggleTheme: () => {},
+});
export const ThemeProvider: React.FC = ({ children }) => {
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
+
+ // Initialize with system preference for SSR, then check localStorage on client
const [mode, setMode] = useState(
- getInitialThemeMode(prefersDarkMode),
+ prefersDarkMode ? ThemeModeEnum.dark : ThemeModeEnum.light,
);
+ // Load theme from localStorage only on client side
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ const savedTheme = localStorage.getItem('theme');
+ if (
+ savedTheme != null &&
+ savedTheme !== '' &&
+ Object.values(ThemeModeEnum).includes(savedTheme as ThemeModeEnum)
+ ) {
+ setMode(savedTheme as ThemeModeEnum);
+ }
+ }
+ }, []);
+
const toggleTheme = (): void => {
const newMode =
mode === ThemeModeEnum.light ? ThemeModeEnum.dark : ThemeModeEnum.light;
setMode(newMode);
- localStorage.setItem('theme', newMode);
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('theme', newMode);
+ }
};
const theme = useMemo(() => getTheme(mode), [mode]);
return (
-
+
{children}
@@ -44,5 +59,5 @@ export const ThemeProvider: React.FC = ({ children }) => {
);
};
-export const useTheme = (): { toggleTheme: () => void } =>
+export const useTheme = (): { mode: ThemeModeEnum; toggleTheme: () => void } =>
useContext(ThemeContext);
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
deleted file mode 100644
index 9695a651..00000000
--- a/src/app/layout.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { SpeedInsights } from '@vercel/speed-insights/next';
-import { Analytics } from '@vercel/analytics/next';
-import ThemeRegistry from './registry';
-import { Providers } from './providers';
-import { type ReactElement } from 'react';
-import { NextIntlClientProvider } from 'next-intl';
-import { getLocale, getMessages } from 'next-intl/server';
-import { getRemoteConfigValues } from '../lib/remote-config.server';
-import { Mulish, IBM_Plex_Mono } from 'next/font/google';
-import Footer from './components/Footer';
-import Header from './components/Header';
-import { Container } from '@mui/material';
-
-export const metadata = {
- title: 'Mobility Database',
- description: 'Mobility Database',
- robots:
- process.env.VERCEL_ENV === 'production'
- ? 'index, follow'
- : 'noindex, nofollow',
-};
-
-const mulish = Mulish({
- weight: ['400', '500', '700'],
- subsets: ['latin'],
- display: 'swap',
- variable: '--font-mulish',
-});
-
-const ibmPlexMono = IBM_Plex_Mono({
- weight: ['400', '500', '700'],
- subsets: ['latin'],
- display: 'swap',
- variable: '--font-ibm-plex-mono',
-});
-
-export default async function RootLayout({
- children,
-}: {
- children: React.ReactNode;
-}): Promise {
- const [locale, messages, remoteConfig] = await Promise.all([
- getLocale(),
- getMessages(),
- getRemoteConfigValues(),
- ]);
-
- return (
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/router/Router.tsx b/src/app/router/Router.tsx
index c3ecf973..fb6ba81b 100644
--- a/src/app/router/Router.tsx
+++ b/src/app/router/Router.tsx
@@ -7,7 +7,6 @@ import ContactInformation from '../screens/ContactInformation';
import { ProtectedRoute } from './ProtectedRoute';
import CompleteRegistration from '../screens/CompleteRegistration';
import ChangePassword from '../screens/ChangePassword';
-import Home from '../screens/Home';
import ForgotPassword from '../screens/ForgotPassword';
import FAQ from '../screens/FAQ';
import PostRegistration from '../screens/PostRegistration';
@@ -67,7 +66,6 @@ export const AppRouter: React.FC = () => {
return (
- } />
} />
} />
}>
diff --git a/src/app/screens/Feed/index.tsx b/src/app/screens/Feed/index.tsx
index 3b819d94..382aa67a 100644
--- a/src/app/screens/Feed/index.tsx
+++ b/src/app/screens/Feed/index.tsx
@@ -41,7 +41,6 @@ import {
import AssociatedFeeds from './components/AssociatedFeeds';
import { WarningContentBox } from '../../components/WarningContentBox';
import { useTranslations } from 'next-intl';
-import { Helmet } from 'react-helmet-async';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import {
ctaContainerStyle,
@@ -83,25 +82,6 @@ const wrapComponent = (
sx={{ width: '100%', m: 'auto', px: 0 }}
maxWidth='xl'
>
-
- {descriptionMeta != undefined && (
-
- )}
- {feedDataType != undefined && (
-
- )}
- {structuredData != undefined && (
-
- )}
-
-
(
-
-
-
-
-);
-
-function Component(): React.ReactElement {
- const [searchInputValue, setSearchInputValue] = useState('');
- const tCommon = useTranslations('common');
- const navigate = useNavigate();
- const theme = useTheme();
-
- const handleSearch = (): void => {
- const encodedURI = encodeURIComponent(searchInputValue.trim());
- if (encodedURI.length === 0) {
- navigate('/feeds');
- } else {
- navigate(`/feeds?q=${encodedURI}`);
- }
- };
-
- const handleKeyDown = (
- event: React.KeyboardEvent,
- ): void => {
- if (event.key === 'Enter') {
- handleSearch();
- }
- };
-
- return (
-
-
-
-
- Explore and Access Global Transit Data
-
-
- Currently serving over
-
- 4000
-
- transportation data feeds from over
-
- 75
-
- countries.
-
-
- {
- setSearchInputValue(e.target.value);
- }}
- onKeyDown={handleKeyDown}
- placeholder='e.g. "New York" or "Carris Metropolitana"'
- InputProps={{
- startAdornment: (
-
-
-
- ),
- }}
- />
-
-
-
-
-
- or
-
-
-
-
-
-
-
-
-
- The Mobility Database is an open data catalog including over 4000
- GTFS, GTFS Realtime, and GBFS feeds in over 75 countries. Whether
- you’re a transportation operator, a researcher studying public transit
- and shared mobility trends, or a maps app needing reliable data to use
- with your application, the Mobility Database has everything you need
- in one central location.
-
-
- Our database integrates with
- }
- >
- the Canonical GTFS Schedule Validator
-
- and
- }
- >
- the GBFS Validator
-
- to provide detailed data quality reports on every feed.
-
-
-
- );
-}
-
-export default function Home(): React.ReactElement {
- return ;
-}
diff --git a/src/app/styles/TextShimmer.css b/src/app/styles/TextShimmer.css
index d8baf1a3..a0f1a236 100644
--- a/src/app/styles/TextShimmer.css
+++ b/src/app/styles/TextShimmer.css
@@ -37,11 +37,12 @@
-webkit-animation-duration: 8s;
-moz-animation-duration: 8s;
animation-duration: 8s;
+ animation-delay: 1s;
-webkit-animation-iteration-count: infinite;
-moz-animation-iteration-count: infinite;
animation-iteration-count: infinite;
background-repeat: no-repeat;
- background-position: 0 0;
+ background-position: -10% 0;
background-color: rgba(56, 89, 250, 0.6);
}
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
deleted file mode 100644
index 48f0f695..00000000
--- a/src/i18n/config.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export const locales = ['en', 'fr'] as const;
-export type Locale = (typeof locales)[number];
-export const defaultLocale: Locale = 'en';
-
-// Subdomain to locale mapping
-export const subdomainToLocale: Record = {
- fr: 'fr',
-};
diff --git a/src/i18n/navigation.ts b/src/i18n/navigation.ts
new file mode 100644
index 00000000..ec0c2c05
--- /dev/null
+++ b/src/i18n/navigation.ts
@@ -0,0 +1,15 @@
+import { createNavigation } from 'next-intl/navigation';
+import { routing } from './routing';
+
+/**
+ * Locale-aware navigation APIs.
+ *
+ * Use these instead of Next.js navigation to automatically handle locale prefixes:
+ * - Link: Locale-aware link component
+ * - redirect: Server-side redirect with locale
+ * - usePathname: Get pathname without locale prefix
+ * - useRouter: Router with locale-aware navigation
+ * - getPathname: Get localized pathname
+ */
+export const { Link, redirect, usePathname, useRouter, getPathname } =
+ createNavigation(routing);
diff --git a/src/i18n/request.ts b/src/i18n/request.ts
index 062c0d61..33f9b420 100644
--- a/src/i18n/request.ts
+++ b/src/i18n/request.ts
@@ -1,16 +1,24 @@
import { getRequestConfig } from 'next-intl/server';
-import { cookies } from 'next/headers';
-import { locales, defaultLocale } from './config';
+import { hasLocale } from 'next-intl';
+import { routing } from './routing';
-export default getRequestConfig(async () => {
- const cookieStore = await cookies();
- const locale = cookieStore.get('NEXT_LOCALE')?.value ?? defaultLocale;
- const validLocale = locales.includes(locale as (typeof locales)[number])
- ? locale
- : defaultLocale;
+/**
+ * next-intl request configuration.
+ *
+ * This is called for every request and determines which locale/messages to use.
+ * The locale comes from the [locale] route segment via the proxy.
+ */
+export default getRequestConfig(async ({ requestLocale }) => {
+ // Typically corresponds to the `[locale]` segment
+ const requested = await requestLocale;
+
+ // Validate the locale, fall back to default if invalid
+ const locale = hasLocale(routing.locales, requested)
+ ? requested
+ : routing.defaultLocale;
return {
- locale: validLocale,
- messages: (await import(`../../messages/${validLocale}.json`)).default,
+ locale,
+ messages: (await import(`../../messages/${locale}.json`)).default,
};
});
diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts
new file mode 100644
index 00000000..d3d8fad0
--- /dev/null
+++ b/src/i18n/routing.ts
@@ -0,0 +1,21 @@
+import { defineRouting } from 'next-intl/routing';
+
+export const AVAILABLE_LOCALES = ['en', 'fr'] as const;
+
+/**
+ * Centralized routing configuration for next-intl.
+ *
+ * - English (en): Default locale, no prefix in URL (/)
+ * - French (fr): Prefixed URL (/fr)
+ */
+export const routing = defineRouting({
+ locales: AVAILABLE_LOCALES,
+ defaultLocale: 'en',
+ // Don't show /en prefix for default locale
+ localePrefix: 'as-needed',
+ // Don't auto-detect locale from Accept-Language header
+ // Users must explicitly navigate to /fr to get French
+ localeDetection: false,
+});
+
+export type Locale = (typeof routing.locales)[number];
diff --git a/src/proxy.ts b/src/proxy.ts
index 659ad550..91eadbff 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -1,20 +1,44 @@
-import { type NextRequest, NextResponse } from 'next/server';
-import { subdomainToLocale, defaultLocale } from './i18n/config';
+import { NextResponse, type NextRequest } from 'next/server';
+import { routing } from './i18n/routing';
-export function proxy(request: NextRequest): NextResponse {
- const hostname = request.headers.get('host') ?? '';
- const subdomain = hostname.split('.')[0];
+/**
+ * IMPORTANT: The logic of this proxy will be tested once the [...slug] route is removed
+ * Reasoning: [...slug] will catch all routes including those with wrong locale prefixes
+ */
- // Determine locale from subdomain (fr.mobilitydata.org → 'fr')
- const locale = subdomainToLocale[subdomain] ?? defaultLocale;
+/**
+ * Internationalization proxy following the Next.js i18n guide.
+ * @see https://nextjs.org/docs/app/guides/internationalization
+ *
+ * Behavior:
+ * - If a supported locale already exists in the pathname, continue without redirect
+ * - If no locale in pathname, internally rewrite to default locale path
+ */
+export default function proxy(request: NextRequest): NextResponse {
+ const { pathname } = request.nextUrl;
- // Set locale in cookie for server components to read
- const response = NextResponse.next();
- response.cookies.set('NEXT_LOCALE', locale);
+ // Check if any supported locale already exists in the pathname
+ const pathnameHasLocale = routing.locales.some(
+ (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
+ );
- return response;
+ // If locale exists in path, let it through
+ if (pathnameHasLocale) {
+ return NextResponse.next();
+ }
+
+ // No locale in pathname - rewrite to include default locale internally
+ // This allows the [locale] segment to receive the default locale
+ // without changing the URL the user sees
+ const url = request.nextUrl.clone();
+ url.pathname = `/${routing.defaultLocale}${pathname}`;
+ return NextResponse.rewrite(url);
}
export const config = {
+ // Match all pathnames except:
+ // - API routes (/api)
+ // - Next.js internals (/_next)
+ // - Static files with extensions (.ico, .png, etc.)
matcher: ['/((?!api|_next|.*\\..*).*)'],
};
diff --git a/yarn.lock b/yarn.lock
index 238f6c47..c42b451b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7639,13 +7639,6 @@ intl-messageformat@^10.5.14:
"@formatjs/icu-messageformat-parser" "2.11.4"
tslib "^2.8.0"
-invariant@^2.2.4:
- version "2.2.4"
- resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
- integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
- dependencies:
- loose-envify "^1.0.0"
-
ip-address@^10.0.1:
version "10.1.0"
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.1.0.tgz#d8dcffb34d0e02eb241427444a6e23f5b0595aa4"
@@ -8996,7 +8989,7 @@ long@^5.0.0:
resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83"
integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==
-loose-envify@^1.0.0, loose-envify@^1.4.0:
+loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -10564,11 +10557,6 @@ react-fast-compare@^2.0.1:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==
-react-fast-compare@^3.2.2:
- version "3.2.2"
- resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
- integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
-
react-ga4@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/react-ga4/-/react-ga4-2.1.0.tgz#56601f59d95c08466ebd6edfbf8dede55c4678f9"
@@ -10582,15 +10570,6 @@ react-google-recaptcha@^3.1.0:
prop-types "^15.5.0"
react-async-script "^1.2.0"
-react-helmet-async@^2.0.5:
- version "2.0.5"
- resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-2.0.5.tgz#cfc70cd7bb32df7883a8ed55502a1513747223ec"
- integrity sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==
- dependencies:
- invariant "^2.2.4"
- react-fast-compare "^3.2.2"
- shallowequal "^1.1.0"
-
react-hook-form@^7.52.1:
version "7.71.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.71.1.tgz#6a758958861682cf0eb22131eead684ba3618f66"
@@ -11199,11 +11178,6 @@ setprototypeof@1.2.0, setprototypeof@~1.2.0:
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
-shallowequal@^1.1.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
- integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==
-
sharp@^0.34.4:
version "0.34.5"
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.5.tgz#b6f148e4b8c61f1797bde11a9d1cfebbae2c57b0"
@@ -11831,10 +11805,10 @@ tar-stream@^3.0.0:
fast-fifo "^1.2.0"
streamx "^2.15.0"
-tar@^7.5.2:
- version "7.5.6"
- resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.6.tgz#2db7a210748a82f0a89cc31527b90d3a24984fb7"
- integrity sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==
+tar@^7.5.2, tar@^7.5.7:
+ version "7.5.7"
+ resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.7.tgz#adf99774008ba1c89819f15dbd6019c630539405"
+ integrity sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==
dependencies:
"@isaacs/fs-minipass" "^4.0.0"
chownr "^3.0.0"