diff --git a/cypress/e2e/changepassword.cy.ts b/cypress/e2e/changepassword.cy.ts index 0941f0a6..1b804bca 100644 --- a/cypress/e2e/changepassword.cy.ts +++ b/cypress/e2e/changepassword.cy.ts @@ -22,7 +22,7 @@ describe('Change Password Screen', () => { cy.get('input[id="currentPassword"]').type('wrong'); cy.get('input[id="newPassword"]').type(newPassword); cy.get('input[id="confirmNewPassword"]').type(newPassword); - cy.get('button[type="submit"]').click(); + cy.get('[data-cy="changePasswordButton"]').click(); cy.contains( 'The password is invalid or the user does not have a password. (auth/wrong-password).', ).should('exist'); @@ -44,7 +44,7 @@ describe('Change Password Screen', () => { cy.get('input[id="currentPassword"]').type(currentPassword); cy.get('input[id="newPassword"]').type(newPassword); cy.get('input[id="confirmNewPassword"]').type(newPassword); - cy.get('button[type="submit"]').click(); + cy.get('[data-cy="changePasswordButton"]').click(); cy.contains('Change Password Succeeded').should('exist'); cy.get('[cy-data="goToAccount"]').click(); diff --git a/cypress/e2e/resetpassword.cy.ts b/cypress/e2e/resetpassword.cy.ts index 1caca269..580081b2 100644 --- a/cypress/e2e/resetpassword.cy.ts +++ b/cypress/e2e/resetpassword.cy.ts @@ -9,14 +9,14 @@ describe('Reset Password Screen', () => { it('should show error when email no email is provided', () => { cy.get('input[id="email"]').type('not an email', { force: true }); - cy.get('[type="submit"]').click(); + cy.get('[data-cy="submitResetPasswordButton"]').click(); cy.get('[data-testid=emailError]').should('exist'); }); it('should show the captcha error when is not accepted', () => { cy.get('iframe[title="reCAPTCHA"]').should('exist'); cy.get('input[id="email"]').type('notvalid@e.c', { force: true }); - cy.get('[type="submit"]').click(); + cy.get('[data-cy="submitResetPasswordButton"]').click(); cy.get('[data-testid=reCaptchaError]') .should('exist') .contains('You must verify you are not a robot.'); diff --git a/messages/en.json b/messages/en.json index d0dafa4c..e1fd815a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -105,7 +105,16 @@ "showMore": "Show {count} more", "moreInfo": "More Info", "license": "License", - "tags": "Tags" + "tags": "Tags", + "openDrawer": "Open drawer", + "mobilityDatabaseHome": "Mobility Database home", + "accountMenu": "Account menu", + "accountDetails": "Account Details", + "login": "Login", + "metricsAdminOnly": "Metrics - Admin Only", + "languageSelect": "Language select", + "transitFeedsRedirectTitle": "You've been redirected from TransitFeeds", + "transitFeedsRedirectBody": "This page now lives on MobilityDatabase.org, where you'll find the most up-to-date transit data." }, "feeds": { "feeds": "Feeds", @@ -513,5 +522,36 @@ "addFeeds": "A simple, easy-to-use form to add new feeds", "openSource": "An open source community actively working to improve the tools" } + }, + "footer": { + "tagline": "An open catalog of transit and mobility data feeds, serving the global transportation community.", + "maintainedBy": "Maintained with 💙 by MobilityData", + "copyright": "© {year} MobilityDatabase. All rights reserved.", + "columns": { + "platform": "Platform", + "validators": "Validators", + "company": "Company", + "legal": "Legal" + }, + "links": { + "feeds": "Feeds", + "addFeed": "Add a Feed", + "apiDocs": "API Docs", + "gtfsValidator": "GTFS Validator", + "gtfsRtValidator": "GTFS-RT Validator", + "gbfsValidator": "GBFS Validator", + "about": "About", + "faq": "FAQ", + "contactUs": "Contact Us", + "shareFeedback": "Share Feedback", + "privacyPolicy": "Privacy Policy", + "termsAndConditions": "Terms and Conditions" + }, + "aria": { + "github": "GitHub", + "slack": "Slack", + "linkedin": "LinkedIn", + "logo": "MobilityDatabase logo" + } } } diff --git a/messages/fr.json b/messages/fr.json index f5f4b3c7..b65bfd9d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -105,7 +105,16 @@ "showMore": "Show {count} more", "moreInfo": "More Info", "license": "License", - "tags": "Tags" + "tags": "Tags", + "openDrawer": "Ouvrir le menu", + "mobilityDatabaseHome": "Accueil de Mobility Database", + "accountMenu": "Menu du compte", + "accountDetails": "Détails du compte", + "login": "Connexion", + "metricsAdminOnly": "Métriques - Admin uniquement", + "languageSelect": "Sélection de la langue", + "transitFeedsRedirectTitle": "Vous avez été redirigé depuis TransitFeeds", + "transitFeedsRedirectBody": "Cette page se trouve désormais sur MobilityDatabase.org, où vous trouverez les données de transit les plus récentes." }, "feeds": { "feeds": "Feeds", @@ -513,5 +522,36 @@ "addFeeds": "Un formulaire simple et facile à utiliser pour ajouter de nouveaux flux", "openSource": "Une communauté open source travaillant activement à améliorer les outils" } + }, + "footer": { + "tagline": "Le plus grand catalogue ouvert de flux de données de transit et de mobilité, au service de la communauté mondiale des transports.", + "maintainedBy": "Maintenu avec 💙 par MobilityData", + "copyright": "© {year} MobilityDatabase. Tous droits réservés.", + "columns": { + "platform": "Plateforme", + "validators": "Validateurs", + "company": "Entreprise", + "legal": "Légal" + }, + "links": { + "feeds": "Flux", + "addFeed": "Ajouter un flux", + "apiDocs": "Docs API", + "gtfsValidator": "Validateur GTFS", + "gtfsRtValidator": "Validateur GTFS-RT", + "gbfsValidator": "Validateur GBFS", + "about": "À propos", + "faq": "FAQ", + "contactUs": "Contactez-nous", + "shareFeedback": "Donner un avis", + "privacyPolicy": "Politique de confidentialité", + "termsAndConditions": "Conditions d'utilisation" + }, + "aria": { + "github": "GitHub", + "slack": "Slack", + "linkedin": "LinkedIn", + "logo": "Logo MobilityDatabase" + } } } diff --git a/package.json b/package.json index 5a64822d..d6418cf1 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "loadashes6": "^1.0.0", "maplibre-gl": "^5.7.0", "material-react-table": "^2.13.0", - "mui-nested-menu": "3.4.0", "next": "16.1.1", "next-intl": "^4.7.0", "openapi-fetch": "^0.9.3", diff --git a/src/app/App.css b/src/app/App.css index 2299ec67..8d474624 100644 --- a/src/app/App.css +++ b/src/app/App.css @@ -42,7 +42,7 @@ position: relative; /* 100vh - header margin - header - footer - footer padding */ /* Not perfect, to revisit: for client loading state */ - min-height: calc(100vh - 32px - 64px - 232px - 20px); - padding-bottom: 20px; + min-height: calc(100vh - 32px - 64px - 302px - 48px); + box-sizing: border-box; } diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 744d0238..092c7fec 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -111,7 +111,7 @@ export default async function LocaleLayout({ component={'main'} id='next' sx={{ - minHeight: 'calc(100vh - 32px - 64px - 232px - 20px)', + minHeight: 'calc(100vh - 32px - 64px - 302px - 48px)', }} > {children} diff --git a/src/app/components/AuthSessionProvider.tsx b/src/app/components/AuthSessionProvider.tsx index 60558ecf..59621f01 100644 --- a/src/app/components/AuthSessionProvider.tsx +++ b/src/app/components/AuthSessionProvider.tsx @@ -18,12 +18,14 @@ interface AuthSession { isAuthReady: boolean; email: string | null; isAuthenticated: boolean; + displayName?: string | null; } const AuthReadyContext = createContext({ isAuthReady: false, email: null, isAuthenticated: false, + displayName: null, }); /** @@ -58,6 +60,7 @@ export function AuthSessionProvider({ isAuthReady: false, email: null, isAuthenticated: false, + displayName: null, }); const intervalRef = useRef | null>(null); @@ -73,6 +76,7 @@ export function AuthSessionProvider({ isAuthReady: true, email: user.email ?? null, isAuthenticated: !user.isAnonymous, + displayName: user.displayName ?? null, }); setUserCookieSession().catch(() => { console.error('Failed to establish session cookie'); @@ -90,7 +94,12 @@ export function AuthSessionProvider({ 5 * 60 * 1000, ); // 5 minutes } else { - setSession({ isAuthReady: false, email: null, isAuthenticated: false }); + setSession({ + isAuthReady: false, + email: null, + isAuthenticated: false, + displayName: null, + }); dispatch(anonymousLogin()); } }); diff --git a/src/app/components/Footer.tsx b/src/app/components/Footer.tsx index 4dd38a8b..0776d795 100644 --- a/src/app/components/Footer.tsx +++ b/src/app/components/Footer.tsx @@ -1,14 +1,19 @@ 'use client'; import React from 'react'; -import '../styles/Footer.css'; -import { Button, IconButton, useTheme } from '@mui/material'; +import { Box, IconButton, Typography, useTheme } from '@mui/material'; import { GitHub, LinkedIn, OpenInNew } from '@mui/icons-material'; import { MOBILITY_DATA_LINKS } from '../constants/Navigation'; import { fontFamily } from '../Theme'; +import Image from 'next/image'; +import { useTranslations } from 'next-intl'; +import { FooterLink, FooterColumnTitle } from './FooterElements'; +import { useRemoteConfig } from '../context/RemoteConfigProvider'; const Footer: React.FC = () => { - // TODO: revisit theming for SSR components const theme = useTheme(); + const t = useTranslations('footer'); + const { config } = useRemoteConfig(); + const FOOTER_COLUMN_WIDTH = '185px'; const SlackSvg = ( { viewBox='0 0 24 24' > ); + const currentYear = new Date().getFullYear(); + return ( -
- + {/* Main footer content */} + - - -
- - {SlackSvg} - - - - - - - -
-

Maintained with 💜 by MobilityData.

- - | - -
+ + + + + {t('maintainedBy')} + + + + {t('copyright', { year: currentYear })} + + + + ); }; diff --git a/src/app/components/FooterElements.tsx b/src/app/components/FooterElements.tsx new file mode 100644 index 00000000..613ac6d2 --- /dev/null +++ b/src/app/components/FooterElements.tsx @@ -0,0 +1,59 @@ +'use client'; +import React from 'react'; +import { Link, Typography, useTheme } from '@mui/material'; +import NextLink from 'next/link'; +import { fontFamily } from '../Theme'; + +interface FooterLinkProps { + href: string; + external?: boolean; + children: React.ReactNode; +} + +export const FooterLink: React.FC = ({ + href, + external, + children, +}) => { + const theme = useTheme(); + return ( + + {children} + + ); +}; +export const FooterColumnTitle: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const theme = useTheme(); + return ( + + {children} + + ); +}; diff --git a/src/app/components/Header.style.ts b/src/app/components/Header.style.ts index c1057ffc..d12fc9d6 100644 --- a/src/app/components/Header.style.ts +++ b/src/app/components/Header.style.ts @@ -16,10 +16,7 @@ export const animatedButtonStyling = ( ): SystemStyleObject => ({ minWidth: 'fit-content', px: 0, - mx: { - md: 1, - lg: 2, - }, + mx: { xs: 1.5, lg: 2 }, fontFamily: fontFamily.secondary, '&:hover, &.active': { backgroundColor: 'transparent', diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx index 3c088aea..f5f228b2 100644 --- a/src/app/components/Header.tsx +++ b/src/app/components/Header.tsx @@ -4,13 +4,15 @@ import * as React from 'react'; import dynamic from 'next/dynamic'; import { AppBar, + Avatar, Box, + Divider, Drawer, IconButton, + ListSubheader, Toolbar, Typography, Button, - ListItemIcon, Menu, MenuItem, Select, @@ -20,8 +22,6 @@ import { } from '@mui/material'; import MenuIcon from '@mui/icons-material/Menu'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; -import LogoutIcon from '@mui/icons-material/Logout'; -import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import { navigationAccountItem, SIGN_IN_TARGET, @@ -32,15 +32,13 @@ import { import type NavigationItem from '../interface/Navigation'; import { usePathname, useRouter } from 'next/navigation'; import Image from 'next/image'; -import { BikeScooterOutlined, OpenInNew } from '@mui/icons-material'; +import { OpenInNew } from '@mui/icons-material'; import { useRemoteConfig } from '../context/RemoteConfigProvider'; -import { NestedMenuItem } from 'mui-nested-menu'; -import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'; -import DepartureBoardIcon from '@mui/icons-material/DepartureBoard'; import { fontFamily } from '../Theme'; import { defaultRemoteConfigValues } from '../interface/RemoteConfig'; import { animatedButtonStyling } from './Header.style'; import ThemeToggle from './ThemeToggle'; +import HeaderSearchBar from './HeaderSearchBar'; import { useTranslations, useLocale } from 'next-intl'; import Link from 'next/link'; import { useAuthSession } from './AuthSessionProvider'; @@ -74,7 +72,11 @@ function useClientSearchParams(): URLSearchParams | null { } export default function DrawerAppBar(): React.ReactElement { - const { email: userEmail, isAuthenticated } = useAuthSession(); + const { + email: userEmail, + isAuthenticated, + displayName: userDisplayName, + } = useAuthSession(); const clientSearchParams = useClientSearchParams(); const hasTransitFeedsRedirectParam = clientSearchParams?.get('utm_source') === 'transitfeeds'; @@ -91,7 +93,7 @@ export default function DrawerAppBar(): React.ReactElement { >(buildNavigationItems(defaultRemoteConfigValues)); const locale = useLocale(); const { config } = useRemoteConfig(); - const t = useTranslations('common'); + const tCommon = useTranslations('common'); React.useEffect(() => { if (hasTransitFeedsRedirectParam) { @@ -125,27 +127,53 @@ export default function DrawerAppBar(): React.ReactElement { const handleLogoutClick = (): void => { setOpenDialog(true); - handleMenuClose(); + setAccountAnchorEl(null); }; const container = typeof window !== 'undefined' ? () => window.document.body : undefined; - const [anchorEl, setAnchorEl] = React.useState(null); + const [validatorAnchorEl, setValidatorAnchorEl] = + React.useState(null); + const validatorCloseTimer = + React.useRef>(undefined); - const handleMenuOpen = (event: React.MouseEvent): void => { - setAnchorEl(event.currentTarget); + const handleValidatorOpen = (e: React.MouseEvent): void => { + clearTimeout(validatorCloseTimer.current); + setValidatorAnchorEl(e.currentTarget); }; - const handleMenuClose = (): void => { - setAnchorEl(null); + const handleValidatorClose = (): void => { + validatorCloseTimer.current = setTimeout(() => { + setValidatorAnchorEl(null); + }, 80); }; - const handleMenuItemClick = (item: NavigationItem | string): void => { - handleMenuClose(); - handleNavigation(item); + const [accountAnchorEl, setAccountAnchorEl] = + React.useState(null); + const accountCloseTimer = + React.useRef>(undefined); + + const handleAccountOpen = (e: React.MouseEvent): void => { + clearTimeout(accountCloseTimer.current); + setAccountAnchorEl(e.currentTarget); + }; + + const handleAccountClose = (): void => { + accountCloseTimer.current = setTimeout(() => { + setAccountAnchorEl(null); + }, 80); }; + React.useEffect(() => { + return () => { + clearTimeout(validatorCloseTimer.current); + clearTimeout(accountCloseTimer.current); + }; + }, []); + + const [isSearchOpen, setIsSearchOpen] = React.useState(false); + const metricsOptionsEnabled = config.enableMetrics || userEmail?.endsWith('mobilitydata.org') === true; @@ -164,15 +192,35 @@ export default function DrawerAppBar(): React.ReactElement { sx={{ background: theme.palette.background.paper, fontFamily: fontFamily.secondary, - borderBottom: '1px solid', - borderColor: theme.palette.divider, }} > - +