diff --git a/cypress/e2e/addFeedForm.cy.ts b/cypress/e2e/addFeedForm.cy.ts
index c3163c2c..80af09bb 100644
--- a/cypress/e2e/addFeedForm.cy.ts
+++ b/cypress/e2e/addFeedForm.cy.ts
@@ -14,7 +14,7 @@ describe('Add Feed Form', () => {
);
cy.get('[data-cy="accountHeader"]').should('exist'); // assures that the user is signed in
- cy.visit('/contribute');
+ cy.get('[data-cy="header-add-a-feed"]').click();
// Assures that the firebase remote config has loaded for the first test
// Optimizations can be made to make the first test run faster
// Long timeout is to assure no flakiness
diff --git a/cypress/e2e/changepassword.cy.ts b/cypress/e2e/changepassword.cy.ts
index 33dd24fd..0941f0a6 100644
--- a/cypress/e2e/changepassword.cy.ts
+++ b/cypress/e2e/changepassword.cy.ts
@@ -7,8 +7,9 @@ describe('Change Password Screen', () => {
cy.visit('/');
cy.get('[data-testid="home-title"]').should('exist');
cy.createNewUserAndSignIn(email, currentPassword);
- cy.get('[data-cy="accountHeader"]').should('exist'); // assures that the user is signed in
- cy.visit('/change-password');
+ cy.get('[data-cy="accountHeader"]').should('exist').click(); // assures that the user is signed in
+ cy.get('[data-cy="accountDetailsHeader"]').should('exist').click();
+ cy.get('[data-cy="changePasswordButton"]').should('exist').click();
});
it('should render components', () => {
@@ -51,7 +52,7 @@ describe('Change Password Screen', () => {
// logout
cy.get('[data-cy="signOutButton"]').click();
- cy.get('[data-cy="confirmSignOutButton"]').should('exist').click();
+ cy.get('[data-cy="confirmSignOutButton"]').should('exist').should('not.be.disabled').click();
cy.visit('/sign-in');
cy.get('[data-cy="signInEmailInput"]').type(email);
cy.get('[data-cy="signInPasswordInput"]').type(newPassword);
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..58575a08 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -2,7 +2,7 @@
import './App.css';
import AppRouter from './router/Router';
-import { BrowserRouter } from 'react-router-dom';
+import { MemoryRouter } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { anonymousLogin } from './store/profile-reducer';
import { app } from '../firebase';
@@ -10,12 +10,36 @@ 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';
+import { usePathname, useSearchParams } from 'next/navigation';
-function App(): React.ReactElement {
+interface AppProps {
+ locale?: string;
+}
+
+// Helper function to construct the full path from Next.js routing
+function buildPathFromNextRouter(
+ pathname: string,
+ searchParams: URLSearchParams,
+ locale?: string,
+): string {
+ const cleanPath =
+ locale != null && locale !== 'en'
+ ? (pathname.replace(`/${locale}`, '') ?? '/')
+ : pathname;
+
+ const searchString = searchParams.toString();
+ return searchString !== '' ? `${cleanPath}?${searchString}` : cleanPath;
+}
+
+function App({ locale }: AppProps): React.ReactElement {
const dispatch = useDispatch();
const [isAppReady, setIsAppReady] = useState(false);
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const initialPath = buildPathFromNextRouter(pathname, searchParams, locale);
+
useEffect(() => {
app.auth().onAuthStateChanged((user) => {
if (user != null) {
@@ -29,24 +53,15 @@ function App(): React.ReactElement {
}, [dispatch]);
return (
-
-
-
-
-
-
- {/* BrowserRouter will be deprecated in favor of Next AppRouter */}
-
- {isAppReady ? : null}
-
-
-
-
+
+
+ {/* MemoryRouter will be deprecated in favor of Next AppRouter */}
+ {/* MemoryRouter synced with Next.js routing via RouterSync component */}
+
+ {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..d27f7582
--- /dev/null
+++ b/src/app/[locale]/[...slug]/page.tsx
@@ -0,0 +1,40 @@
+'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, useEffect } from 'react';
+import dynamic from 'next/dynamic';
+import { PersistGate } from 'redux-persist/integration/react';
+import { persistStore } from 'redux-persist';
+import { store } from '../../store/store';
+import { useAppDispatch } from '../../hooks';
+import { resetProfileErrors } from '../../store/profile-reducer';
+
+const App = dynamic(async () => await import('../../App'), { ssr: false });
+
+const persistor = persistStore(store);
+
+interface PageProps {
+ params: Promise<{
+ locale: string;
+ slug: string[];
+ }>;
+}
+
+export default function Page({ params }: PageProps): ReactNode {
+ const { locale } = use(params);
+ const pathKey = use(params).slug?.join('/') ?? '/';
+ const dispatch = useAppDispatch();
+
+ useEffect(() => {
+ // Clean errors from previous session
+ dispatch(resetProfileErrors());
+ }, [dispatch]);
+
+ // 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 54%
rename from src/app/about/page.tsx
rename to src/app/[locale]/about/components/AboutPage.tsx
index e13f6015..bdf1aac9 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..e0241959
--- /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 Locale, routing } from '../../../i18n/routing';
+import AboutPage from './components/AboutPage';
+
+export const dynamic = 'force-static';
+
+export function generateStaticParams(): Array<{
+ locale: Locale;
+}> {
+ return routing.locales.map((locale) => ({ locale }));
+}
+
+interface PageProps {
+ params: Promise<{ locale: string }>;
+}
+
+export default async function 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 96%
rename from src/app/feeds/[feedDataType]/[feedId]/page.tsx
rename to src/app/[locale]/feeds/[feedDataType]/[feedId]/page.tsx
index 146e37c8..0850dce2 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,24 +9,25 @@ import {
getGtfsFeedDatasets,
getGtfsFeedRoutes,
getGtfsFeedAssociatedGtfsRtFeeds,
-} from '../../../services/feeds';
+} from '../../../../services/feeds';
import { notFound } from 'next/navigation';
import type { Metadata, ResolvingMetadata } from 'next';
-import {
- getSSRAccessToken,
- getUserContextJwtFromCookie,
-} 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';
+import {
+ getSSRAccessToken,
+ getUserContextJwtFromCookie,
+} from '../../../../utils/auth-server';
interface Props {
params: Promise<{ feedDataType: string; feedId: string }>;
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..15d2b089
--- /dev/null
+++ b/src/app/[locale]/layout.tsx
@@ -0,0 +1,120 @@
+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 Locale, 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: Locale }> {
+ return routing.locales.map((locale) => ({ locale }));
+}
+
+interface LocaleLayoutProps {
+ children: React.ReactNode;
+ params: Promise<{ locale: string }>;
+}
+
+/**
+ * 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 and narrow the type
+ if (!hasLocale(routing.locales, locale)) {
+ notFound();
+ }
+
+ // At this point, locale is guaranteed to be a valid Locale type
+ const validLocale = locale as Locale;
+
+ // Enable static rendering for this locale
+ setRequestLocale(validLocale);
+
+ const [messages, remoteConfig] = await Promise.all([
+ getMessages(),
+ getRemoteConfigValues(),
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+ {process.env.VERCEL_ENV === 'production' && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ );
+}
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..672f3a59 100644
--- a/src/app/components/Context.tsx
+++ b/src/app/components/Context.tsx
@@ -1,42 +1,20 @@
'use client';
-import React, { useEffect } from 'react';
+import React from 'react';
import type ContextProviderProps from '../interface/ContextProviderProps';
import { Provider } from 'react-redux';
import { store } from '../store/store';
-import { PersistGate } from 'redux-persist/integration/react';
-import { persistStore } from 'redux-persist';
-import { useAppDispatch } from '../hooks';
-import { resetProfileErrors } from '../store/profile-reducer';
-const persistor = persistStore(store);
/**
- * This component is used to wrap the entire application
- */
-const AppContent: React.FC = ({ children }) => {
- const dispatch = useAppDispatch();
-
- useEffect(() => {
- // This function will run when the component is first loaded
- // Clean errros from previous session
- dispatch(resetProfileErrors());
- }, []);
- return (
-
- {children}
-
- );
-};
-
-/**
- * This component is used to wrap the entire application adding the store provider and reseting the errors from previous session
+ * This component is used to wrap the entire application with Redux Provider
+ * It provides the Redux store to all components in the app
+ * IMPORTANT: This does not include the PersistGate, which is used in the page component to delay rendering until the store is rehydrated
+ * This allows for a fast initial render, but makes it possible for components to access the store before it's fully rehydrated.
+ * This also allows for us to have SSG pages that use Redux. It also allows for these pages to be rendered purely in HTML on the server side without waiting for Redux Persist to rehydrate the store.
+ * Use the `useRehydrated` hook to check rehydration status if needed.
*/
const ContextProviders: React.FC = ({ children }) => {
- return (
-
- {children}
-
- );
+ return {children};
};
export default ContextProviders;
diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx
index 50059614..be910eeb 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,
@@ -14,7 +15,6 @@ import {
MenuItem,
Select,
useTheme,
- Link,
Alert,
AlertTitle,
} from '@mui/material';
@@ -30,8 +30,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,25 +40,52 @@ 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';
-import {
- selectIsAuthenticated,
- selectUserEmail,
-} from '../store/profile-selectors';
+import Link from 'next/link';
+import { app } from '../../firebase';
+
+// 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 [currentUser, setCurrentUser] = React.useState<
+ { email: string; isAuthenticated: boolean } | undefined
+ >(undefined);
+ 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 +95,29 @@ export default function DrawerAppBar(): React.ReactElement {
const { config } = useRemoteConfig();
const t = useTranslations('common');
+ React.useEffect(() => {
+ const auth = app.auth();
+ const unsubscribe = auth.onAuthStateChanged(async (user) => {
+ if (user != null) {
+ setCurrentUser({
+ email: user.email ?? '',
+ isAuthenticated: !user.isAnonymous,
+ });
+ } else {
+ setCurrentUser(undefined);
+ }
+ });
+ return () => {
+ unsubscribe();
+ };
+ }, []);
+
+ React.useEffect(() => {
+ if (hasTransitFeedsRedirectParam) {
+ setHasTransitFeedsRedirect(true);
+ }
+ }, [hasTransitFeedsRedirectParam]);
+
React.useEffect(() => {
setActiveTab(pathname ?? '');
}, [pathname]);
@@ -77,8 +127,9 @@ export default function DrawerAppBar(): React.ReactElement {
}, [config]);
const router = useRouter();
- const isAuthenticated = useSelector(selectIsAuthenticated);
- const userEmail = useSelector(selectUserEmail);
+
+ const isAuthenticated = currentUser != null && currentUser.isAuthenticated;
+ const userEmail = currentUser?.email;
const handleDrawerToggle = (): void => {
setMobileOpen((prevState) => !prevState);
@@ -100,7 +151,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);
@@ -150,7 +201,7 @@ export default function DrawerAppBar(): React.ReactElement {
>
-
-
-
-
-
-
+
Mobility Database
-
+
{navigationItems.map((item) => (
-
+
+
))}
{config.gbfsValidator && (
<>
@@ -357,6 +406,7 @@ export default function DrawerAppBar(): React.ReactElement {
onClose={handleMenuClose}
>
-
- );
-}
-
-export default function Home(): React.ReactElement {
- return ;
-}
diff --git a/src/app/screens/SignIn.tsx b/src/app/screens/SignIn.tsx
index a1b1d28d..55c0c655 100644
--- a/src/app/screens/SignIn.tsx
+++ b/src/app/screens/SignIn.tsx
@@ -9,7 +9,7 @@ import Container from '@mui/material/Container';
import GoogleIcon from '@mui/icons-material/Google';
import GitHubIcon from '@mui/icons-material/GitHub';
import AppleIcon from '@mui/icons-material/Apple';
-import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useRouter, useSearchParams } from 'next/navigation';
import { useAppDispatch } from '../hooks';
import {
login,
@@ -50,14 +50,14 @@ import { VisibilityOffOutlined, VisibilityOutlined } from '@mui/icons-material';
export default function SignIn(): React.ReactElement {
const dispatch = useAppDispatch();
- const navigateTo = useNavigate();
+ const router = useRouter();
const theme = useTheme();
const userProfileStatus = useSelector(selectUserProfileStatus);
const emailLoginError = useSelector(selectEmailLoginError);
const [isSubmitted, setIsSubmitted] = React.useState(false);
const [showPassword, setShowPassword] = React.useState(false);
const [showNoEmailSnackbar, setShowNoEmailSnackbar] = React.useState(false);
- const [searchParams] = useSearchParams();
+ const searchParams = useSearchParams();
const SignInSchema = Yup.object().shape({
email: Yup.string()
@@ -92,18 +92,18 @@ export default function SignIn(): React.ReactElement {
React.useEffect(() => {
if (userProfileStatus === 'registered') {
if (searchParams.has('add_feed')) {
- navigateTo(ADD_FEED_TARGET, { state: { from: 'registration' } });
+ router.push(ADD_FEED_TARGET);
} else {
- navigateTo(ACCOUNT_TARGET);
+ router.push(ACCOUNT_TARGET);
}
}
if (userProfileStatus === 'authenticated') {
- navigateTo(COMPLETE_REGISTRATION_TARGET + '?' + searchParams.toString());
+ router.push(COMPLETE_REGISTRATION_TARGET + '?' + searchParams.toString());
}
if (userProfileStatus === 'unverified') {
- navigateTo(POST_REGISTRATION_TARGET + '?' + searchParams.toString());
+ router.push(POST_REGISTRATION_TARGET + '?' + searchParams.toString());
}
- }, [userProfileStatus]);
+ }, [userProfileStatus, router, searchParams]);
const signInWithProvider = (oauthProvider: OauthProvider): void => {
const auth = getAuth();
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/index.css b/src/index.css
deleted file mode 100644
index ec2585e8..00000000
--- a/src/index.css
+++ /dev/null
@@ -1,13 +0,0 @@
-body {
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
-}
diff --git a/src/index.tsx b/src/index.tsx
deleted file mode 100644
index 1b3ade5a..00000000
--- a/src/index.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { SentryErrorBoundary } from './sentry';
-import SentryErrorFallback from './app/components/SentryErrorFallback';
-import React from 'react';
-import ReactDOM from 'react-dom/client';
-import './index.css';
-import App from './app/App';
-import ReactGA from 'react-ga4';
-import { getEnvConfig } from './app/utils/config';
-import ContextProviders from './app/components/Context';
-import { CssBaseline } from '@mui/material';
-import { ThemeProvider } from './app/context/ThemeProvider';
-
-const gaId = getEnvConfig('NEXT_PUBLIC_GOOGLE_ANALYTICS_ID');
-if (gaId.length > 0) {
- ReactGA.initialize(gaId);
- ReactGA.send('pageview');
-}
-
-const root = ReactDOM.createRoot(
- document.getElementById('root') as HTMLElement,
-);
-root.render(
-
-
-
-
- (
-
- )}
- showDialog
- >
-
-
-
-
- ,
-);
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
index 80f87426..0f826fe3 100644
--- a/src/mocks/handlers.ts
+++ b/src/mocks/handlers.ts
@@ -40,6 +40,11 @@ export const handlers = [
return HttpResponse.json(feedJson);
}),
+ // Mock GET /v1/feeds/{id} - basic feed info
+ http.get(`*/v1/feeds/*/test-516`, () => {
+ return HttpResponse.json(feedJson);
+ }),
+
// Mock GET /v1/gtfs_feeds/{id} - GTFS specific feed info
http.get(`*/v1/gtfs_feeds/test-516`, () => {
return HttpResponse.json(gtfsFeedJson);
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"