Skip to content

Commit 97a5b4c

Browse files
Merge pull request #6 from MobilityData/feat/1554-landing-page-optimization
Feat: landing page ssr + seo optimization
2 parents ba8264e + c20de07 commit 97a5b4c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1027
-721
lines changed

cypress/e2e/addFeedForm.cy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('Add Feed Form', () => {
1414
);
1515

1616
cy.get('[data-cy="accountHeader"]').should('exist'); // assures that the user is signed in
17-
cy.visit('/contribute');
17+
cy.get('[data-cy="header-add-a-feed"]').click();
1818
// Assures that the firebase remote config has loaded for the first test
1919
// Optimizations can be made to make the first test run faster
2020
// Long timeout is to assure no flakiness

cypress/e2e/changepassword.cy.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ describe('Change Password Screen', () => {
77
cy.visit('/');
88
cy.get('[data-testid="home-title"]').should('exist');
99
cy.createNewUserAndSignIn(email, currentPassword);
10-
cy.get('[data-cy="accountHeader"]').should('exist'); // assures that the user is signed in
11-
cy.visit('/change-password');
10+
cy.get('[data-cy="accountHeader"]').should('exist').click(); // assures that the user is signed in
11+
cy.get('[data-cy="accountDetailsHeader"]').should('exist').click();
12+
cy.get('[data-cy="changePasswordButton"]').should('exist').click();
1213
});
1314

1415
it('should render components', () => {
@@ -51,7 +52,7 @@ describe('Change Password Screen', () => {
5152

5253
// logout
5354
cy.get('[data-cy="signOutButton"]').click();
54-
cy.get('[data-cy="confirmSignOutButton"]').should('exist').click();
55+
cy.get('[data-cy="confirmSignOutButton"]').should('exist').should('not.be.disabled').click();
5556
cy.visit('/sign-in');
5657
cy.get('[data-cy="signInEmailInput"]').type(email);
5758
cy.get('[data-cy="signInPasswordInput"]').type(newPassword);

docs/ssg-initial-flow.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
```mermaid
2+
sequenceDiagram
3+
autonumber
4+
actor U as User
5+
participant B as Browser
6+
participant CDN as CDN/Edge Cache
7+
participant Next as Next.js Build (CI)
8+
participant I18N as next-intl (generateStaticParams)
9+
participant RC as Firebase Remote Config (build-time)
10+
participant JS as Client JS (Header component)
11+
12+
rect rgb(235, 245, 255)
13+
note over Next,RC: BUILD TIME (SSG)
14+
Next->>I18N: generateStaticParams() → all locales
15+
I18N-->>Next: locale list
16+
Next->>I18N: Build locale message bundles (baked)
17+
Next->>RC: Fetch Remote Config snapshot (baked)
18+
RC-->>Next: Remote Config values
19+
Next->>Next: Render pages to static HTML
20+
Next->>Next: Extract critical MUI styles (Emotion)
21+
note over Next: <style data-emotion="mui-*"> inlined in HTML
22+
Next->>Next: Emit JS/CSS assets (Header client bundle)
23+
Next->>CDN: Deploy static HTML + assets
24+
end
25+
26+
rect rgb(245, 245, 245)
27+
note over U,CDN: RUNTIME (User request)
28+
U->>B: Navigate to /{locale}/page
29+
B->>CDN: GET /{locale}/page
30+
CDN-->>B: 200 Static HTML (SSG + inline MUI styles)
31+
32+
note over B: ✅ Styled content is painted\n(LCP occurs here)
33+
34+
B->>CDN: GET external CSS (non-critical)
35+
CDN-->>B: CSS files
36+
B->>CDN: GET JS bundle(s)
37+
CDN-->>B: JS
38+
39+
note over B: React hydration begins
40+
B->>JS: Hydrate client components
41+
note over JS: ✅ Header hydration completes\n(interactivity enabled)
42+
end
43+
```

messages/en.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,5 +471,37 @@
471471
"nearby_stations": "Nearby Stations"
472472
},
473473
"unableToDetectVersions": "Unable to detect versions within this feed."
474+
},
475+
"home": {
476+
"title": "Explore and Access Global Transit Data",
477+
"servingOver": "Currently serving over",
478+
"feeds": "transportation data feeds from over",
479+
"fromOver": "from over",
480+
"countries": "countries.",
481+
"or": "or",
482+
"browseFeeds": "Browse Feeds",
483+
"addFeed": "Add a feed",
484+
"signUpApi": "Sign up for the API",
485+
"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.",
486+
"validatorIntro": "Our database integrates with",
487+
"gtfsValidator": "the Canonical GTFS Schedule Validator",
488+
"and": "and",
489+
"gbfsValidator": "the GBFS Validator",
490+
"validatorOutro": "to provide detailed data quality reports on every feed."
491+
},
492+
"about": {
493+
"title": "About",
494+
"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.",
495+
"learnMore": "Learn more about MobilityData",
496+
"whyUse": "Why Use the Mobility Database?",
497+
"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.",
498+
"gtfsValidator": "the Canonical GTFS Schedule Validator",
499+
"gbfsValidator": "the GBFS Validator.",
500+
"benefits": {
501+
"mirrored": "Mirrored versions of operator-hosted GTFS Schedule feeds to avoid operator website downtimes and geoblocking",
502+
"boundingBoxes": "Bounding boxes that help to visualize or filter in the API by a select region",
503+
"addFeeds": "A simple, easy-to-use form to add new feeds",
504+
"openSource": "An open source community actively working to improve the tools"
505+
}
474506
}
475507
}

messages/fr.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,5 +471,37 @@
471471
"nearby_stations": "Nearby Stations"
472472
},
473473
"unableToDetectVersions": "Unable to detect versions within this feed."
474+
},
475+
"home": {
476+
"title": "Explorez et accédez aux données de transport mondiales",
477+
"servingOver": "Actuellement plus de",
478+
"feeds": "flux de données de transport de plus de",
479+
"fromOver": "de plus de",
480+
"countries": "pays.",
481+
"or": "ou",
482+
"browseFeeds": "Parcourir les flux",
483+
"addFeed": "Ajouter un flux",
484+
"signUpApi": "S'inscrire à l'API",
485+
"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.",
486+
"validatorIntro": "Notre base de données s'intègre avec",
487+
"gtfsValidator": "le validateur canonique GTFS Schedule",
488+
"and": "et",
489+
"gbfsValidator": "le validateur GBFS",
490+
"validatorOutro": "pour fournir des rapports détaillés sur la qualité des données de chaque flux."
491+
},
492+
"about": {
493+
"title": "À propos",
494+
"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.",
495+
"learnMore": "En savoir plus sur MobilityData",
496+
"whyUse": "Pourquoi utiliser la Mobility Database ?",
497+
"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.",
498+
"gtfsValidator": "le validateur canonique GTFS Schedule",
499+
"gbfsValidator": "le validateur GBFS.",
500+
"benefits": {
501+
"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",
502+
"boundingBoxes": "Boîtes englobantes pour visualiser ou filtrer par région sélectionnée dans l'API",
503+
"addFeeds": "Un formulaire simple et facile à utiliser pour ajouter de nouveaux flux",
504+
"openSource": "Une communauté open source travaillant activement à améliorer les outils"
505+
}
474506
}
475507
}

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
"react-draggable": "^4.5.0",
4242
"react-ga4": "^2.1.0",
4343
"react-google-recaptcha": "^3.1.0",
44-
"react-helmet-async": "^2.0.5",
4544
"react-hook-form": "^7.52.1",
4645
"react-leaflet": "^4.2.1",
4746
"react-map-gl": "^8.0.4",
@@ -56,6 +55,7 @@
5655
},
5756
"scripts": {
5857
"build:prod": "next build",
58+
"build:analyze": "next experimental-analyze",
5959
"start:dev": "next dev",
6060
"start:dev:mock": "NEXT_PUBLIC_API_MOCKING=enabled next dev -p 3001",
6161
"start:prod": "next build && next start",
@@ -73,6 +73,9 @@
7373
"generate:gbfs-validator-types:output": "npm exec -- openapi-typescript ./external_types/GbfsValidator.yaml -o $npm_config_output_path && eslint $npm_config_output_path --fix",
7474
"generate:gbfs-validator-types": "npm run generate:gbfs-validator-types:output -- --output-file=src/app/services/feeds/gbfs-validator-types.ts"
7575
},
76+
"resolutions": {
77+
"tar": "^7.5.7"
78+
},
7679
"eslintConfig": {
7780
"extends": [
7881
"react-app",

src/app/App.tsx

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,44 @@
22

33
import './App.css';
44
import AppRouter from './router/Router';
5-
import { BrowserRouter } from 'react-router-dom';
5+
import { MemoryRouter } from 'react-router-dom';
66
import { useDispatch } from 'react-redux';
77
import { anonymousLogin } from './store/profile-reducer';
88
import { app } from '../firebase';
99
import { Suspense, useEffect, useState } from 'react';
1010
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
1111
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
1212
import AppContainer from './AppContainer';
13-
import { Helmet, HelmetProvider } from 'react-helmet-async';
13+
import { usePathname, useSearchParams } from 'next/navigation';
1414

15-
function App(): React.ReactElement {
15+
interface AppProps {
16+
locale?: string;
17+
}
18+
19+
// Helper function to construct the full path from Next.js routing
20+
function buildPathFromNextRouter(
21+
pathname: string,
22+
searchParams: URLSearchParams,
23+
locale?: string,
24+
): string {
25+
const cleanPath =
26+
locale != null && locale !== 'en'
27+
? (pathname.replace(`/${locale}`, '') ?? '/')
28+
: pathname;
29+
30+
const searchString = searchParams.toString();
31+
return searchString !== '' ? `${cleanPath}?${searchString}` : cleanPath;
32+
}
33+
34+
function App({ locale }: AppProps): React.ReactElement {
1635
const dispatch = useDispatch();
1736
const [isAppReady, setIsAppReady] = useState(false);
1837

38+
const pathname = usePathname();
39+
const searchParams = useSearchParams();
40+
41+
const initialPath = buildPathFromNextRouter(pathname, searchParams, locale);
42+
1943
useEffect(() => {
2044
app.auth().onAuthStateChanged((user) => {
2145
if (user != null) {
@@ -29,24 +53,15 @@ function App(): React.ReactElement {
2953
}, [dispatch]);
3054

3155
return (
32-
<HelmetProvider>
33-
<Helmet>
34-
<meta
35-
name='description'
36-
content={
37-
"Access GTFS, GTFS Realtime, GBFS transit data with over 4,000 feeds from 70+ countries on the web's leading transit data platform."
38-
}
39-
/>
40-
</Helmet>
41-
<Suspense>
42-
<LocalizationProvider dateAdapter={AdapterDayjs}>
43-
{/* BrowserRouter will be deprecated in favor of Next AppRouter */}
44-
<BrowserRouter>
45-
<AppContainer>{isAppReady ? <AppRouter /> : null}</AppContainer>
46-
</BrowserRouter>
47-
</LocalizationProvider>
48-
</Suspense>
49-
</HelmetProvider>
56+
<Suspense>
57+
<LocalizationProvider dateAdapter={AdapterDayjs}>
58+
{/* MemoryRouter will be deprecated in favor of Next AppRouter */}
59+
{/* MemoryRouter synced with Next.js routing via RouterSync component */}
60+
<MemoryRouter initialEntries={[initialPath]}>
61+
<AppContainer>{isAppReady ? <AppRouter /> : null}</AppContainer>
62+
</MemoryRouter>
63+
</LocalizationProvider>
64+
</Suspense>
5065
);
5166
}
5267

src/app/AppContainer.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,19 @@ import * as React from 'react';
44
import { Box, LinearProgress } from '@mui/material';
55
import type ContextProviderProps from './interface/ContextProviderProps';
66
import { useLocation } from 'react-router-dom';
7-
import { Helmet } from 'react-helmet-async';
87
import { selectLoadingApp } from './store/selectors';
98
import { useSelector } from 'react-redux';
109

1110
const AppContainer: React.FC<ContextProviderProps> = ({ children }) => {
1211
const isAppLoading = useSelector(selectLoadingApp);
1312
const location = useLocation();
14-
const canonicalUrl = window.location.origin + location.pathname;
1513

1614
React.useLayoutEffect(() => {
1715
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
1816
}, [location.pathname]);
1917

2018
return (
2119
<>
22-
<Helmet>
23-
<link rel='canonical' href={canonicalUrl} />
24-
</Helmet>
2520
<Box id='app-main-container'>
2621
{isAppLoading ? (
2722
<Box sx={{ width: '100%', mt: '-31px' }}>

src/app/[[...slug]]/page.tsx

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client';
2+
3+
// This page is temporary to ease the migration to Next.js App Router
4+
// It will be deprecated once the migration is fully complete
5+
import { type ReactNode, use, useEffect } from 'react';
6+
import dynamic from 'next/dynamic';
7+
import { PersistGate } from 'redux-persist/integration/react';
8+
import { persistStore } from 'redux-persist';
9+
import { store } from '../../store/store';
10+
import { useAppDispatch } from '../../hooks';
11+
import { resetProfileErrors } from '../../store/profile-reducer';
12+
13+
const App = dynamic(async () => await import('../../App'), { ssr: false });
14+
15+
const persistor = persistStore(store);
16+
17+
interface PageProps {
18+
params: Promise<{
19+
locale: string;
20+
slug: string[];
21+
}>;
22+
}
23+
24+
export default function Page({ params }: PageProps): ReactNode {
25+
const { locale } = use(params);
26+
const pathKey = use(params).slug?.join('/') ?? '/';
27+
const dispatch = useAppDispatch();
28+
29+
useEffect(() => {
30+
// Clean errors from previous session
31+
dispatch(resetProfileErrors());
32+
}, [dispatch]);
33+
34+
// Pass locale to App so BrowserRouter can use correct basename
35+
return (
36+
<PersistGate loading={null} persistor={persistor}>
37+
<App locale={locale} key={pathKey} />;
38+
</PersistGate>
39+
);
40+
}

0 commit comments

Comments
 (0)