From f7fff390f5ffdf06db454f888643f6ac6ed12305 Mon Sep 17 00:00:00 2001 From: Pablo Date: Tue, 19 Aug 2025 18:11:35 +0200 Subject: [PATCH 01/33] disabled sheet overlay for performance improvements --- src/components/ui/sheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 5e643c0034e..a882c1c603f 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -65,7 +65,7 @@ const SheetContent = React.forwardRef< SheetContentProps >(({ side = "right", className, ...props }, ref) => ( - + {/* - Disabled for performance reasons. See https://github.com/radix-ui/primitives/issues/1634 for details on floating element performance issues */} Date: Tue, 19 Aug 2025 18:36:27 +0200 Subject: [PATCH 02/33] calc filtered locales outside of the component --- .../LanguagePicker/useLanguagePicker.tsx | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/src/components/LanguagePicker/useLanguagePicker.tsx b/src/components/LanguagePicker/useLanguagePicker.tsx index 01733d819e0..96f529903e1 100644 --- a/src/components/LanguagePicker/useLanguagePicker.tsx +++ b/src/components/LanguagePicker/useLanguagePicker.tsx @@ -13,16 +13,18 @@ import { localeToDisplayInfo } from "./localeToDisplayInfo" import { useDisclosure } from "@/hooks/useDisclosure" import { useTranslation } from "@/hooks/useTranslation" +// Move locales computation outside component to make it stable +const FILTERED_LOCALES = filterRealLocales(LOCALES_CODES) + export const useLanguagePicker = (handleClose?: () => void) => { const { t } = useTranslation("common") const locale = useLocale() // Get the preferred language for the users browser const [navLang] = typeof navigator !== "undefined" ? navigator.languages : [] - const locales = useMemo(() => filterRealLocales(LOCALES_CODES), []) const intlLocalePreference = useMemo( () => - locales?.reduce((acc, cur) => { + FILTERED_LOCALES?.reduce((acc, cur) => { if (cur.toLowerCase() === navLang.toLowerCase()) return cur if ( navLang.toLowerCase().startsWith(cur.toLowerCase()) && @@ -31,30 +33,27 @@ export const useLanguagePicker = (handleClose?: () => void) => { return cur return acc }, "") as Lang, - [navLang, locales] + [navLang] ) - const languages = useMemo( - () => - (locales as Lang[]) - ?.map((localeOption) => { - const displayInfo = localeToDisplayInfo( - localeOption, - locale as Lang, - t - ) - const isBrowserDefault = intlLocalePreference === localeOption - return { ...displayInfo, isBrowserDefault } - }) - .sort((a, b) => { - // Always put the browser's preferred language first - if (a.localeOption === intlLocalePreference) return -1 - if (b.localeOption === intlLocalePreference) return 1 - // Otherwise, sort alphabetically by source name using localeCompare - return a.sourceName.localeCompare(b.sourceName, locale) - }) || [], - [intlLocalePreference, locale, locales, t] - ) + const languages = useMemo(() => { + // Early return if no locales + if (!FILTERED_LOCALES?.length) return [] + + return (FILTERED_LOCALES as Lang[]) + .map((localeOption) => { + const displayInfo = localeToDisplayInfo(localeOption, locale as Lang, t) + const isBrowserDefault = intlLocalePreference === localeOption + return { ...displayInfo, isBrowserDefault } + }) + .sort((a, b) => { + // Always put the browser's preferred language first + if (a.localeOption === intlLocalePreference) return -1 + if (b.localeOption === intlLocalePreference) return 1 + // Otherwise, sort alphabetically by source name using localeCompare + return a.sourceName.localeCompare(b.sourceName, locale) + }) + }, [intlLocalePreference, locale, t]) const intlLanguagePreference = languages.find( (lang) => lang.localeOption === intlLocalePreference From bfc7d853435cd2a48703fb7203dbf5bda8b222ac Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 20 Aug 2025 11:07:05 +0200 Subject: [PATCH 03/33] disable dialog overlay for performance improvements --- src/components/ui/dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 8eea2cf29a0..03f4316e1da 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -32,7 +32,7 @@ const DialogContent = React.forwardRef< React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - + {/* - Disabled for performance reasons. See https://github.com/radix-ui/primitives/issues/1634 for details on floating element performance issues */} Date: Wed, 20 Aug 2025 12:06:30 +0200 Subject: [PATCH 04/33] reorder nav to enable server rendering --- src/components/Nav/Client/index.tsx | 168 ---------------------------- src/components/Nav/DesktopNav.tsx | 58 ++++++++++ src/components/Nav/Menu/index.tsx | 15 ++- src/components/Nav/Mobile/index.tsx | 23 ++-- src/components/Nav/MobileNav.tsx | 16 +++ src/components/Nav/index.tsx | 6 +- src/components/Search/index.tsx | 16 ++- 7 files changed, 103 insertions(+), 199 deletions(-) delete mode 100644 src/components/Nav/Client/index.tsx create mode 100644 src/components/Nav/DesktopNav.tsx create mode 100644 src/components/Nav/MobileNav.tsx diff --git a/src/components/Nav/Client/index.tsx b/src/components/Nav/Client/index.tsx deleted file mode 100644 index bfaa373b389..00000000000 --- a/src/components/Nav/Client/index.tsx +++ /dev/null @@ -1,168 +0,0 @@ -"use client" - -import { useRef } from "react" -import { Languages } from "lucide-react" -import dynamic from "next/dynamic" -import { useLocale } from "next-intl" - -import SearchButton from "@/components/Search/SearchButton" -import SearchInputButton from "@/components/Search/SearchInputButton" -import { Skeleton } from "@/components/ui/skeleton" - -import { DESKTOP_LANGUAGE_BUTTON_NAME } from "@/lib/constants" - -import { Button } from "../../ui/buttons/Button" -import { useNavigation } from "../useNavigation" -import { useThemeToggle } from "../useThemeToggle" - -import { useBreakpointValue } from "@/hooks/useBreakpointValue" -import { useIsClient } from "@/hooks/useIsClient" -import { useTranslation } from "@/hooks/useTranslation" - -const LazyButton = dynamic( - () => import("../../ui/buttons/Button").then((mod) => mod.Button), - { - ssr: false, - loading: () => ( - - ), - } -) - -const Menu = dynamic(() => import("../Menu"), { - ssr: false, - loading: () => ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ), -}) - -const MobileMenuLoading = () => ( - -) - -const MobileNavMenu = dynamic(() => import("../Mobile"), { - ssr: false, - loading: MobileMenuLoading, -}) - -const SearchProvider = dynamic(() => import("../../Search"), { - ssr: false, - loading: () => ( - <> -
- - -
-
- - -
- - ), -}) - -const LanguagePicker = dynamic(() => import("../../LanguagePicker"), { - ssr: false, - loading: () => ( - // LG skeleton width approximates English "[icon] Languages EN" text width - - ), -}) - -const ClientSideNav = () => { - const { t } = useTranslation("common") - const locale = useLocale() - - const { linkSections } = useNavigation() - const { toggleColorMode, ThemeIcon, themeIconAriaLabel } = useThemeToggle() - - const languagePickerRef = useRef(null) - const isClient = useIsClient() - - // avoid rendering/adding desktop Menu version to DOM on mobile - const desktopScreenValue = useBreakpointValue({ base: false, md: true }) - - // Use fallback value during SSR to prevent hydration mismatch - // Default to false (mobile) during SSR, then use actual value on client - const desktopScreen = isClient ? desktopScreenValue : false - - return ( - <> - {desktopScreen && ( - - )} - -
- - {({ onOpen }) => { - return ( - <> - - - - {!desktopScreen && ( - - )} - - ) - }} - - - {desktopScreen && ( - - - - )} - - {desktopScreen && ( - - - - )} -
- - ) -} - -export default ClientSideNav diff --git a/src/components/Nav/DesktopNav.tsx b/src/components/Nav/DesktopNav.tsx new file mode 100644 index 00000000000..b64c375c799 --- /dev/null +++ b/src/components/Nav/DesktopNav.tsx @@ -0,0 +1,58 @@ +"use client" + +import { Languages } from "lucide-react" +import { useLocale } from "next-intl" + +import { cn } from "@/lib/utils/cn" + +import { DESKTOP_LANGUAGE_BUTTON_NAME } from "@/lib/constants" + +import LanguagePicker from "../LanguagePicker" +import Search from "../Search" +import { Button } from "../ui/buttons/Button" + +import Menu from "./Menu" +import { useThemeToggle } from "./useThemeToggle" + +import { useTranslation } from "@/hooks/useTranslation" + +export const DesktopNav = ({ className }: { className?: string }) => { + const { t } = useTranslation() + const { toggleColorMode, ThemeIcon, themeIconAriaLabel } = useThemeToggle() + const locale = useLocale() + + return ( +
+ + +
+ + + + + + + +
+
+ ) +} diff --git a/src/components/Nav/Menu/index.tsx b/src/components/Nav/Menu/index.tsx index 61e9bd0da45..abfdb01b7b8 100644 --- a/src/components/Nav/Menu/index.tsx +++ b/src/components/Nav/Menu/index.tsx @@ -15,18 +15,17 @@ import { cn } from "@/lib/utils/cn" import { MAIN_NAV_ID, SECTION_LABELS } from "@/lib/constants" import { Button } from "../../ui/buttons/Button" -import type { NavSections } from "../types" +import { useNavigation } from "../useNavigation" import MenuContent from "./MenuContent" import { useNavMenu } from "./useNavMenu" -type NavMenuProps = BaseHTMLAttributes & { - sections: NavSections -} +type NavMenuProps = BaseHTMLAttributes -const Menu = ({ sections, ...props }: NavMenuProps) => { +const Menu = ({ ...props }: NavMenuProps) => { + const { linkSections } = useNavigation() const { activeSection, direction, handleSectionChange, isOpen } = - useNavMenu(sections) + useNavMenu(linkSections) return (
@@ -38,7 +37,7 @@ const Menu = ({ sections, ...props }: NavMenuProps) => { > {SECTION_LABELS.map((sectionKey) => { - const { label, items } = sections[sectionKey] + const { label, items } = linkSections[sectionKey] const isActive = activeSection === sectionKey return ( @@ -66,7 +65,7 @@ const Menu = ({ sections, ...props }: NavMenuProps) => { ) diff --git a/src/components/Nav/Mobile/index.tsx b/src/components/Nav/Mobile/index.tsx index e151d082006..368adf73cae 100644 --- a/src/components/Nav/Mobile/index.tsx +++ b/src/components/Nav/Mobile/index.tsx @@ -11,7 +11,8 @@ import { import { cn } from "@/lib/utils/cn" import { ButtonProps } from "../../ui/buttons/Button" -import type { NavSections } from "../types" +import { useNavigation } from "../useNavigation" +import { useThemeToggle } from "../useThemeToggle" import HamburgerButton from "./HamburgerButton" import MenuBody from "./MenuBody" @@ -20,20 +21,12 @@ import MenuHeader from "./MenuHeader" import { useDisclosure } from "@/hooks/useDisclosure" -type MobileNavMenuProps = ButtonProps & { - toggleColorMode: () => void - toggleSearch: () => void - linkSections: NavSections -} +type MobileMenuProps = ButtonProps -const MobileNavMenu = ({ - toggleColorMode, - toggleSearch, - linkSections, - className, - ...props -}: MobileNavMenuProps) => { +const MobileMenu = ({ className, ...props }: MobileMenuProps) => { const { isOpen, onToggle } = useDisclosure() + const { linkSections } = useNavigation() + const { toggleColorMode } = useThemeToggle() // DRAWER MENU return ( @@ -60,7 +53,7 @@ const MobileNavMenu = ({ {}} toggleColorMode={toggleColorMode} /> @@ -69,4 +62,4 @@ const MobileNavMenu = ({ ) } -export default MobileNavMenu +export default MobileMenu diff --git a/src/components/Nav/MobileNav.tsx b/src/components/Nav/MobileNav.tsx new file mode 100644 index 00000000000..d4323dfc4e5 --- /dev/null +++ b/src/components/Nav/MobileNav.tsx @@ -0,0 +1,16 @@ +"use client" + +import { cn } from "@/lib/utils/cn" + +import Search from "../Search" + +import MobileMenu from "./Mobile" + +export const MobileNav = ({ className }: { className?: string }) => { + return ( +
+ + +
+ ) +} diff --git a/src/components/Nav/index.tsx b/src/components/Nav/index.tsx index 62cc661bd72..ad4bc98173f 100644 --- a/src/components/Nav/index.tsx +++ b/src/components/Nav/index.tsx @@ -4,7 +4,8 @@ import { EthHomeIcon } from "@/components/icons" import { BaseLink } from "../ui/Link" -import ClientSideNav from "./Client" +import { DesktopNav } from "./DesktopNav" +import { MobileNav } from "./MobileNav" const Nav = async () => { const locale = await getLocale() @@ -25,7 +26,8 @@ const Nav = async () => {
- + +
) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 7262ef0b5c6..25d3b191235 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -10,16 +10,15 @@ import { trackCustomEvent } from "@/lib/utils/matomo" import { sanitizeHitTitle } from "@/lib/utils/sanitizeHitTitle" import { sanitizeHitUrl } from "@/lib/utils/url" +import SearchButton from "./SearchButton" +import SearchInputButton from "./SearchInputButton" + import { useDisclosure } from "@/hooks/useDisclosure" import { useTranslation } from "@/hooks/useTranslation" const SearchModal = dynamic(() => import("./SearchModal")) -type Props = { - children: (props: ReturnType) => React.ReactNode -} - -const Search = ({ children }: Props) => { +const Search = () => { const disclosure = useDisclosure() const { isOpen, onOpen, onClose } = disclosure @@ -50,7 +49,12 @@ const Search = ({ children }: Props) => { return ( <> - {children({ ...disclosure, onOpen: handleOpen })} + + {isOpen && ( Date: Wed, 20 Aug 2025 16:08:33 +0200 Subject: [PATCH 05/33] refactor mobile menu to implement rsc as much as possible --- src/components/Nav/Mobile/ExpandIcon.tsx | 15 +++--- src/components/Nav/Mobile/HamburgerButton.tsx | 2 + src/components/Nav/Mobile/LvlAccordion.tsx | 16 +++--- src/components/Nav/Mobile/MenuBody.tsx | 51 ++++++------------- src/components/Nav/Mobile/MenuFooter.tsx | 23 ++++----- .../Nav/Mobile/TrackingAccordion.tsx | 48 +++++++++++++++++ src/components/Nav/Mobile/index.tsx | 24 ++------- src/components/Nav/MobileNav.tsx | 2 - 8 files changed, 91 insertions(+), 90 deletions(-) create mode 100644 src/components/Nav/Mobile/TrackingAccordion.tsx diff --git a/src/components/Nav/Mobile/ExpandIcon.tsx b/src/components/Nav/Mobile/ExpandIcon.tsx index bceaa6f343f..ab30a6a7818 100644 --- a/src/components/Nav/Mobile/ExpandIcon.tsx +++ b/src/components/Nav/Mobile/ExpandIcon.tsx @@ -1,14 +1,11 @@ import { Minus, Plus } from "lucide-react" -type ExpandIconProps = { - isOpen: boolean -} +const ExpandIcon = () => ( + <> + -const ExpandIcon = ({ isOpen }: ExpandIconProps) => - isOpen ? ( - - ) : ( - - ) + + +) export default ExpandIcon diff --git a/src/components/Nav/Mobile/HamburgerButton.tsx b/src/components/Nav/Mobile/HamburgerButton.tsx index 79563edf82f..07ebb10adc8 100644 --- a/src/components/Nav/Mobile/HamburgerButton.tsx +++ b/src/components/Nav/Mobile/HamburgerButton.tsx @@ -1,3 +1,5 @@ +"use client" + import { forwardRef } from "react" import { motion } from "framer-motion" diff --git a/src/components/Nav/Mobile/LvlAccordion.tsx b/src/components/Nav/Mobile/LvlAccordion.tsx index c213eca9ce4..124073d32d9 100644 --- a/src/components/Nav/Mobile/LvlAccordion.tsx +++ b/src/components/Nav/Mobile/LvlAccordion.tsx @@ -1,3 +1,5 @@ +"use client" + import { useState } from "react" import { useLocale } from "next-intl" import * as AccordionPrimitive from "@radix-ui/react-accordion" @@ -24,7 +26,6 @@ type LvlAccordionProps = { lvl: Level items: NavItem[] activeSection: NavSectionKey - onToggle: () => void } const subtextColorPerLevel = { @@ -41,12 +42,7 @@ const backgroundColorPerLevel = { 4: "bg-background-high", } -const LvlAccordion = ({ - lvl, - items, - activeSection, - onToggle, -}: LvlAccordionProps) => { +const LvlAccordion = ({ lvl, items, activeSection }: LvlAccordionProps) => { const pathname = usePathname() const locale = useLocale() const [value, setValue] = useState("") @@ -91,7 +87,7 @@ const LvlAccordion = ({ eventAction: `Menu: ${locale} - ${activeSection}`, eventName: action.href!, }) - onToggle() + // onToggle() }} >
@@ -141,7 +137,7 @@ const LvlAccordion = ({ }) }} > - +

{label} @@ -164,7 +160,7 @@ const LvlAccordion = ({ lvl={(lvl + 1) as Level} items={action.items} activeSection={activeSection} - onToggle={onToggle} + // onToggle={onToggle} /> diff --git a/src/components/Nav/Mobile/MenuBody.tsx b/src/components/Nav/Mobile/MenuBody.tsx index 19dc48b7414..b47562809e1 100644 --- a/src/components/Nav/Mobile/MenuBody.tsx +++ b/src/components/Nav/Mobile/MenuBody.tsx @@ -1,62 +1,42 @@ -import { useState } from "react" -import { useLocale } from "next-intl" +import { getLocale } from "next-intl/server" + +import { Lang } from "@/lib/types" import { cn } from "@/lib/utils/cn" -import { trackCustomEvent } from "@/lib/utils/matomo" import { SECTION_LABELS } from "@/lib/constants" -import type { Level, NavSections } from "../types" +import type { Level } from "../types" import ExpandIcon from "./ExpandIcon" import LvlAccordion from "./LvlAccordion" import { - Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "./MenuAccordion" +import { TrackingAccordion } from "./TrackingAccordion" -type MenuBodyProps = { - onToggle: () => void - linkSections: NavSections -} +import { getNavigation } from "@/lib/nav/links" -const MenuBody = ({ linkSections, onToggle }: MenuBodyProps) => { - const locale = useLocale() - const [value, setValue] = useState("") +const MenuBody = async () => { + const locale = await getLocale() + const linkSections = await getNavigation(locale as Lang) return (

) } diff --git a/src/components/Nav/Mobile/MenuFooter.tsx b/src/components/Nav/Mobile/MenuFooter.tsx index 09eebc7717e..33e4e8e95b3 100644 --- a/src/components/Nav/Mobile/MenuFooter.tsx +++ b/src/components/Nav/Mobile/MenuFooter.tsx @@ -1,29 +1,24 @@ +"use client" + import { Languages, Moon, Search, Sun } from "lucide-react" import LanguagePicker from "@/components/LanguagePicker" import { MOBILE_LANGUAGE_BUTTON_NAME } from "@/lib/constants" +import { useThemeToggle } from "../useThemeToggle" + import FooterButton from "./FooterButton" import FooterItemText from "./FooterItemText" import useColorModeValue from "@/hooks/useColorModeValue" import { useTranslation } from "@/hooks/useTranslation" -type MenuFooterProps = { - onToggle: () => void - toggleColorMode: () => void - toggleSearch: () => void -} - -const MenuFooter = ({ - onToggle, - toggleColorMode, - toggleSearch, -}: MenuFooterProps) => { +const MenuFooter = () => { const { t } = useTranslation("common") const ThemeIcon = useColorModeValue(Moon, Sun) const themeLabelKey = useColorModeValue("dark-mode", "light-mode") + const { toggleColorMode } = useThemeToggle() return (
@@ -31,8 +26,8 @@ const MenuFooter = ({ icon={Search} onClick={() => { // Workaround to ensure the input for the search modal can have focus - onToggle() - toggleSearch() + // onToggle() + // toggleSearch() }} > {t("search")} @@ -42,7 +37,7 @@ const MenuFooter = ({ {t(themeLabelKey)} - + {}}> {t("languages")} diff --git a/src/components/Nav/Mobile/TrackingAccordion.tsx b/src/components/Nav/Mobile/TrackingAccordion.tsx new file mode 100644 index 00000000000..d4de4814594 --- /dev/null +++ b/src/components/Nav/Mobile/TrackingAccordion.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useState } from "react" + +import { Lang } from "@/lib/types" + +import { trackCustomEvent } from "@/lib/utils/matomo" + +import { Accordion } from "./MenuAccordion" + +type TrackingAccordionProps = { + locale: Lang + children: React.ReactNode +} + +export const TrackingAccordion = ({ + locale, + children, +}: TrackingAccordionProps) => { + const [currentValue, setCurrentValue] = useState( + undefined + ) + + const handleValueChange = (value: string | undefined) => { + const isExpanded = currentValue === value + + trackCustomEvent({ + eventCategory: "Mobile navigation menu", + eventAction: "Section changed", + eventName: `${ + isExpanded ? "Close" : "Open" + } section: ${locale} - ${value || currentValue}`, + }) + + setCurrentValue(value) + } + + return ( + + {children} + + ) +} diff --git a/src/components/Nav/Mobile/index.tsx b/src/components/Nav/Mobile/index.tsx index 368adf73cae..98e429df568 100644 --- a/src/components/Nav/Mobile/index.tsx +++ b/src/components/Nav/Mobile/index.tsx @@ -1,5 +1,3 @@ -"use client" - import { Sheet, SheetContent, @@ -11,30 +9,22 @@ import { import { cn } from "@/lib/utils/cn" import { ButtonProps } from "../../ui/buttons/Button" -import { useNavigation } from "../useNavigation" -import { useThemeToggle } from "../useThemeToggle" import HamburgerButton from "./HamburgerButton" import MenuBody from "./MenuBody" import MenuFooter from "./MenuFooter" import MenuHeader from "./MenuHeader" -import { useDisclosure } from "@/hooks/useDisclosure" - type MobileMenuProps = ButtonProps const MobileMenu = ({ className, ...props }: MobileMenuProps) => { - const { isOpen, onToggle } = useDisclosure() - const { linkSections } = useNavigation() - const { toggleColorMode } = useThemeToggle() - - // DRAWER MENU return ( - + @@ -46,16 +36,12 @@ const MobileMenu = ({ className, ...props }: MobileMenuProps) => { {/* MAIN NAV ACCORDION CONTENTS OF MOBILE MENU */}
- +
{/* FOOTER ELEMENTS: SEARCH, LIGHT/DARK, LANGUAGES */} - {}} - toggleColorMode={toggleColorMode} - /> +
diff --git a/src/components/Nav/MobileNav.tsx b/src/components/Nav/MobileNav.tsx index d4323dfc4e5..85df3a0f04f 100644 --- a/src/components/Nav/MobileNav.tsx +++ b/src/components/Nav/MobileNav.tsx @@ -1,5 +1,3 @@ -"use client" - import { cn } from "@/lib/utils/cn" import Search from "../Search" From 9a753de834e7cb0ad431beee80792739b36745c1 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 20 Aug 2025 17:36:41 +0200 Subject: [PATCH 06/33] refactor MenuFooter to be server rendered --- src/components/Nav/Mobile/FooterButton.tsx | 4 +- src/components/Nav/Mobile/MenuFooter.tsx | 37 ++--- .../Nav/Mobile/ThemeToggleButton.tsx | 26 +++ src/components/Search/index.tsx | 157 ++++++++++-------- 4 files changed, 125 insertions(+), 99 deletions(-) create mode 100644 src/components/Nav/Mobile/ThemeToggleButton.tsx diff --git a/src/components/Nav/Mobile/FooterButton.tsx b/src/components/Nav/Mobile/FooterButton.tsx index 9d182cc40ca..7421cc1d3b8 100644 --- a/src/components/Nav/Mobile/FooterButton.tsx +++ b/src/components/Nav/Mobile/FooterButton.tsx @@ -4,7 +4,7 @@ import { LucideIcon } from "lucide-react" import { Button, type ButtonProps } from "../../ui/buttons/Button" type FooterButtonProps = ButtonProps & { - icon: React.FC> | LucideIcon + icon?: React.FC> | LucideIcon } const FooterButton = forwardRef( @@ -15,7 +15,7 @@ const FooterButton = forwardRef( variant="ghost" {...props} > - + {Icon && } {children} ) diff --git a/src/components/Nav/Mobile/MenuFooter.tsx b/src/components/Nav/Mobile/MenuFooter.tsx index 33e4e8e95b3..95f952fa018 100644 --- a/src/components/Nav/Mobile/MenuFooter.tsx +++ b/src/components/Nav/Mobile/MenuFooter.tsx @@ -1,43 +1,30 @@ -"use client" - -import { Languages, Moon, Search, Sun } from "lucide-react" +import { Languages, Search as SearchIcon } from "lucide-react" import LanguagePicker from "@/components/LanguagePicker" +import Search from "@/components/Search" import { MOBILE_LANGUAGE_BUTTON_NAME } from "@/lib/constants" -import { useThemeToggle } from "../useThemeToggle" - import FooterButton from "./FooterButton" import FooterItemText from "./FooterItemText" +import ThemeToggleButton from "./ThemeToggleButton" -import useColorModeValue from "@/hooks/useColorModeValue" import { useTranslation } from "@/hooks/useTranslation" const MenuFooter = () => { const { t } = useTranslation("common") - const ThemeIcon = useColorModeValue(Moon, Sun) - const themeLabelKey = useColorModeValue("dark-mode", "light-mode") - const { toggleColorMode } = useThemeToggle() return (
- { - // Workaround to ensure the input for the search modal can have focus - // onToggle() - // toggleSearch() - }} - > - {t("search")} - - - - {t(themeLabelKey)} - - - {}}> + + + {t("search")} + + + + + + {t("languages")} diff --git a/src/components/Nav/Mobile/ThemeToggleButton.tsx b/src/components/Nav/Mobile/ThemeToggleButton.tsx new file mode 100644 index 00000000000..ba29229babd --- /dev/null +++ b/src/components/Nav/Mobile/ThemeToggleButton.tsx @@ -0,0 +1,26 @@ +"use client" + +import { Moon, Sun } from "lucide-react" + +import { useThemeToggle } from "../useThemeToggle" + +import FooterButton from "./FooterButton" +import FooterItemText from "./FooterItemText" + +import useColorModeValue from "@/hooks/useColorModeValue" +import { useTranslation } from "@/hooks/useTranslation" + +const ThemeToggleButton = () => { + const { t } = useTranslation("common") + const ThemeIcon = useColorModeValue(Moon, Sun) + const themeLabelKey = useColorModeValue("dark-mode", "light-mode") + const { toggleColorMode } = useThemeToggle() + + return ( + + {t(themeLabelKey)} + + ) +} + +export default ThemeToggleButton diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 25d3b191235..b661208dd6f 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -5,6 +5,7 @@ import dynamic from "next/dynamic" import { useLocale } from "next-intl" import { type DocSearchHit, useDocSearchKeyboardEvents } from "@docsearch/react" import * as Portal from "@radix-ui/react-portal" +import { Slot } from "@radix-ui/react-slot" import { trackCustomEvent } from "@/lib/utils/matomo" import { sanitizeHitTitle } from "@/lib/utils/sanitizeHitTitle" @@ -18,7 +19,12 @@ import { useTranslation } from "@/hooks/useTranslation" const SearchModal = dynamic(() => import("./SearchModal")) -const Search = () => { +interface SearchProps { + asChild?: boolean + children?: React.ReactElement +} + +const Search = ({ asChild = false, children }: SearchProps) => { const disclosure = useDisclosure() const { isOpen, onOpen, onClose } = disclosure @@ -47,80 +53,87 @@ const Search = () => { const indexName = process.env.NEXT_PUBLIC_ALGOLIA_BASE_SEARCH_INDEX_NAME || "ethereumorg" + const searchModalProps = { + apiKey, + appId, + indexName, + onClose, + searchParameters: { + facetFilters: [`lang:${locale}`], + }, + transformItems: (items: DocSearchHit[]) => + items.map((item: DocSearchHit) => { + const newItem: DocSearchHit = structuredClone(item) + newItem.url = sanitizeHitUrl(item.url) + const newTitle = sanitizeHitTitle(item.hierarchy.lvl0 || "") + newItem.hierarchy.lvl0 = newTitle + return newItem + }), + placeholder: t("search-ethereum-org"), + translations: { + searchBox: { + resetButtonTitle: t("clear"), + resetButtonAriaLabel: t("clear"), + cancelButtonText: t("close"), + cancelButtonAriaLabel: t("close"), + }, + footer: { + selectText: t("docsearch-to-select"), + selectKeyAriaLabel: t("docsearch-to-select"), + navigateText: t("docsearch-to-navigate"), + navigateUpKeyAriaLabel: t("up"), + navigateDownKeyAriaLabel: t("down"), + closeText: t("docsearch-to-close"), + closeKeyAriaLabel: t("docsearch-to-close"), + searchByText: t("docsearch-search-by"), + }, + errorScreen: { + titleText: t("docsearch-error-title"), + helpText: t("docsearch-error-help"), + }, + startScreen: { + recentSearchesTitle: t("docsearch-start-recent-searches-title"), + noRecentSearchesText: t("docsearch-start-no-recent-searches"), + saveRecentSearchButtonTitle: t("docsearch-start-save-recent-search"), + removeRecentSearchButtonTitle: t( + "docsearch-start-remove-recent-search" + ), + favoriteSearchesTitle: t("docsearch-start-favorite-searches"), + removeFavoriteSearchButtonTitle: t( + "docsearch-start-remove-favorite-search" + ), + }, + noResultsScreen: { + noResultsText: t("docsearch-no-results-text"), + suggestedQueryText: t("docsearch-no-results-suggested-query"), + reportMissingResultsText: t("docsearch-no-results-missing"), + reportMissingResultsLinkText: t("docsearch-no-results-missing-link"), + }, + }, + } + return ( <> - - - - {isOpen && ( - - items.map((item: DocSearchHit) => { - const newItem: DocSearchHit = structuredClone(item) - newItem.url = sanitizeHitUrl(item.url) - const newTitle = sanitizeHitTitle(item.hierarchy.lvl0 || "") - newItem.hierarchy.lvl0 = newTitle - return newItem - }) - } - placeholder={t("search-ethereum-org")} - translations={{ - searchBox: { - resetButtonTitle: t("clear"), - resetButtonAriaLabel: t("clear"), - cancelButtonText: t("close"), - cancelButtonAriaLabel: t("close"), - }, - footer: { - selectText: t("docsearch-to-select"), - selectKeyAriaLabel: t("docsearch-to-select"), - navigateText: t("docsearch-to-navigate"), - navigateUpKeyAriaLabel: t("up"), - navigateDownKeyAriaLabel: t("down"), - closeText: t("docsearch-to-close"), - closeKeyAriaLabel: t("docsearch-to-close"), - searchByText: t("docsearch-search-by"), - }, - errorScreen: { - titleText: t("docsearch-error-title"), - helpText: t("docsearch-error-help"), - }, - startScreen: { - recentSearchesTitle: t("docsearch-start-recent-searches-title"), - noRecentSearchesText: t("docsearch-start-no-recent-searches"), - saveRecentSearchButtonTitle: t( - "docsearch-start-save-recent-search" - ), - removeRecentSearchButtonTitle: t( - "docsearch-start-remove-recent-search" - ), - favoriteSearchesTitle: t("docsearch-start-favorite-searches"), - removeFavoriteSearchButtonTitle: t( - "docsearch-start-remove-favorite-search" - ), - }, - noResultsScreen: { - noResultsText: t("docsearch-no-results-text"), - suggestedQueryText: t("docsearch-no-results-suggested-query"), - reportMissingResultsText: t("docsearch-no-results-missing"), - reportMissingResultsLinkText: t( - "docsearch-no-results-missing-link" - ), - }, - }} + {asChild ? ( + + {children} + + ) : ( + <> + - )} + + + )} + + {isOpen && } ) From 38c1c6d3f90a47b875611c1d0776739b1e96efa6 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 21 Aug 2025 19:49:45 +0200 Subject: [PATCH 07/33] refactor the LanguagePicker to precompute the languages list on the server --- .../LanguagePicker/ClientLanguagePicker.tsx | 246 ++++++++++++++++++ .../LanguagePicker/ClientMenuItem.tsx | 84 ++++++ src/components/LanguagePicker/index.tsx | 232 +---------------- .../LanguagePicker/useLanguagePicker.tsx | 29 +-- src/components/Nav/DesktopNav.tsx | 26 +- src/components/Nav/Mobile/MenuFooter.tsx | 11 +- ...Button.tsx => ThemeToggleFooterButton.tsx} | 4 +- src/components/Nav/ThemeToggleButton.tsx | 21 ++ 8 files changed, 389 insertions(+), 264 deletions(-) create mode 100644 src/components/LanguagePicker/ClientLanguagePicker.tsx create mode 100644 src/components/LanguagePicker/ClientMenuItem.tsx rename src/components/Nav/Mobile/{ThemeToggleButton.tsx => ThemeToggleFooterButton.tsx} (89%) create mode 100644 src/components/Nav/ThemeToggleButton.tsx diff --git a/src/components/LanguagePicker/ClientLanguagePicker.tsx b/src/components/LanguagePicker/ClientLanguagePicker.tsx new file mode 100644 index 00000000000..46738b59bd9 --- /dev/null +++ b/src/components/LanguagePicker/ClientLanguagePicker.tsx @@ -0,0 +1,246 @@ +"use client" + +import { useParams } from "next/navigation" +import { useLocale } from "next-intl" + +import type { LocaleDisplayInfo } from "@/lib/types" + +import { ButtonLink } from "@/components/ui/buttons/Button" + +import { cn } from "@/lib/utils/cn" + +import { DEFAULT_LOCALE } from "@/lib/constants" + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandList, +} from "../ui/command" +import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog" +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" + +import ClientMenuItem from "./ClientMenuItem" +import { MobileCloseBar } from "./MobileCloseBar" +import NoResultsCallout from "./NoResultsCallout" +import { useLanguagePicker } from "./useLanguagePicker" + +import { useEventListener } from "@/hooks/useEventListener" +import { useTranslation } from "@/hooks/useTranslation" +import { usePathname, useRouter } from "@/i18n/routing" + +type ClientLanguagePickerProps = { + children: React.ReactNode + languages: LocaleDisplayInfo[] + className?: string + handleClose?: () => void + dialog?: boolean +} + +const ClientLanguagePicker = ({ + children, + languages, + handleClose, + className, + dialog, +}: ClientLanguagePickerProps) => { + const pathname = usePathname() + const { push } = useRouter() + const params = useParams() + const { + disclosure, + languages: sortedLanguages, + intlLanguagePreference, + } = useLanguagePicker(languages, handleClose) + const { isOpen, setValue, onClose, onOpen } = disclosure + + /** + * Adds a keydown event listener to focus filter input (\). + * @param {string} event - The keydown event. + */ + useEventListener("keydown", (e) => { + if (e.key !== "\\" || e.metaKey || e.ctrlKey) return + e.preventDefault() + onOpen() + }) + + // onClick handlers + const handleMobileCloseBarClick = () => onClose() + const handleMenuItemSelect = (currentValue: string) => { + push( + // @ts-expect-error -- TypeScript will validate that only known `params` + // are used in combination with a given `pathname`. Since the two will + // always match for the current route, we can skip runtime checks. + { pathname, params }, + { + locale: currentValue, + } + ) + onClose({ + eventAction: "Locale chosen", + eventName: currentValue, + }) + } + const handleBaseLinkClose = () => + onClose({ + eventAction: "Translation program link (menu footer)", + eventName: "/contributing/translation-program", + }) + + if (dialog) { + return ( + + {children} + + {/* Mobile Close bar */} + + + + onClose({ + eventAction: "Translation program link (no results)", + eventName: "/contributing/translation-program", + }) + } + /> + + + + + ) + } + + return ( + + {children} + + + onClose({ + eventAction: "Translation program link (no results)", + eventName: "/contributing/translation-program", + }) + } + /> + + + + + ) +} + +const LanguagePickerMenu = ({ languages, onClose, onSelect }) => { + const { t } = useTranslation("common") + + return ( + { + const item = languages.find((name) => name.localeOption === value) + + if (!item) return 0 + + const { localeOption, sourceName, targetName, englishName } = item + + if ( + (localeOption + sourceName + targetName + englishName) + .toLowerCase() + .includes(search.toLowerCase()) + ) { + return 1 + } + + return 0 + }} + > +
+ {t("page-languages-filter-label")}{" "} + + ({languages.length} {t("common:languages")}) + +
+ + + + + + + + + {languages.map((displayInfo) => ( + + ))} + + +
+ ) +} + +const LanguagePickerFooter = ({ + intlLanguagePreference, + onTranslationProgramClick, +}: { + intlLanguagePreference?: LocaleDisplayInfo + onTranslationProgramClick: () => void +}) => { + const { t } = useTranslation("common") + const locale = useLocale() + return ( +
+
+
+ {locale === DEFAULT_LOCALE ? ( +

+ {intlLanguagePreference + ? `${t("page-languages-translate-cta-title")} ${t(`language-${intlLanguagePreference.localeOption}`)}` + : "Translate ethereum.org"} +

+ ) : ( +

+ {t("page-languages-translate-cta-title")}{" "} + {t(`language-${locale}`)} +

+ )} +

+ {t("page-languages-recruit-community")} +

+
+ + {t("get-involved")} + +
+
+ ) +} + +export default ClientLanguagePicker diff --git a/src/components/LanguagePicker/ClientMenuItem.tsx b/src/components/LanguagePicker/ClientMenuItem.tsx new file mode 100644 index 00000000000..70e52e111e0 --- /dev/null +++ b/src/components/LanguagePicker/ClientMenuItem.tsx @@ -0,0 +1,84 @@ +import { ComponentPropsWithoutRef } from "react" +import { Check } from "lucide-react" +import { useLocale } from "next-intl" + +import type { LocaleDisplayInfo } from "@/lib/types" + +import { cn } from "@/lib/utils/cn" + +import { CommandItem } from "../ui/command" + +import ProgressBar from "./ProgressBar" + +import { useTranslation } from "@/hooks/useTranslation" + +type ItemProps = ComponentPropsWithoutRef & { + displayInfo: LocaleDisplayInfo +} + +const ClientMenuItem = ({ displayInfo, ...props }: ItemProps) => { + const { + localeOption, + sourceName, + targetName, + approvalProgress, + wordsApproved, + } = displayInfo + const { t } = useTranslation("common") + const locale = useLocale() + const isCurrent = localeOption === locale + + const getProgressInfo = (approvalProgress: number, wordsApproved: number) => { + const percentage = new Intl.NumberFormat(locale!, { + style: "percent", + }).format(approvalProgress / 100) + const progress = + approvalProgress === 0 ? "<" + percentage.replace("0", "1") : percentage + const words = new Intl.NumberFormat(locale!).format(wordsApproved) + return { progress, words } + } + + const { progress, words } = getProgressInfo(approvalProgress, wordsApproved) + + return ( + +
+
+
+

+ {targetName} +

+
+

{sourceName}

+
+ {isCurrent && ( + + )} +
+

+ {progress} {t("page-languages-translated")} • {words}{" "} + {t("page-languages-words")} +

+ +
+ ) +} + +export default ClientMenuItem diff --git a/src/components/LanguagePicker/index.tsx b/src/components/LanguagePicker/index.tsx index baebd509e80..a94015570ce 100644 --- a/src/components/LanguagePicker/index.tsx +++ b/src/components/LanguagePicker/index.tsx @@ -1,34 +1,6 @@ -"use client" +import ClientLanguagePicker from "./ClientLanguagePicker" -import { useParams } from "next/navigation" -import { useLocale } from "next-intl" - -import type { LocaleDisplayInfo } from "@/lib/types" - -import { ButtonLink } from "@/components/ui/buttons/Button" - -import { cn } from "@/lib/utils/cn" - -import { DEFAULT_LOCALE } from "@/lib/constants" - -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandList, -} from "../ui/command" -import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog" -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" - -import MenuItem from "./MenuItem" -import { MobileCloseBar } from "./MobileCloseBar" -import NoResultsCallout from "./NoResultsCallout" -import { useLanguagePicker } from "./useLanguagePicker" - -import { useEventListener } from "@/hooks/useEventListener" -import { useTranslation } from "@/hooks/useTranslation" -import { usePathname, useRouter } from "@/i18n/routing" +import { getLanguagesDisplayInfo } from "@/lib/nav/links" type LanguagePickerProps = { children: React.ReactNode @@ -37,205 +9,23 @@ type LanguagePickerProps = { dialog?: boolean } -const LanguagePicker = ({ +const LanguagePicker = async ({ children, handleClose, className, dialog, }: LanguagePickerProps) => { - const pathname = usePathname() - const { push } = useRouter() - const params = useParams() - const { disclosure, languages, intlLanguagePreference } = - useLanguagePicker(handleClose) - const { isOpen, setValue, onClose, onOpen } = disclosure - - /** - * Adds a keydown event listener to focus filter input (\). - * @param {string} event - The keydown event. - */ - useEventListener("keydown", (e) => { - if (e.key !== "\\" || e.metaKey || e.ctrlKey) return - e.preventDefault() - onOpen() - }) - - // onClick handlers - const handleMobileCloseBarClick = () => onClose() - const handleMenuItemSelect = (currentValue: string) => { - push( - // @ts-expect-error -- TypeScript will validate that only known `params` - // are used in combination with a given `pathname`. Since the two will - // always match for the current route, we can skip runtime checks. - { pathname, params }, - { - locale: currentValue, - } - ) - onClose({ - eventAction: "Locale chosen", - eventName: currentValue, - }) - } - const handleBaseLinkClose = () => - onClose({ - eventAction: "Translation program link (menu footer)", - eventName: "/contributing/translation-program", - }) - - if (dialog) { - return ( - - {children} - - {/* Mobile Close bar */} - - - - onClose({ - eventAction: "Translation program link (no results)", - eventName: "/contributing/translation-program", - }) - } - /> - - - - - ) - } - - return ( - - {children} - - - onClose({ - eventAction: "Translation program link (no results)", - eventName: "/contributing/translation-program", - }) - } - /> - - - - - ) -} - -const LanguagePickerMenu = ({ languages, onClose, onSelect }) => { - const { t } = useTranslation("common") + const languages = await getLanguagesDisplayInfo() return ( - { - const item = languages.find((name) => name.localeOption === value) - - if (!item) return 0 - - const { localeOption, sourceName, targetName, englishName } = item - - if ( - (localeOption + sourceName + targetName + englishName) - .toLowerCase() - .includes(search.toLowerCase()) - ) { - return 1 - } - - return 0 - }} + -
- {t("page-languages-filter-label")}{" "} - - ({languages.length} {t("common:languages")}) - -
- - - - - - - - - {languages.map((displayInfo) => ( - - ))} - - -
- ) -} - -const LanguagePickerFooter = ({ - intlLanguagePreference, - onTranslationProgramClick, -}: { - intlLanguagePreference?: LocaleDisplayInfo - onTranslationProgramClick: () => void -}) => { - const { t } = useTranslation("common") - const locale = useLocale() - console.log({ intlLanguagePreference }) - return ( -
-
-
- {locale === DEFAULT_LOCALE ? ( -

- {intlLanguagePreference - ? `${t("page-languages-translate-cta-title")} ${t(`language-${intlLanguagePreference.localeOption}`)}` - : "Translate ethereum.org"} -

- ) : ( -

- {t("page-languages-translate-cta-title")}{" "} - {t(`language-${locale}`)} -

- )} -

- {t("page-languages-recruit-community")} -

-
- - {t("get-involved")} - -
-
+ {children} + ) } diff --git a/src/components/LanguagePicker/useLanguagePicker.tsx b/src/components/LanguagePicker/useLanguagePicker.tsx index 96f529903e1..5bdcba36e2d 100644 --- a/src/components/LanguagePicker/useLanguagePicker.tsx +++ b/src/components/LanguagePicker/useLanguagePicker.tsx @@ -8,16 +8,15 @@ import { filterRealLocales } from "@/lib/utils/translations" import { LOCALES_CODES } from "@/lib/constants" -import { localeToDisplayInfo } from "./localeToDisplayInfo" - import { useDisclosure } from "@/hooks/useDisclosure" -import { useTranslation } from "@/hooks/useTranslation" // Move locales computation outside component to make it stable const FILTERED_LOCALES = filterRealLocales(LOCALES_CODES) -export const useLanguagePicker = (handleClose?: () => void) => { - const { t } = useTranslation("common") +export const useLanguagePicker = ( + languages: LocaleDisplayInfo[], + handleClose?: () => void +) => { const locale = useLocale() // Get the preferred language for the users browser @@ -36,14 +35,12 @@ export const useLanguagePicker = (handleClose?: () => void) => { [navLang] ) - const languages = useMemo(() => { - // Early return if no locales - if (!FILTERED_LOCALES?.length) return [] - - return (FILTERED_LOCALES as Lang[]) - .map((localeOption) => { - const displayInfo = localeToDisplayInfo(localeOption, locale as Lang, t) - const isBrowserDefault = intlLocalePreference === localeOption + // Sort languages client-side to prioritize browser preference + const sortedLanguages = useMemo(() => { + return [...languages] + .map((displayInfo) => { + const isBrowserDefault = + intlLocalePreference === displayInfo.localeOption return { ...displayInfo, isBrowserDefault } }) .sort((a, b) => { @@ -53,9 +50,9 @@ export const useLanguagePicker = (handleClose?: () => void) => { // Otherwise, sort alphabetically by source name using localeCompare return a.sourceName.localeCompare(b.sourceName, locale) }) - }, [intlLocalePreference, locale, t]) + }, [languages, intlLocalePreference, locale]) - const intlLanguagePreference = languages.find( + const intlLanguagePreference = sortedLanguages.find( (lang) => lang.localeOption === intlLocalePreference ) @@ -93,7 +90,7 @@ export const useLanguagePicker = (handleClose?: () => void) => { return { disclosure: { isOpen, setValue, onOpen, onClose }, - languages, + languages: sortedLanguages, intlLanguagePreference, } } diff --git a/src/components/Nav/DesktopNav.tsx b/src/components/Nav/DesktopNav.tsx index b64c375c799..ac045101669 100644 --- a/src/components/Nav/DesktopNav.tsx +++ b/src/components/Nav/DesktopNav.tsx @@ -1,7 +1,5 @@ -"use client" - import { Languages } from "lucide-react" -import { useLocale } from "next-intl" +import { getLocale, getTranslations } from "next-intl/server" import { cn } from "@/lib/utils/cn" @@ -12,14 +10,12 @@ import Search from "../Search" import { Button } from "../ui/buttons/Button" import Menu from "./Menu" -import { useThemeToggle } from "./useThemeToggle" +import { ThemeToggleButton } from "./ThemeToggleButton" -import { useTranslation } from "@/hooks/useTranslation" +export const DesktopNav = async ({ className }: { className?: string }) => { + const t = await getTranslations({ namespace: "common" }) -export const DesktopNav = ({ className }: { className?: string }) => { - const { t } = useTranslation() - const { toggleColorMode, ThemeIcon, themeIconAriaLabel } = useThemeToggle() - const locale = useLocale() + const locale = await getLocale() return (
{
- +
diff --git a/src/components/Nav/Mobile/MenuFooter.tsx b/src/components/Nav/Mobile/MenuFooter.tsx index 95f952fa018..761f23286d7 100644 --- a/src/components/Nav/Mobile/MenuFooter.tsx +++ b/src/components/Nav/Mobile/MenuFooter.tsx @@ -1,4 +1,5 @@ import { Languages, Search as SearchIcon } from "lucide-react" +import { getTranslations } from "next-intl/server" import LanguagePicker from "@/components/LanguagePicker" import Search from "@/components/Search" @@ -7,12 +8,10 @@ import { MOBILE_LANGUAGE_BUTTON_NAME } from "@/lib/constants" import FooterButton from "./FooterButton" import FooterItemText from "./FooterItemText" -import ThemeToggleButton from "./ThemeToggleButton" +import ThemeToggleFooterButton from "./ThemeToggleFooterButton" -import { useTranslation } from "@/hooks/useTranslation" - -const MenuFooter = () => { - const { t } = useTranslation("common") +const MenuFooter = async () => { + const t = await getTranslations({ namespace: "common" }) return (
@@ -22,7 +21,7 @@ const MenuFooter = () => { - + diff --git a/src/components/Nav/Mobile/ThemeToggleButton.tsx b/src/components/Nav/Mobile/ThemeToggleFooterButton.tsx similarity index 89% rename from src/components/Nav/Mobile/ThemeToggleButton.tsx rename to src/components/Nav/Mobile/ThemeToggleFooterButton.tsx index ba29229babd..fb2fd50bfa5 100644 --- a/src/components/Nav/Mobile/ThemeToggleButton.tsx +++ b/src/components/Nav/Mobile/ThemeToggleFooterButton.tsx @@ -10,7 +10,7 @@ import FooterItemText from "./FooterItemText" import useColorModeValue from "@/hooks/useColorModeValue" import { useTranslation } from "@/hooks/useTranslation" -const ThemeToggleButton = () => { +const ThemeToggleFooterButton = () => { const { t } = useTranslation("common") const ThemeIcon = useColorModeValue(Moon, Sun) const themeLabelKey = useColorModeValue("dark-mode", "light-mode") @@ -23,4 +23,4 @@ const ThemeToggleButton = () => { ) } -export default ThemeToggleButton +export default ThemeToggleFooterButton diff --git a/src/components/Nav/ThemeToggleButton.tsx b/src/components/Nav/ThemeToggleButton.tsx new file mode 100644 index 00000000000..f2072c33ef3 --- /dev/null +++ b/src/components/Nav/ThemeToggleButton.tsx @@ -0,0 +1,21 @@ +"use client" + +import { Button } from "../ui/buttons/Button" + +import { useThemeToggle } from "./useThemeToggle" + +export const ThemeToggleButton = () => { + const { toggleColorMode, ThemeIcon, themeIconAriaLabel } = useThemeToggle() + + return ( + + ) +} From a0151bf6b3b0c16d83aa27a60a975adecb1869ad Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 22 Aug 2025 10:59:53 +0200 Subject: [PATCH 08/33] add navigation links lib --- src/lib/nav/links.ts | 523 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 src/lib/nav/links.ts diff --git a/src/lib/nav/links.ts b/src/lib/nav/links.ts new file mode 100644 index 00000000000..b754232e0d3 --- /dev/null +++ b/src/lib/nav/links.ts @@ -0,0 +1,523 @@ +import { getLocale, getTranslations } from "next-intl/server" + +// import BookIcon from "@/components/icons/book.svg" +// import BuildingsIcon from "@/components/icons/buildings.svg" +// import CodeSquareIcon from "@/components/icons/code-square.svg" +// import CompassIcon from "@/components/icons/compass.svg" +// import EthereumIcon from "@/components/icons/ethereum-icon.svg" +// import FlagIcon from "@/components/icons/flag.svg" +// import Flask from "@/components/icons/flask.svg" +// import JournalCodeIcon from "@/components/icons/journal-code.svg" +// import LayersIcon from "@/components/icons/layers.svg" +// import LightbulbIcon from "@/components/icons/lightbulb.svg" +// import MegaphoneIcon from "@/components/icons/megaphone.svg" +// import MortarboardIcon from "@/components/icons/mortarboard.svg" +// import PinAngleIcon from "@/components/icons/pin-angle.svg" +// import SafeIcon from "@/components/icons/safe.svg" +// import SignpostIcon from "@/components/icons/signpost.svg" +// import SlidersHorizontalCircles from "@/components/icons/sliders-horizontal-circles.svg" +// import UiChecksGridIcon from "@/components/icons/ui-checks-grid.svg" +// import UsersFourLight from "@/components/icons/users-four-light.svg" +import type { Lang, LocaleDisplayInfo } from "@/lib/types" + +import { localeToDisplayInfo } from "@/components/LanguagePicker/localeToDisplayInfo" +import type { NavSections } from "@/components/Nav/types" + +import { filterRealLocales } from "@/lib/utils/translations" + +import { LOCALES_CODES } from "@/lib/constants" + +// Pre-filtered locales for server use +const FILTERED_LOCALES = filterRealLocales(LOCALES_CODES) + +/** + * Server-side function to generate language display information + * Processes all locale data on the server without client-side hooks + */ +export const getLanguagesDisplayInfo = async (): Promise< + LocaleDisplayInfo[] +> => { + const locale = await getLocale() + const t = await getTranslations({ locale, namespace: "common" }) + + // Early return if no locales + if (!FILTERED_LOCALES?.length) return [] + + return (FILTERED_LOCALES as Lang[]).map((localeOption) => { + return localeToDisplayInfo(localeOption, locale as Lang, t) + }) +} + +export const getNavigation = async (locale: Lang) => { + const t = await getTranslations({ + locale, + namespace: "common", + }) + + const linkSections: NavSections = { + learn: { + label: t("learn"), + ariaLabel: t("learn-menu"), + items: [ + { + label: t("nav-overview-label"), + description: t("nav-overview-description"), + // icon: CompassIcon, + href: "/learn/", + }, + { + label: t("nav-basics-label"), + description: t("nav-basics-description"), + // icon: UiChecksGridIcon, + items: [ + { + label: t("what-is-ethereum"), + description: t("nav-what-is-ethereum-description"), + href: "/what-is-ethereum/", + }, + { + label: t("what-is-ether"), + description: t("nav-what-is-ether-description"), + href: "/eth/", + }, + { + label: t("ethereum-wallets"), + description: t("nav-ethereum-wallets-description"), + href: "/wallets/", + }, + { + label: t("nav-what-is-web3-label"), + description: t("nav-what-is-web3-description"), + href: "/web3/", + }, + { + label: t("smart-contracts"), + description: t("nav-smart-contracts-description"), + href: "/smart-contracts/", + }, + ], + }, + { + label: t("nav-advanced-label"), + description: t("nav-advanced-description"), + // icon: SlidersHorizontalCircles, + items: [ + { + label: t("nav-gas-fees-label"), + description: t("nav-gas-fees-description"), + href: "/gas/", + }, + { + label: t("bridges"), + description: t("nav-bridges-description"), + href: "/bridges/", + }, + { + label: t("zero-knowledge-proofs"), + description: t("nav-zkp-description"), + href: "/zero-knowledge-proofs/", + }, + { + label: t("run-a-node"), + description: t("nav-run-a-node-description"), + href: "/run-a-node/", + }, + { + label: t("ethereum-security"), + description: t("nav-security-description"), + href: "/security/", + }, + ], + }, + { + label: t("nav-quizzes-label"), + description: t("nav-quizzes-description"), + // icon: MortarboardIcon, + href: "/quizzes/", + }, + ], + }, + use: { + label: t("use"), + ariaLabel: t("use-menu"), + items: [ + { + label: t("get-started"), + description: t("nav-get-started-description"), + // icon: PinAngleIcon, + items: [ + { + label: t("nav-start-with-crypto-title"), + description: t("nav-start-with-crypto-description"), + href: "/start/", + }, + { + label: t("nav-find-wallet-label"), + description: t("nav-find-wallet-description"), + href: "/wallets/find-wallet/", + }, + { + label: t("get-eth"), + description: t("nav-get-eth-description"), + href: "/get-eth/", + }, + { + label: t("application-explorer"), + description: t("nav-apps-description"), + href: "/apps/", + }, + { + label: t("nav-guides-label"), + description: t("nav-guides-description"), + items: [ + { + label: t("nav-overview-label"), + description: t("nav-guide-overview-description"), + href: "/guides/", + }, + { + label: t("nav-guide-create-account-label"), + description: t("nav-guide-create-account-description"), + href: "/guides/how-to-create-an-ethereum-account/", + }, + { + label: t("nav-guide-use-wallet-label"), + description: t("nav-guide-use-wallet-description"), + href: "/guides/how-to-use-a-wallet/", + }, + { + label: t("nav-guide-revoke-access-label"), + description: t("nav-guide-revoke-access-description"), + href: "/guides/how-to-revoke-token-access/", + }, + ], + }, + ], + }, + { + label: t("nav-use-cases-label"), + description: t("nav-use-cases-description"), + // icon: LightbulbIcon, + items: [ + { + label: t("stablecoins"), + description: t("nav-stablecoins-description"), + href: "/stablecoins/", + }, + { + label: t("nft-page"), + description: t("nav-nft-description"), + href: "/nft/", + }, + { + label: t("defi-page"), + description: t("nav-defi-description"), + href: "/defi/", + }, + { + label: t("payments-page"), + description: t("nav-payments-description"), + href: "/payments/", + }, + { + label: t("dao-page"), + description: t("nav-dao-description"), + href: "/dao/", + }, + { + label: t("nav-emerging-label"), + description: t("nav-emerging-description"), + items: [ + { + label: t("decentralized-identity"), + description: t("nav-did-description"), + href: "/decentralized-identity/", + }, + { + label: t("decentralized-social-networks"), + description: t("nav-desoc-description"), + href: "/social-networks/", + }, + { + label: t("decentralized-science"), + description: t("nav-desci-description"), + href: "/desci/", + }, + { + label: t("regenerative-finance"), + description: t("nav-refi-description"), + href: "/refi/", + }, + { + label: t("ai-agents"), + description: t("nav-ai-agents-description"), + href: "/ai-agents/", + }, + { + label: t("prediction-markets"), + description: t("nav-prediction-markets-description"), + href: "/prediction-markets/", + }, + { + label: t("real-world-assets"), + description: t("nav-rwa-description"), + href: "/real-world-assets/", + }, + ], + }, + ], + }, + { + label: t("nav-stake-label"), + description: t("nav-stake-description"), + // icon: SafeIcon, + items: [ + { + label: t("nav-staking-home-label"), + description: t("nav-staking-home-description"), + href: "/staking/", + }, + { + label: t("nav-staking-solo-label"), + description: t("nav-staking-solo-description"), + href: "/staking/solo/", + }, + { + label: t("nav-staking-saas-label"), + description: t("nav-staking-saas-description"), + href: "/staking/saas/", + }, + { + label: t("nav-staking-pool-label"), + description: t("nav-staking-pool-description"), + href: "/staking/pools/", + }, + ], + }, + { + label: t("nav-ethereum-networks"), + description: t("nav-ethereum-networks-description"), + // icon: LayersIcon, + items: [ + { + label: t("nav-networks-introduction-label"), + description: t("nav-networks-introduction-description"), + href: "/layer-2/", + }, + { + label: t("nav-networks-explore-networks-label"), + description: t("nav-networks-explore-networks-description"), + href: "/layer-2/networks/", + }, + { + label: t("nav-networks-learn-label"), + description: t("nav-networks-learn-description"), + href: "/layer-2/learn/", + }, + ], + }, + ], + }, + build: { + label: t("build"), + ariaLabel: t("build-menu"), + items: [ + { + label: t("nav-builders-home-label"), + description: t("nav-builders-home-description"), + // icon: CodeSquareIcon, + href: "/developers/", + }, + { + label: t("get-started"), + description: t("nav-start-building-description"), + // icon: FlagIcon, + items: [ + { + label: t("tutorials"), + description: t("nav-tutorials-description"), + href: "/developers/tutorials/", + }, + { + label: t("learn-by-coding"), + description: t("nav-learn-by-coding-description"), + href: "/developers/learning-tools/", + }, + { + label: t("set-up-local-env"), + description: t("nav-local-env-description"), + href: "/developers/local-environment/", + }, + { + label: t("grants"), + description: t("nav-grants-description"), + href: "/community/grants/", + }, + ], + }, + { + label: t("documentation"), + description: t("nav-docs-description"), + // icon: JournalCodeIcon, + items: [ + { + label: t("nav-overview-label"), + description: t("nav-docs-overview-description"), + href: "/developers/docs/", + }, + { + label: t("nav-docs-foundation-label"), + description: t("nav-docs-foundation-description"), + href: "/developers/docs/intro-to-ethereum/", + }, + { + label: t("nav-docs-stack-label"), + description: t("nav-docs-stack-description"), + href: "/developers/docs/ethereum-stack/", + }, + { + label: t("nav-docs-design-label"), + description: t("nav-docs-design-description"), + href: "/developers/docs/design-and-ux/", + }, + ], + }, + { + label: t("enterprise"), + description: t("nav-mainnet-description"), + // icon: BuildingsIcon, + href: "/enterprise/", + }, + ], + }, + participate: { + label: t("participate"), + ariaLabel: t("participate-menu"), + items: [ + { + label: t("community-hub"), + description: t("nav-participate-overview-description"), + // icon: UsersFourLight, + href: "/community/", + }, + { + label: t("nav-events-label"), + description: t("nav-events-description"), + // icon: MegaphoneIcon, + items: [ + { + label: t("ethereum-online"), + description: t("nav-events-online-description"), + href: "/community/online/", + }, + { + label: t("ethereum-events"), + description: t("nav-events-irl-description"), + href: "/community/events/", + }, + ], + }, + { + label: t("site-title"), + description: t("nav-ethereum-org-description"), + // icon: EthereumIcon, + items: [ + { + label: t("nav-contribute-label"), + description: t("nav-contribute-description"), + href: "/contributing/", + }, + { + label: t("translation-program"), + description: t("nav-translation-program-description"), + href: "/contributing/translation-program/", + }, + { + label: t("nav-collectibles-label"), + description: t("nav-collectibles-description"), + href: "/collectibles/", + }, + { + label: t("about-ethereum-org"), + description: t("nav-about-description"), + href: "/about/", + }, + ], + }, + ], + }, + research: { + label: t("research"), + ariaLabel: t("research-menu"), + items: [ + { + label: t("ethereum-whitepaper"), + description: t("nav-whitepaper-description"), + // icon: BookIcon, + href: "/whitepaper/", + }, + { + label: t("nav-roadmap-label"), + description: t("nav-roadmap-description"), + // icon: SignpostIcon, + items: [ + { + label: t("nav-overview-label"), + description: t("nav-roadmap-overview-description"), + href: "/roadmap/", + }, + { + label: t("nav-roadmap-security-label"), + description: t("nav-roadmap-security-description"), + href: "/roadmap/security/", + }, + { + label: t("nav-roadmap-scaling-label"), + description: t("nav-roadmap-scaling-description"), + href: "/roadmap/scaling/", + }, + { + label: t("nav-roadmap-ux-label"), + description: t("nav-roadmap-ux-description"), + href: "/roadmap/user-experience/", + }, + { + label: t("nav-roadmap-future-label"), + description: t("nav-roadmap-future-description"), + href: "/roadmap/future-proofing/", + }, + ], + }, + { + label: t("nav-research-label"), + description: t("nav-research-description"), + // icon: Flask, + items: [ + { + label: t("nav-history-label"), + description: t("nav-history-description"), + href: "/history/", + }, + { + label: t("nav-open-research-label"), + description: t("nav-open-research-description"), + href: "/community/research/", + }, + { + label: t("nav-eip-label"), + description: t("nav-eip-description"), + href: "/eips/", + }, + { + label: t("nav-governance-label"), + description: t("nav-governance-description"), + href: "/governance/", + }, + ], + }, + ], + }, + } + + return linkSections +} From aecae9042112998a72575ca2f722c603635ce1da Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 22 Aug 2025 11:09:07 +0200 Subject: [PATCH 09/33] moblie menu --- .../Nav/Mobile/MenuFooterClient.tsx | 45 ++++ .../Nav/Mobile/MobileLanguagePicker.tsx | 204 ++++++++++++++++++ .../Nav/Mobile/MobileMenuContent.tsx | 68 ++++++ src/components/Nav/Mobile/index.tsx | 34 +-- 4 files changed, 325 insertions(+), 26 deletions(-) create mode 100644 src/components/Nav/Mobile/MenuFooterClient.tsx create mode 100644 src/components/Nav/Mobile/MobileLanguagePicker.tsx create mode 100644 src/components/Nav/Mobile/MobileMenuContent.tsx diff --git a/src/components/Nav/Mobile/MenuFooterClient.tsx b/src/components/Nav/Mobile/MenuFooterClient.tsx new file mode 100644 index 00000000000..ad7f97cbaa4 --- /dev/null +++ b/src/components/Nav/Mobile/MenuFooterClient.tsx @@ -0,0 +1,45 @@ +"use client" + +import { Languages, Search as SearchIcon } from "lucide-react" + +import Search from "@/components/Search" + +import { MOBILE_LANGUAGE_BUTTON_NAME } from "@/lib/constants" + +import FooterButton from "./FooterButton" +import FooterItemText from "./FooterItemText" +import { useMobileMenu } from "./MobileMenuContent" +import ThemeToggleFooterButton from "./ThemeToggleFooterButton" + +import { useTranslation } from "@/hooks/useTranslation" + +const MenuFooterClient = () => { + const { t } = useTranslation("common") + const { setCurrentView } = useMobileMenu() + + const handleLanguageClick = () => { + setCurrentView("language-picker") + } + + return ( +
+ + + {t("search")} + + + + + + + {t("languages")} + +
+ ) +} + +export default MenuFooterClient diff --git a/src/components/Nav/Mobile/MobileLanguagePicker.tsx b/src/components/Nav/Mobile/MobileLanguagePicker.tsx new file mode 100644 index 00000000000..7a9323f96de --- /dev/null +++ b/src/components/Nav/Mobile/MobileLanguagePicker.tsx @@ -0,0 +1,204 @@ +"use client" + +import { memo } from "react" +import { ChevronLeft } from "lucide-react" +import { useParams } from "next/navigation" +import { useLocale } from "next-intl" + +import type { LocaleDisplayInfo } from "@/lib/types" + +import { ButtonLink } from "@/components/ui/buttons/Button" +import { Button } from "@/components/ui/buttons/Button" + +import { DEFAULT_LOCALE } from "@/lib/constants" + +import ClientMenuItem from "../../LanguagePicker/ClientMenuItem" +import NoResultsCallout from "../../LanguagePicker/NoResultsCallout" +import { useLanguagePicker } from "../../LanguagePicker/useLanguagePicker" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandList, +} from "../../ui/command" + +import { useMobileMenu } from "./MobileMenuContent" + +import { useTranslation } from "@/hooks/useTranslation" +import { usePathname, useRouter } from "@/i18n/routing" + +type MobileLanguagePickerProps = { + languages: LocaleDisplayInfo[] +} + +const MobileLanguagePicker = memo( + ({ languages }: MobileLanguagePickerProps) => { + const { setCurrentView } = useMobileMenu() + const pathname = usePathname() + const { push } = useRouter() + const params = useParams() + const { languages: sortedLanguages, intlLanguagePreference } = + useLanguagePicker(languages) + + const handleBackClick = () => { + setCurrentView("menu") + } + + const handleMenuItemSelect = (currentValue: string) => { + push( + // @ts-expect-error -- TypeScript will validate that only known `params` + // are used in combination with a given `pathname`. Since the two will + // always match for the current route, we can skip runtime checks. + { pathname, params }, + { + locale: currentValue, + } + ) + // Close the sheet by going back to menu view + setCurrentView("menu") + } + + const handleNoResultsClose = () => { + // Navigate to translation program or handle as needed + } + + const handleTranslationProgramClick = () => { + // Navigate to translation program + } + + return ( +
+ {/* Back navigation */} +
+ +
+ + {/* Language picker menu */} +
+ +
+ + {/* Footer */} + +
+ ) + } +) + +MobileLanguagePicker.displayName = "MobileLanguagePicker" + +const LanguagePickerMenu = ({ languages, onClose, onSelect }) => { + const { t } = useTranslation("common") + + return ( + { + const item = languages.find((name) => name.localeOption === value) + + if (!item) return 0 + + const { localeOption, sourceName, targetName, englishName } = item + + if ( + (localeOption + sourceName + targetName + englishName) + .toLowerCase() + .includes(search.toLowerCase()) + ) { + return 1 + } + + return 0 + }} + > +
+ {t("page-languages-filter-label")}{" "} + + ({languages.length} {t("common:languages")}) + +
+ + + + + + + + + {languages.map((displayInfo) => ( + + ))} + + +
+ ) +} + +const LanguagePickerFooter = ({ + intlLanguagePreference, + onTranslationProgramClick, +}: { + intlLanguagePreference?: LocaleDisplayInfo + onTranslationProgramClick: () => void +}) => { + const { t } = useTranslation("common") + const locale = useLocale() + + return ( +
+
+
+ {locale === DEFAULT_LOCALE ? ( +

+ {intlLanguagePreference + ? `${t("page-languages-translate-cta-title")} ${t(`language-${intlLanguagePreference.localeOption}`)}` + : "Translate ethereum.org"} +

+ ) : ( +

+ {t("page-languages-translate-cta-title")}{" "} + {t(`language-${locale}`)} +

+ )} +

+ {t("page-languages-recruit-community")} +

+
+ + {t("get-involved")} + +
+
+ ) +} + +export default MobileLanguagePicker diff --git a/src/components/Nav/Mobile/MobileMenuContent.tsx b/src/components/Nav/Mobile/MobileMenuContent.tsx new file mode 100644 index 00000000000..17f68cee616 --- /dev/null +++ b/src/components/Nav/Mobile/MobileMenuContent.tsx @@ -0,0 +1,68 @@ +"use client" + +import { createContext, useContext, useState } from "react" + +import type { LocaleDisplayInfo } from "@/lib/types" + +import { SheetContent, SheetFooter, SheetHeader } from "@/components/ui/sheet" + +import MenuFooterClient from "./MenuFooterClient" +import MenuHeader from "./MenuHeader" +import MobileLanguagePicker from "./MobileLanguagePicker" + +type MobileMenuView = "menu" | "language-picker" + +type MobileMenuContextType = { + currentView: MobileMenuView + setCurrentView: (view: MobileMenuView) => void +} + +const MobileMenuContext = createContext( + undefined +) + +export const useMobileMenu = () => { + const context = useContext(MobileMenuContext) + if (!context) { + throw new Error("useMobileMenu must be used within MobileMenuContent") + } + return context +} + +type MobileMenuContentProps = { + menuBody: React.ReactNode + languages: LocaleDisplayInfo[] +} + +const MobileMenuContent = ({ menuBody, languages }: MobileMenuContentProps) => { + const [currentView, setCurrentView] = useState("menu") + + return ( + + + {/* HEADER ELEMENTS: SITE NAME, CLOSE BUTTON */} + + + + + {/* MAIN NAV ACCORDION CONTENTS OF MOBILE MENU */} +
+ {currentView === "menu" ? ( + menuBody + ) : ( + + )} +
+ + {/* FOOTER ELEMENTS: SEARCH, LIGHT/DARK, LANGUAGES */} + {currentView === "menu" && ( + + + + )} +
+
+ ) +} + +export default MobileMenuContent diff --git a/src/components/Nav/Mobile/index.tsx b/src/components/Nav/Mobile/index.tsx index 98e429df568..ca411c5babf 100644 --- a/src/components/Nav/Mobile/index.tsx +++ b/src/components/Nav/Mobile/index.tsx @@ -1,10 +1,4 @@ -import { - Sheet, - SheetContent, - SheetFooter, - SheetHeader, - SheetTrigger, -} from "@/components/ui/sheet" +import { Sheet, SheetTrigger } from "@/components/ui/sheet" import { cn } from "@/lib/utils/cn" @@ -12,12 +6,15 @@ import { ButtonProps } from "../../ui/buttons/Button" import HamburgerButton from "./HamburgerButton" import MenuBody from "./MenuBody" -import MenuFooter from "./MenuFooter" -import MenuHeader from "./MenuHeader" +import MobileMenuContent from "./MobileMenuContent" + +import { getLanguagesDisplayInfo } from "@/lib/nav/links" type MobileMenuProps = ButtonProps -const MobileMenu = ({ className, ...props }: MobileMenuProps) => { +const MobileMenu = async ({ className, ...props }: MobileMenuProps) => { + const languages = await getLanguagesDisplayInfo() + return ( @@ -28,22 +25,7 @@ const MobileMenu = ({ className, ...props }: MobileMenuProps) => { {...props} /> - - {/* HEADER ELEMENTS: SITE NAME, CLOSE BUTTON */} - - - - - {/* MAIN NAV ACCORDION CONTENTS OF MOBILE MENU */} -
- -
- - {/* FOOTER ELEMENTS: SEARCH, LIGHT/DARK, LANGUAGES */} - - - -
+ } languages={languages} />
) } From 74e00e10f2315ed0cf8146b9acb4fd3cf2d17fda Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 22 Aug 2025 11:40:56 +0200 Subject: [PATCH 10/33] remove back button --- src/components/Nav/Mobile/MenuHeader.tsx | 12 +++++++++++- .../Nav/Mobile/MobileLanguagePicker.tsx | 19 ------------------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/components/Nav/Mobile/MenuHeader.tsx b/src/components/Nav/Mobile/MenuHeader.tsx index b7c43fce378..0178fd9db30 100644 --- a/src/components/Nav/Mobile/MenuHeader.tsx +++ b/src/components/Nav/Mobile/MenuHeader.tsx @@ -1,16 +1,26 @@ import { SheetClose, SheetTitle } from "@/components/ui/sheet" +import { useMobileMenu } from "./MobileMenuContent" + import { useTranslation } from "@/hooks/useTranslation" const MenuHeader = () => { const { t } = useTranslation("common") + const { setCurrentView } = useMobileMenu() return (
{t("site-title")} - {t("close")} + { + setCurrentView("menu") + }} + > + {t("close")} +
) } diff --git a/src/components/Nav/Mobile/MobileLanguagePicker.tsx b/src/components/Nav/Mobile/MobileLanguagePicker.tsx index 7a9323f96de..fa4618241dd 100644 --- a/src/components/Nav/Mobile/MobileLanguagePicker.tsx +++ b/src/components/Nav/Mobile/MobileLanguagePicker.tsx @@ -1,14 +1,12 @@ "use client" import { memo } from "react" -import { ChevronLeft } from "lucide-react" import { useParams } from "next/navigation" import { useLocale } from "next-intl" import type { LocaleDisplayInfo } from "@/lib/types" import { ButtonLink } from "@/components/ui/buttons/Button" -import { Button } from "@/components/ui/buttons/Button" import { DEFAULT_LOCALE } from "@/lib/constants" @@ -41,10 +39,6 @@ const MobileLanguagePicker = memo( const { languages: sortedLanguages, intlLanguagePreference } = useLanguagePicker(languages) - const handleBackClick = () => { - setCurrentView("menu") - } - const handleMenuItemSelect = (currentValue: string) => { push( // @ts-expect-error -- TypeScript will validate that only known `params` @@ -69,19 +63,6 @@ const MobileLanguagePicker = memo( return (
- {/* Back navigation */} -
- -
- {/* Language picker menu */}
Date: Fri, 22 Aug 2025 17:53:57 +0200 Subject: [PATCH 11/33] cleanup --- .../LanguagePicker/ClientLanguagePicker.tsx | 246 ------------------ .../LanguagePicker/ClientMenuItem.tsx | 84 ------ src/components/LanguagePicker/Desktop.tsx | 103 ++++++++ .../LanguagePicker/LanguagePickerFooter.tsx | 55 ++++ .../LanguagePicker/LanguagePickerMenu.tsx | 81 ++++++ src/components/LanguagePicker/Mobile.tsx | 71 +++++ .../LanguagePicker/MobileCloseBar.tsx | 21 -- src/components/LanguagePicker/index.tsx | 8 +- src/components/Nav/Mobile/MenuAccordion.tsx | 48 +++- src/components/Nav/Mobile/MenuBody.tsx | 6 +- src/components/Nav/Mobile/MenuFooter.tsx | 28 +- .../Nav/Mobile/MenuFooterClient.tsx | 45 ---- src/components/Nav/Mobile/MenuHeader.tsx | 2 +- ...MobileMenuContent.tsx => MenuSwitcher.tsx} | 13 +- .../Nav/Mobile/MobileLanguagePicker.tsx | 185 ------------- .../Nav/Mobile/TrackingAccordion.tsx | 48 ---- src/components/Nav/Mobile/index.tsx | 4 +- 17 files changed, 392 insertions(+), 656 deletions(-) delete mode 100644 src/components/LanguagePicker/ClientLanguagePicker.tsx delete mode 100644 src/components/LanguagePicker/ClientMenuItem.tsx create mode 100644 src/components/LanguagePicker/Desktop.tsx create mode 100644 src/components/LanguagePicker/LanguagePickerFooter.tsx create mode 100644 src/components/LanguagePicker/LanguagePickerMenu.tsx create mode 100644 src/components/LanguagePicker/Mobile.tsx delete mode 100644 src/components/LanguagePicker/MobileCloseBar.tsx delete mode 100644 src/components/Nav/Mobile/MenuFooterClient.tsx rename src/components/Nav/Mobile/{MobileMenuContent.tsx => MenuSwitcher.tsx} (85%) delete mode 100644 src/components/Nav/Mobile/MobileLanguagePicker.tsx delete mode 100644 src/components/Nav/Mobile/TrackingAccordion.tsx diff --git a/src/components/LanguagePicker/ClientLanguagePicker.tsx b/src/components/LanguagePicker/ClientLanguagePicker.tsx deleted file mode 100644 index 46738b59bd9..00000000000 --- a/src/components/LanguagePicker/ClientLanguagePicker.tsx +++ /dev/null @@ -1,246 +0,0 @@ -"use client" - -import { useParams } from "next/navigation" -import { useLocale } from "next-intl" - -import type { LocaleDisplayInfo } from "@/lib/types" - -import { ButtonLink } from "@/components/ui/buttons/Button" - -import { cn } from "@/lib/utils/cn" - -import { DEFAULT_LOCALE } from "@/lib/constants" - -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandList, -} from "../ui/command" -import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog" -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" - -import ClientMenuItem from "./ClientMenuItem" -import { MobileCloseBar } from "./MobileCloseBar" -import NoResultsCallout from "./NoResultsCallout" -import { useLanguagePicker } from "./useLanguagePicker" - -import { useEventListener } from "@/hooks/useEventListener" -import { useTranslation } from "@/hooks/useTranslation" -import { usePathname, useRouter } from "@/i18n/routing" - -type ClientLanguagePickerProps = { - children: React.ReactNode - languages: LocaleDisplayInfo[] - className?: string - handleClose?: () => void - dialog?: boolean -} - -const ClientLanguagePicker = ({ - children, - languages, - handleClose, - className, - dialog, -}: ClientLanguagePickerProps) => { - const pathname = usePathname() - const { push } = useRouter() - const params = useParams() - const { - disclosure, - languages: sortedLanguages, - intlLanguagePreference, - } = useLanguagePicker(languages, handleClose) - const { isOpen, setValue, onClose, onOpen } = disclosure - - /** - * Adds a keydown event listener to focus filter input (\). - * @param {string} event - The keydown event. - */ - useEventListener("keydown", (e) => { - if (e.key !== "\\" || e.metaKey || e.ctrlKey) return - e.preventDefault() - onOpen() - }) - - // onClick handlers - const handleMobileCloseBarClick = () => onClose() - const handleMenuItemSelect = (currentValue: string) => { - push( - // @ts-expect-error -- TypeScript will validate that only known `params` - // are used in combination with a given `pathname`. Since the two will - // always match for the current route, we can skip runtime checks. - { pathname, params }, - { - locale: currentValue, - } - ) - onClose({ - eventAction: "Locale chosen", - eventName: currentValue, - }) - } - const handleBaseLinkClose = () => - onClose({ - eventAction: "Translation program link (menu footer)", - eventName: "/contributing/translation-program", - }) - - if (dialog) { - return ( - - {children} - - {/* Mobile Close bar */} - - - - onClose({ - eventAction: "Translation program link (no results)", - eventName: "/contributing/translation-program", - }) - } - /> - - - - - ) - } - - return ( - - {children} - - - onClose({ - eventAction: "Translation program link (no results)", - eventName: "/contributing/translation-program", - }) - } - /> - - - - - ) -} - -const LanguagePickerMenu = ({ languages, onClose, onSelect }) => { - const { t } = useTranslation("common") - - return ( - { - const item = languages.find((name) => name.localeOption === value) - - if (!item) return 0 - - const { localeOption, sourceName, targetName, englishName } = item - - if ( - (localeOption + sourceName + targetName + englishName) - .toLowerCase() - .includes(search.toLowerCase()) - ) { - return 1 - } - - return 0 - }} - > -
- {t("page-languages-filter-label")}{" "} - - ({languages.length} {t("common:languages")}) - -
- - - - - - - - - {languages.map((displayInfo) => ( - - ))} - - -
- ) -} - -const LanguagePickerFooter = ({ - intlLanguagePreference, - onTranslationProgramClick, -}: { - intlLanguagePreference?: LocaleDisplayInfo - onTranslationProgramClick: () => void -}) => { - const { t } = useTranslation("common") - const locale = useLocale() - return ( -
-
-
- {locale === DEFAULT_LOCALE ? ( -

- {intlLanguagePreference - ? `${t("page-languages-translate-cta-title")} ${t(`language-${intlLanguagePreference.localeOption}`)}` - : "Translate ethereum.org"} -

- ) : ( -

- {t("page-languages-translate-cta-title")}{" "} - {t(`language-${locale}`)} -

- )} -

- {t("page-languages-recruit-community")} -

-
- - {t("get-involved")} - -
-
- ) -} - -export default ClientLanguagePicker diff --git a/src/components/LanguagePicker/ClientMenuItem.tsx b/src/components/LanguagePicker/ClientMenuItem.tsx deleted file mode 100644 index 70e52e111e0..00000000000 --- a/src/components/LanguagePicker/ClientMenuItem.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { ComponentPropsWithoutRef } from "react" -import { Check } from "lucide-react" -import { useLocale } from "next-intl" - -import type { LocaleDisplayInfo } from "@/lib/types" - -import { cn } from "@/lib/utils/cn" - -import { CommandItem } from "../ui/command" - -import ProgressBar from "./ProgressBar" - -import { useTranslation } from "@/hooks/useTranslation" - -type ItemProps = ComponentPropsWithoutRef & { - displayInfo: LocaleDisplayInfo -} - -const ClientMenuItem = ({ displayInfo, ...props }: ItemProps) => { - const { - localeOption, - sourceName, - targetName, - approvalProgress, - wordsApproved, - } = displayInfo - const { t } = useTranslation("common") - const locale = useLocale() - const isCurrent = localeOption === locale - - const getProgressInfo = (approvalProgress: number, wordsApproved: number) => { - const percentage = new Intl.NumberFormat(locale!, { - style: "percent", - }).format(approvalProgress / 100) - const progress = - approvalProgress === 0 ? "<" + percentage.replace("0", "1") : percentage - const words = new Intl.NumberFormat(locale!).format(wordsApproved) - return { progress, words } - } - - const { progress, words } = getProgressInfo(approvalProgress, wordsApproved) - - return ( - -
-
-
-

- {targetName} -

-
-

{sourceName}

-
- {isCurrent && ( - - )} -
-

- {progress} {t("page-languages-translated")} • {words}{" "} - {t("page-languages-words")} -

- -
- ) -} - -export default ClientMenuItem diff --git a/src/components/LanguagePicker/Desktop.tsx b/src/components/LanguagePicker/Desktop.tsx new file mode 100644 index 00000000000..cf249a4a445 --- /dev/null +++ b/src/components/LanguagePicker/Desktop.tsx @@ -0,0 +1,103 @@ +"use client" + +import { useParams } from "next/navigation" + +import type { LocaleDisplayInfo } from "@/lib/types" + +import { cn } from "@/lib/utils/cn" + +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" + +import LanguagePickerFooter from "./LanguagePickerFooter" +import LanguagePickerMenu from "./LanguagePickerMenu" +import { useLanguagePicker } from "./useLanguagePicker" + +import { useEventListener } from "@/hooks/useEventListener" +import { usePathname, useRouter } from "@/i18n/routing" + +type DesktopLanguagePickerProps = { + children: React.ReactNode + languages: LocaleDisplayInfo[] + className?: string + handleClose?: () => void +} + +const DesktopLanguagePicker = ({ + children, + languages, + handleClose, + className, +}: DesktopLanguagePickerProps) => { + const pathname = usePathname() + const { push } = useRouter() + const params = useParams() + const { + disclosure, + languages: sortedLanguages, + intlLanguagePreference, + } = useLanguagePicker(languages, handleClose) + const { isOpen, setValue, onClose, onOpen } = disclosure + + /** + * Adds a keydown event listener to focus filter input (\). + * @param {string} event - The keydown event. + */ + useEventListener("keydown", (e) => { + if (e.key !== "\\" || e.metaKey || e.ctrlKey) return + e.preventDefault() + onOpen() + }) + + // onClick handlers + const handleMenuItemSelect = (currentValue: string) => { + push( + // @ts-expect-error -- TypeScript will validate that only known `params` + // are used in combination with a given `pathname`. Since the two will + // always match for the current route, we can skip runtime checks. + { pathname, params }, + { + locale: currentValue, + } + ) + onClose({ + eventAction: "Locale chosen", + eventName: currentValue, + }) + } + const handleBaseLinkClose = () => + onClose({ + eventAction: "Translation program link (menu footer)", + eventName: "/contributing/translation-program", + }) + + return ( + + {children} + + + onClose({ + eventAction: "Translation program link (no results)", + eventName: "/contributing/translation-program", + }) + } + /> + + + + + ) +} + +export default DesktopLanguagePicker diff --git a/src/components/LanguagePicker/LanguagePickerFooter.tsx b/src/components/LanguagePicker/LanguagePickerFooter.tsx new file mode 100644 index 00000000000..f5422332f63 --- /dev/null +++ b/src/components/LanguagePicker/LanguagePickerFooter.tsx @@ -0,0 +1,55 @@ +import { useLocale } from "next-intl" + +import type { LocaleDisplayInfo } from "@/lib/types" + +import { ButtonLink } from "@/components/ui/buttons/Button" + +import { DEFAULT_LOCALE } from "@/lib/constants" + +import { useTranslation } from "@/hooks/useTranslation" + +type LanguagePickerFooterProps = { + intlLanguagePreference?: LocaleDisplayInfo + onTranslationProgramClick: () => void +} + +const LanguagePickerFooter = ({ + intlLanguagePreference, + onTranslationProgramClick, +}: LanguagePickerFooterProps) => { + const { t } = useTranslation("common") + const locale = useLocale() + return ( +
+
+
+ {locale === DEFAULT_LOCALE ? ( +

+ {intlLanguagePreference + ? `${t("page-languages-translate-cta-title")} ${t(`language-${intlLanguagePreference.localeOption}`)}` + : "Translate ethereum.org"} +

+ ) : ( +

+ {t("page-languages-translate-cta-title")}{" "} + {t(`language-${locale}`)} +

+ )} +

+ {t("page-languages-recruit-community")} +

+
+ + {t("get-involved")} + +
+
+ ) +} + +export default LanguagePickerFooter diff --git a/src/components/LanguagePicker/LanguagePickerMenu.tsx b/src/components/LanguagePicker/LanguagePickerMenu.tsx new file mode 100644 index 00000000000..6baadf14e74 --- /dev/null +++ b/src/components/LanguagePicker/LanguagePickerMenu.tsx @@ -0,0 +1,81 @@ +import type { LocaleDisplayInfo } from "@/lib/types" + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandList, +} from "../ui/command" + +import MenuItem from "./MenuItem" +import NoResultsCallout from "./NoResultsCallout" + +import { useTranslation } from "@/hooks/useTranslation" + +type LanguagePickerMenuProps = { + languages: LocaleDisplayInfo[] + onClose: () => void + onSelect: (value: string) => void +} + +const LanguagePickerMenu = ({ + languages, + onClose, + onSelect, +}: LanguagePickerMenuProps) => { + const { t } = useTranslation("common") + + return ( + { + const item = languages.find((name) => name.localeOption === value) + + if (!item) return 0 + + const { localeOption, sourceName, targetName, englishName } = item + + if ( + (localeOption + sourceName + targetName + englishName) + .toLowerCase() + .includes(search.toLowerCase()) + ) { + return 1 + } + + return 0 + }} + > +
+ {t("page-languages-filter-label")}{" "} + + ({languages.length} {t("common:languages")}) + +
+ + + + + + + + + {languages.map((displayInfo) => ( + + ))} + + +
+ ) +} + +export default LanguagePickerMenu diff --git a/src/components/LanguagePicker/Mobile.tsx b/src/components/LanguagePicker/Mobile.tsx new file mode 100644 index 00000000000..49f80233919 --- /dev/null +++ b/src/components/LanguagePicker/Mobile.tsx @@ -0,0 +1,71 @@ +"use client" + +import { useParams } from "next/navigation" + +import type { LocaleDisplayInfo } from "@/lib/types" + +import { useMobileMenu } from "../Nav/Mobile/MenuSwitcher" + +import LanguagePickerFooter from "./LanguagePickerFooter" +import LanguagePickerMenu from "./LanguagePickerMenu" +import { useLanguagePicker } from "./useLanguagePicker" + +import { usePathname, useRouter } from "@/i18n/routing" + +type MobileLanguagePickerProps = { + languages: LocaleDisplayInfo[] +} + +const MobileLanguagePicker = ({ languages }: MobileLanguagePickerProps) => { + const { setCurrentView } = useMobileMenu() + const pathname = usePathname() + const { push } = useRouter() + const params = useParams() + const { languages: sortedLanguages, intlLanguagePreference } = + useLanguagePicker(languages) + + const handleMenuItemSelect = (currentValue: string) => { + push( + // @ts-expect-error -- TypeScript will validate that only known `params` + // are used in combination with a given `pathname`. Since the two will + // always match for the current route, we can skip runtime checks. + { pathname, params }, + { + locale: currentValue, + } + ) + // Close the sheet by going back to menu view + setCurrentView("menu") + } + + const handleNoResultsClose = () => { + // Navigate to translation program or handle as needed + } + + const handleTranslationProgramClick = () => { + // Navigate to translation program + } + + return ( +
+ {/* Language picker menu */} +
+ +
+ + {/* Footer */} + +
+ ) +} + +MobileLanguagePicker.displayName = "MobileLanguagePicker" + +export default MobileLanguagePicker diff --git a/src/components/LanguagePicker/MobileCloseBar.tsx b/src/components/LanguagePicker/MobileCloseBar.tsx deleted file mode 100644 index 60937dcdf19..00000000000 --- a/src/components/LanguagePicker/MobileCloseBar.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { MouseEventHandler } from "react" - -import { Button } from "../ui/buttons/Button" - -import { useTranslation } from "@/hooks/useTranslation" - -type MobileCloseBarProps = { - handleClick: MouseEventHandler -} - -export const MobileCloseBar = ({ handleClick }: MobileCloseBarProps) => { - const { t } = useTranslation() - - return ( -
- -
- ) -} diff --git a/src/components/LanguagePicker/index.tsx b/src/components/LanguagePicker/index.tsx index a94015570ce..22837778087 100644 --- a/src/components/LanguagePicker/index.tsx +++ b/src/components/LanguagePicker/index.tsx @@ -1,4 +1,4 @@ -import ClientLanguagePicker from "./ClientLanguagePicker" +import DesktopLanguagePicker from "./Desktop" import { getLanguagesDisplayInfo } from "@/lib/nav/links" @@ -13,19 +13,17 @@ const LanguagePicker = async ({ children, handleClose, className, - dialog, }: LanguagePickerProps) => { const languages = await getLanguagesDisplayInfo() return ( - {children} - + ) } diff --git a/src/components/Nav/Mobile/MenuAccordion.tsx b/src/components/Nav/Mobile/MenuAccordion.tsx index f9a3ef3db3a..970569f9ca9 100644 --- a/src/components/Nav/Mobile/MenuAccordion.tsx +++ b/src/components/Nav/Mobile/MenuAccordion.tsx @@ -1,8 +1,54 @@ +"use client" + +import { useState } from "react" import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { Lang } from "@/lib/types" + import { cn } from "@/lib/utils/cn" +import { trackCustomEvent } from "@/lib/utils/matomo" + +import { + Accordion as BaseAccordion, + AccordionContent, + AccordionItem, +} from "../../ui/accordion" + +type AccordionProps = { + locale: Lang + children: React.ReactNode +} + +const Accordion = ({ locale, children }: AccordionProps) => { + const [currentValue, setCurrentValue] = useState( + undefined + ) -import { Accordion, AccordionContent, AccordionItem } from "../../ui/accordion" + const handleValueChange = (value: string | undefined) => { + const isExpanded = currentValue === value + + trackCustomEvent({ + eventCategory: "Mobile navigation menu", + eventAction: "Section changed", + eventName: `${ + isExpanded ? "Close" : "Open" + } section: ${locale} - ${value || currentValue}`, + }) + + setCurrentValue(value) + } + + return ( + + {children} + + ) +} type AccordionTriggerProps = { heading?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" diff --git a/src/components/Nav/Mobile/MenuBody.tsx b/src/components/Nav/Mobile/MenuBody.tsx index b47562809e1..2b59902f7c4 100644 --- a/src/components/Nav/Mobile/MenuBody.tsx +++ b/src/components/Nav/Mobile/MenuBody.tsx @@ -11,11 +11,11 @@ import type { Level } from "../types" import ExpandIcon from "./ExpandIcon" import LvlAccordion from "./LvlAccordion" import { + Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "./MenuAccordion" -import { TrackingAccordion } from "./TrackingAccordion" import { getNavigation } from "@/lib/nav/links" @@ -25,7 +25,7 @@ const MenuBody = async () => { return ( ) } diff --git a/src/components/Nav/Mobile/MenuFooter.tsx b/src/components/Nav/Mobile/MenuFooter.tsx index 761f23286d7..7cdcf81d7ad 100644 --- a/src/components/Nav/Mobile/MenuFooter.tsx +++ b/src/components/Nav/Mobile/MenuFooter.tsx @@ -1,17 +1,25 @@ +"use client" + import { Languages, Search as SearchIcon } from "lucide-react" -import { getTranslations } from "next-intl/server" -import LanguagePicker from "@/components/LanguagePicker" import Search from "@/components/Search" import { MOBILE_LANGUAGE_BUTTON_NAME } from "@/lib/constants" import FooterButton from "./FooterButton" import FooterItemText from "./FooterItemText" +import { useMobileMenu } from "./MenuSwitcher" import ThemeToggleFooterButton from "./ThemeToggleFooterButton" -const MenuFooter = async () => { - const t = await getTranslations({ namespace: "common" }) +import { useTranslation } from "@/hooks/useTranslation" + +const MenuFooter = () => { + const { t } = useTranslation("common") + const { setCurrentView } = useMobileMenu() + + const handleLanguageClick = () => { + setCurrentView("language-picker") + } return (
@@ -23,11 +31,13 @@ const MenuFooter = async () => { - - - {t("languages")} - - + + {t("languages")} +
) } diff --git a/src/components/Nav/Mobile/MenuFooterClient.tsx b/src/components/Nav/Mobile/MenuFooterClient.tsx deleted file mode 100644 index ad7f97cbaa4..00000000000 --- a/src/components/Nav/Mobile/MenuFooterClient.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client" - -import { Languages, Search as SearchIcon } from "lucide-react" - -import Search from "@/components/Search" - -import { MOBILE_LANGUAGE_BUTTON_NAME } from "@/lib/constants" - -import FooterButton from "./FooterButton" -import FooterItemText from "./FooterItemText" -import { useMobileMenu } from "./MobileMenuContent" -import ThemeToggleFooterButton from "./ThemeToggleFooterButton" - -import { useTranslation } from "@/hooks/useTranslation" - -const MenuFooterClient = () => { - const { t } = useTranslation("common") - const { setCurrentView } = useMobileMenu() - - const handleLanguageClick = () => { - setCurrentView("language-picker") - } - - return ( -
- - - {t("search")} - - - - - - - {t("languages")} - -
- ) -} - -export default MenuFooterClient diff --git a/src/components/Nav/Mobile/MenuHeader.tsx b/src/components/Nav/Mobile/MenuHeader.tsx index 0178fd9db30..23267bc2db4 100644 --- a/src/components/Nav/Mobile/MenuHeader.tsx +++ b/src/components/Nav/Mobile/MenuHeader.tsx @@ -1,6 +1,6 @@ import { SheetClose, SheetTitle } from "@/components/ui/sheet" -import { useMobileMenu } from "./MobileMenuContent" +import { useMobileMenu } from "./MenuSwitcher" import { useTranslation } from "@/hooks/useTranslation" diff --git a/src/components/Nav/Mobile/MobileMenuContent.tsx b/src/components/Nav/Mobile/MenuSwitcher.tsx similarity index 85% rename from src/components/Nav/Mobile/MobileMenuContent.tsx rename to src/components/Nav/Mobile/MenuSwitcher.tsx index 17f68cee616..d31cfcdb034 100644 --- a/src/components/Nav/Mobile/MobileMenuContent.tsx +++ b/src/components/Nav/Mobile/MenuSwitcher.tsx @@ -6,9 +6,10 @@ import type { LocaleDisplayInfo } from "@/lib/types" import { SheetContent, SheetFooter, SheetHeader } from "@/components/ui/sheet" -import MenuFooterClient from "./MenuFooterClient" +import MobileLanguagePicker from "../../LanguagePicker/Mobile" + +import MenuFooter from "./MenuFooter" import MenuHeader from "./MenuHeader" -import MobileLanguagePicker from "./MobileLanguagePicker" type MobileMenuView = "menu" | "language-picker" @@ -29,12 +30,12 @@ export const useMobileMenu = () => { return context } -type MobileMenuContentProps = { +type MenuSwitcherProps = { menuBody: React.ReactNode languages: LocaleDisplayInfo[] } -const MobileMenuContent = ({ menuBody, languages }: MobileMenuContentProps) => { +const MenuSwitcher = ({ menuBody, languages }: MenuSwitcherProps) => { const [currentView, setCurrentView] = useState("menu") return ( @@ -57,7 +58,7 @@ const MobileMenuContent = ({ menuBody, languages }: MobileMenuContentProps) => { {/* FOOTER ELEMENTS: SEARCH, LIGHT/DARK, LANGUAGES */} {currentView === "menu" && ( - + )} @@ -65,4 +66,4 @@ const MobileMenuContent = ({ menuBody, languages }: MobileMenuContentProps) => { ) } -export default MobileMenuContent +export default MenuSwitcher diff --git a/src/components/Nav/Mobile/MobileLanguagePicker.tsx b/src/components/Nav/Mobile/MobileLanguagePicker.tsx deleted file mode 100644 index fa4618241dd..00000000000 --- a/src/components/Nav/Mobile/MobileLanguagePicker.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"use client" - -import { memo } from "react" -import { useParams } from "next/navigation" -import { useLocale } from "next-intl" - -import type { LocaleDisplayInfo } from "@/lib/types" - -import { ButtonLink } from "@/components/ui/buttons/Button" - -import { DEFAULT_LOCALE } from "@/lib/constants" - -import ClientMenuItem from "../../LanguagePicker/ClientMenuItem" -import NoResultsCallout from "../../LanguagePicker/NoResultsCallout" -import { useLanguagePicker } from "../../LanguagePicker/useLanguagePicker" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandList, -} from "../../ui/command" - -import { useMobileMenu } from "./MobileMenuContent" - -import { useTranslation } from "@/hooks/useTranslation" -import { usePathname, useRouter } from "@/i18n/routing" - -type MobileLanguagePickerProps = { - languages: LocaleDisplayInfo[] -} - -const MobileLanguagePicker = memo( - ({ languages }: MobileLanguagePickerProps) => { - const { setCurrentView } = useMobileMenu() - const pathname = usePathname() - const { push } = useRouter() - const params = useParams() - const { languages: sortedLanguages, intlLanguagePreference } = - useLanguagePicker(languages) - - const handleMenuItemSelect = (currentValue: string) => { - push( - // @ts-expect-error -- TypeScript will validate that only known `params` - // are used in combination with a given `pathname`. Since the two will - // always match for the current route, we can skip runtime checks. - { pathname, params }, - { - locale: currentValue, - } - ) - // Close the sheet by going back to menu view - setCurrentView("menu") - } - - const handleNoResultsClose = () => { - // Navigate to translation program or handle as needed - } - - const handleTranslationProgramClick = () => { - // Navigate to translation program - } - - return ( -
- {/* Language picker menu */} -
- -
- - {/* Footer */} - -
- ) - } -) - -MobileLanguagePicker.displayName = "MobileLanguagePicker" - -const LanguagePickerMenu = ({ languages, onClose, onSelect }) => { - const { t } = useTranslation("common") - - return ( - { - const item = languages.find((name) => name.localeOption === value) - - if (!item) return 0 - - const { localeOption, sourceName, targetName, englishName } = item - - if ( - (localeOption + sourceName + targetName + englishName) - .toLowerCase() - .includes(search.toLowerCase()) - ) { - return 1 - } - - return 0 - }} - > -
- {t("page-languages-filter-label")}{" "} - - ({languages.length} {t("common:languages")}) - -
- - - - - - - - - {languages.map((displayInfo) => ( - - ))} - - -
- ) -} - -const LanguagePickerFooter = ({ - intlLanguagePreference, - onTranslationProgramClick, -}: { - intlLanguagePreference?: LocaleDisplayInfo - onTranslationProgramClick: () => void -}) => { - const { t } = useTranslation("common") - const locale = useLocale() - - return ( -
-
-
- {locale === DEFAULT_LOCALE ? ( -

- {intlLanguagePreference - ? `${t("page-languages-translate-cta-title")} ${t(`language-${intlLanguagePreference.localeOption}`)}` - : "Translate ethereum.org"} -

- ) : ( -

- {t("page-languages-translate-cta-title")}{" "} - {t(`language-${locale}`)} -

- )} -

- {t("page-languages-recruit-community")} -

-
- - {t("get-involved")} - -
-
- ) -} - -export default MobileLanguagePicker diff --git a/src/components/Nav/Mobile/TrackingAccordion.tsx b/src/components/Nav/Mobile/TrackingAccordion.tsx deleted file mode 100644 index d4de4814594..00000000000 --- a/src/components/Nav/Mobile/TrackingAccordion.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client" - -import { useState } from "react" - -import { Lang } from "@/lib/types" - -import { trackCustomEvent } from "@/lib/utils/matomo" - -import { Accordion } from "./MenuAccordion" - -type TrackingAccordionProps = { - locale: Lang - children: React.ReactNode -} - -export const TrackingAccordion = ({ - locale, - children, -}: TrackingAccordionProps) => { - const [currentValue, setCurrentValue] = useState( - undefined - ) - - const handleValueChange = (value: string | undefined) => { - const isExpanded = currentValue === value - - trackCustomEvent({ - eventCategory: "Mobile navigation menu", - eventAction: "Section changed", - eventName: `${ - isExpanded ? "Close" : "Open" - } section: ${locale} - ${value || currentValue}`, - }) - - setCurrentValue(value) - } - - return ( - - {children} - - ) -} diff --git a/src/components/Nav/Mobile/index.tsx b/src/components/Nav/Mobile/index.tsx index ca411c5babf..460b304fcd7 100644 --- a/src/components/Nav/Mobile/index.tsx +++ b/src/components/Nav/Mobile/index.tsx @@ -6,7 +6,7 @@ import { ButtonProps } from "../../ui/buttons/Button" import HamburgerButton from "./HamburgerButton" import MenuBody from "./MenuBody" -import MobileMenuContent from "./MobileMenuContent" +import MenuSwitcher from "./MenuSwitcher" import { getLanguagesDisplayInfo } from "@/lib/nav/links" @@ -25,7 +25,7 @@ const MobileMenu = async ({ className, ...props }: MobileMenuProps) => { {...props} /> - } languages={languages} /> + } languages={languages} /> ) } From 24db03877899f06e9918ecddf3e70c248114cd6f Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 25 Aug 2025 14:42:36 +0200 Subject: [PATCH 12/33] refactor mobile nav --- src/components/LanguagePicker/Mobile.tsx | 5 - src/components/Nav/DesktopNav.tsx | 4 +- src/components/Nav/Mobile/MenuBody.tsx | 65 -------- src/components/Nav/Mobile/MenuFooter.tsx | 45 ------ src/components/Nav/Mobile/MenuSwitcher.tsx | 69 -------- src/components/Nav/Mobile/index.tsx | 33 ---- .../Nav/{Mobile => MobileMenu}/ExpandIcon.tsx | 0 .../{Mobile => MobileMenu}/FooterButton.tsx | 0 .../{Mobile => MobileMenu}/FooterItemText.tsx | 0 .../HamburgerButton.tsx | 0 .../{Mobile => MobileMenu}/LvlAccordion.tsx | 71 ++++---- .../{Mobile => MobileMenu}/MenuAccordion.tsx | 0 .../Nav/{Mobile => MobileMenu}/MenuHeader.tsx | 12 +- .../ThemeToggleFooterButton.tsx | 0 src/components/Nav/MobileMenu/index.tsx | 152 ++++++++++++++++++ src/components/Nav/MobileNav.tsx | 6 +- src/components/Nav/index.tsx | 9 +- 17 files changed, 206 insertions(+), 265 deletions(-) delete mode 100644 src/components/Nav/Mobile/MenuBody.tsx delete mode 100644 src/components/Nav/Mobile/MenuFooter.tsx delete mode 100644 src/components/Nav/Mobile/MenuSwitcher.tsx delete mode 100644 src/components/Nav/Mobile/index.tsx rename src/components/Nav/{Mobile => MobileMenu}/ExpandIcon.tsx (100%) rename src/components/Nav/{Mobile => MobileMenu}/FooterButton.tsx (100%) rename src/components/Nav/{Mobile => MobileMenu}/FooterItemText.tsx (100%) rename src/components/Nav/{Mobile => MobileMenu}/HamburgerButton.tsx (100%) rename src/components/Nav/{Mobile => MobileMenu}/LvlAccordion.tsx (76%) rename src/components/Nav/{Mobile => MobileMenu}/MenuAccordion.tsx (100%) rename src/components/Nav/{Mobile => MobileMenu}/MenuHeader.tsx (61%) rename src/components/Nav/{Mobile => MobileMenu}/ThemeToggleFooterButton.tsx (100%) create mode 100644 src/components/Nav/MobileMenu/index.tsx diff --git a/src/components/LanguagePicker/Mobile.tsx b/src/components/LanguagePicker/Mobile.tsx index 49f80233919..a477bd4c86b 100644 --- a/src/components/LanguagePicker/Mobile.tsx +++ b/src/components/LanguagePicker/Mobile.tsx @@ -4,8 +4,6 @@ import { useParams } from "next/navigation" import type { LocaleDisplayInfo } from "@/lib/types" -import { useMobileMenu } from "../Nav/Mobile/MenuSwitcher" - import LanguagePickerFooter from "./LanguagePickerFooter" import LanguagePickerMenu from "./LanguagePickerMenu" import { useLanguagePicker } from "./useLanguagePicker" @@ -17,7 +15,6 @@ type MobileLanguagePickerProps = { } const MobileLanguagePicker = ({ languages }: MobileLanguagePickerProps) => { - const { setCurrentView } = useMobileMenu() const pathname = usePathname() const { push } = useRouter() const params = useParams() @@ -34,8 +31,6 @@ const MobileLanguagePicker = ({ languages }: MobileLanguagePickerProps) => { locale: currentValue, } ) - // Close the sheet by going back to menu view - setCurrentView("menu") } const handleNoResultsClose = () => { diff --git a/src/components/Nav/DesktopNav.tsx b/src/components/Nav/DesktopNav.tsx index ac045101669..4adc6658677 100644 --- a/src/components/Nav/DesktopNav.tsx +++ b/src/components/Nav/DesktopNav.tsx @@ -12,7 +12,7 @@ import { Button } from "../ui/buttons/Button" import Menu from "./Menu" import { ThemeToggleButton } from "./ThemeToggleButton" -export const DesktopNav = async ({ className }: { className?: string }) => { +const DesktopNav = async ({ className }: { className?: string }) => { const t = await getTranslations({ namespace: "common" }) const locale = await getLocale() @@ -44,3 +44,5 @@ export const DesktopNav = async ({ className }: { className?: string }) => {
) } + +export default DesktopNav diff --git a/src/components/Nav/Mobile/MenuBody.tsx b/src/components/Nav/Mobile/MenuBody.tsx deleted file mode 100644 index 2b59902f7c4..00000000000 --- a/src/components/Nav/Mobile/MenuBody.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { getLocale } from "next-intl/server" - -import { Lang } from "@/lib/types" - -import { cn } from "@/lib/utils/cn" - -import { SECTION_LABELS } from "@/lib/constants" - -import type { Level } from "../types" - -import ExpandIcon from "./ExpandIcon" -import LvlAccordion from "./LvlAccordion" -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "./MenuAccordion" - -import { getNavigation } from "@/lib/nav/links" - -const MenuBody = async () => { - const locale = await getLocale() - const linkSections = await getNavigation(locale as Lang) - - return ( - - ) -} - -export default MenuBody diff --git a/src/components/Nav/Mobile/MenuFooter.tsx b/src/components/Nav/Mobile/MenuFooter.tsx deleted file mode 100644 index 7cdcf81d7ad..00000000000 --- a/src/components/Nav/Mobile/MenuFooter.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client" - -import { Languages, Search as SearchIcon } from "lucide-react" - -import Search from "@/components/Search" - -import { MOBILE_LANGUAGE_BUTTON_NAME } from "@/lib/constants" - -import FooterButton from "./FooterButton" -import FooterItemText from "./FooterItemText" -import { useMobileMenu } from "./MenuSwitcher" -import ThemeToggleFooterButton from "./ThemeToggleFooterButton" - -import { useTranslation } from "@/hooks/useTranslation" - -const MenuFooter = () => { - const { t } = useTranslation("common") - const { setCurrentView } = useMobileMenu() - - const handleLanguageClick = () => { - setCurrentView("language-picker") - } - - return ( -
- - - {t("search")} - - - - - - - {t("languages")} - -
- ) -} - -export default MenuFooter diff --git a/src/components/Nav/Mobile/MenuSwitcher.tsx b/src/components/Nav/Mobile/MenuSwitcher.tsx deleted file mode 100644 index d31cfcdb034..00000000000 --- a/src/components/Nav/Mobile/MenuSwitcher.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client" - -import { createContext, useContext, useState } from "react" - -import type { LocaleDisplayInfo } from "@/lib/types" - -import { SheetContent, SheetFooter, SheetHeader } from "@/components/ui/sheet" - -import MobileLanguagePicker from "../../LanguagePicker/Mobile" - -import MenuFooter from "./MenuFooter" -import MenuHeader from "./MenuHeader" - -type MobileMenuView = "menu" | "language-picker" - -type MobileMenuContextType = { - currentView: MobileMenuView - setCurrentView: (view: MobileMenuView) => void -} - -const MobileMenuContext = createContext( - undefined -) - -export const useMobileMenu = () => { - const context = useContext(MobileMenuContext) - if (!context) { - throw new Error("useMobileMenu must be used within MobileMenuContent") - } - return context -} - -type MenuSwitcherProps = { - menuBody: React.ReactNode - languages: LocaleDisplayInfo[] -} - -const MenuSwitcher = ({ menuBody, languages }: MenuSwitcherProps) => { - const [currentView, setCurrentView] = useState("menu") - - return ( - - - {/* HEADER ELEMENTS: SITE NAME, CLOSE BUTTON */} - - - - - {/* MAIN NAV ACCORDION CONTENTS OF MOBILE MENU */} -
- {currentView === "menu" ? ( - menuBody - ) : ( - - )} -
- - {/* FOOTER ELEMENTS: SEARCH, LIGHT/DARK, LANGUAGES */} - {currentView === "menu" && ( - - - - )} -
-
- ) -} - -export default MenuSwitcher diff --git a/src/components/Nav/Mobile/index.tsx b/src/components/Nav/Mobile/index.tsx deleted file mode 100644 index 460b304fcd7..00000000000 --- a/src/components/Nav/Mobile/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Sheet, SheetTrigger } from "@/components/ui/sheet" - -import { cn } from "@/lib/utils/cn" - -import { ButtonProps } from "../../ui/buttons/Button" - -import HamburgerButton from "./HamburgerButton" -import MenuBody from "./MenuBody" -import MenuSwitcher from "./MenuSwitcher" - -import { getLanguagesDisplayInfo } from "@/lib/nav/links" - -type MobileMenuProps = ButtonProps - -const MobileMenu = async ({ className, ...props }: MobileMenuProps) => { - const languages = await getLanguagesDisplayInfo() - - return ( - - - - - } languages={languages} /> - - ) -} - -export default MobileMenu diff --git a/src/components/Nav/Mobile/ExpandIcon.tsx b/src/components/Nav/MobileMenu/ExpandIcon.tsx similarity index 100% rename from src/components/Nav/Mobile/ExpandIcon.tsx rename to src/components/Nav/MobileMenu/ExpandIcon.tsx diff --git a/src/components/Nav/Mobile/FooterButton.tsx b/src/components/Nav/MobileMenu/FooterButton.tsx similarity index 100% rename from src/components/Nav/Mobile/FooterButton.tsx rename to src/components/Nav/MobileMenu/FooterButton.tsx diff --git a/src/components/Nav/Mobile/FooterItemText.tsx b/src/components/Nav/MobileMenu/FooterItemText.tsx similarity index 100% rename from src/components/Nav/Mobile/FooterItemText.tsx rename to src/components/Nav/MobileMenu/FooterItemText.tsx diff --git a/src/components/Nav/Mobile/HamburgerButton.tsx b/src/components/Nav/MobileMenu/HamburgerButton.tsx similarity index 100% rename from src/components/Nav/Mobile/HamburgerButton.tsx rename to src/components/Nav/MobileMenu/HamburgerButton.tsx diff --git a/src/components/Nav/Mobile/LvlAccordion.tsx b/src/components/Nav/MobileMenu/LvlAccordion.tsx similarity index 76% rename from src/components/Nav/Mobile/LvlAccordion.tsx rename to src/components/Nav/MobileMenu/LvlAccordion.tsx index 124073d32d9..79fed6d2756 100644 --- a/src/components/Nav/Mobile/LvlAccordion.tsx +++ b/src/components/Nav/MobileMenu/LvlAccordion.tsx @@ -1,11 +1,10 @@ -"use client" - -import { useState } from "react" -import { useLocale } from "next-intl" +import { getLocale } from "next-intl/server" import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { Lang } from "@/lib/types" + import { cn } from "@/lib/utils/cn" -import { trackCustomEvent } from "@/lib/utils/matomo" +// import { trackCustomEvent } from "@/lib/utils/matomo" import { cleanPath } from "@/lib/utils/url" import { Button } from "../../ui/buttons/Button" @@ -20,8 +19,6 @@ import { AccordionTrigger, } from "./MenuAccordion" -import { usePathname } from "@/i18n/routing" - type LvlAccordionProps = { lvl: Level items: NavItem[] @@ -42,17 +39,29 @@ const backgroundColorPerLevel = { 4: "bg-background-high", } -const LvlAccordion = ({ lvl, items, activeSection }: LvlAccordionProps) => { - const pathname = usePathname() - const locale = useLocale() - const [value, setValue] = useState("") +const LvlAccordion = async (props: LvlAccordionProps) => { + const locale = await getLocale() return ( - + + + + ) +} +const LvlAccordionItems = async ({ + lvl, + items, + activeSection, +}: LvlAccordionProps) => { + // const locale = await getLocale() + // TODO: get pathname from the current page + const pathname = "/" + + return ( + <> {items.map(({ label, description, ...action }) => { const isLink = "href" in action const isActivePage = isLink && cleanPath(pathname) === action.href - const isExpanded = value === label const nestedAccordionSpacingMap = { 2: "ps-8", @@ -81,14 +90,13 @@ const LvlAccordion = ({ lvl, items, activeSection }: LvlAccordionProps) => { > { - trackCustomEvent({ - eventCategory: "Mobile navigation menu", - eventAction: `Menu: ${locale} - ${activeSection}`, - eventName: action.href!, - }) - // onToggle() - }} + // onClick={() => { + // trackCustomEvent({ + // eventCategory: "Mobile navigation menu", + // eventAction: `Menu: ${locale} - ${activeSection}`, + // eventName: action.href!, + // }) + // }} >

{ { - trackCustomEvent({ - eventCategory: "Mobile navigation menu", - eventAction: `Level ${lvl - 1} section changed`, - eventName: `${ - isExpanded ? "Close" : "Open" - } section: ${label} - ${description.slice(0, 16)}...`, - }) - }} + // onClick={() => { + // trackCustomEvent({ + // eventCategory: "Mobile navigation menu", + // eventAction: `Level ${lvl - 1} section changed`, + // eventName: `${ + // isExpanded ? "Close" : "Open" + // } section: ${label} - ${description.slice(0, 16)}...`, + // }) + // }} >

@@ -160,13 +168,12 @@ const LvlAccordion = ({ lvl, items, activeSection }: LvlAccordionProps) => { lvl={(lvl + 1) as Level} items={action.items} activeSection={activeSection} - // onToggle={onToggle} /> ) })} - + ) } diff --git a/src/components/Nav/Mobile/MenuAccordion.tsx b/src/components/Nav/MobileMenu/MenuAccordion.tsx similarity index 100% rename from src/components/Nav/Mobile/MenuAccordion.tsx rename to src/components/Nav/MobileMenu/MenuAccordion.tsx diff --git a/src/components/Nav/Mobile/MenuHeader.tsx b/src/components/Nav/MobileMenu/MenuHeader.tsx similarity index 61% rename from src/components/Nav/Mobile/MenuHeader.tsx rename to src/components/Nav/MobileMenu/MenuHeader.tsx index 23267bc2db4..b7c43fce378 100644 --- a/src/components/Nav/Mobile/MenuHeader.tsx +++ b/src/components/Nav/MobileMenu/MenuHeader.tsx @@ -1,26 +1,16 @@ import { SheetClose, SheetTitle } from "@/components/ui/sheet" -import { useMobileMenu } from "./MenuSwitcher" - import { useTranslation } from "@/hooks/useTranslation" const MenuHeader = () => { const { t } = useTranslation("common") - const { setCurrentView } = useMobileMenu() return (
{t("site-title")} - { - setCurrentView("menu") - }} - > - {t("close")} - + {t("close")}
) } diff --git a/src/components/Nav/Mobile/ThemeToggleFooterButton.tsx b/src/components/Nav/MobileMenu/ThemeToggleFooterButton.tsx similarity index 100% rename from src/components/Nav/Mobile/ThemeToggleFooterButton.tsx rename to src/components/Nav/MobileMenu/ThemeToggleFooterButton.tsx diff --git a/src/components/Nav/MobileMenu/index.tsx b/src/components/Nav/MobileMenu/index.tsx new file mode 100644 index 00000000000..152bfbe017f --- /dev/null +++ b/src/components/Nav/MobileMenu/index.tsx @@ -0,0 +1,152 @@ +import { Languages, SearchIcon } from "lucide-react" +import { getLocale, getTranslations } from "next-intl/server" +import { Trigger as TabsTrigger } from "@radix-ui/react-tabs" + +import { Lang } from "@/lib/types" + +import MobileLanguagePicker from "@/components/LanguagePicker/Mobile" +import ExpandIcon from "@/components/Nav/MobileMenu/ExpandIcon" +import LvlAccordion from "@/components/Nav/MobileMenu/LvlAccordion" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/Nav/MobileMenu/MenuAccordion" +import Search from "@/components/Search" +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTrigger, +} from "@/components/ui/sheet" +import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs" + +import { cn } from "@/lib/utils/cn" + +import { MOBILE_LANGUAGE_BUTTON_NAME, SECTION_LABELS } from "@/lib/constants" + +import FooterButton from "./FooterButton" +import FooterItemText from "./FooterItemText" +import HamburgerButton from "./HamburgerButton" +import MenuHeader from "./MenuHeader" +import ThemeToggleFooterButton from "./ThemeToggleFooterButton" + +import { getLanguagesDisplayInfo, getNavigation } from "@/lib/nav/links" + +type MobileMenuProps = { + className?: string +} + +export default async function MobileMenu({ + className, + ...props +}: MobileMenuProps) { + const t = await getTranslations({ namespace: "common" }) + + return ( + + + + + + + + + + +
+ + + + + + +
+ + + +
+ + + {t("search")} + + +
+
+ +
+
+ + + {t("languages")} + + +
+
+
+
+
+
+ ) +} + +async function NavigationContent() { + const locale = await getLocale() + const linkSections = await getNavigation(locale as Lang) + + return ( + + ) +} + +async function LanguageContent() { + const languages = await getLanguagesDisplayInfo() + + return ( +
+ +
+ ) +} diff --git a/src/components/Nav/MobileNav.tsx b/src/components/Nav/MobileNav.tsx index 85df3a0f04f..2557047ba0c 100644 --- a/src/components/Nav/MobileNav.tsx +++ b/src/components/Nav/MobileNav.tsx @@ -2,9 +2,9 @@ import { cn } from "@/lib/utils/cn" import Search from "../Search" -import MobileMenu from "./Mobile" +import MobileMenu from "./MobileMenu" -export const MobileNav = ({ className }: { className?: string }) => { +const MobileNav = ({ className }: { className?: string }) => { return (
@@ -12,3 +12,5 @@ export const MobileNav = ({ className }: { className?: string }) => {
) } + +export default MobileNav diff --git a/src/components/Nav/index.tsx b/src/components/Nav/index.tsx index ad4bc98173f..eaade75b909 100644 --- a/src/components/Nav/index.tsx +++ b/src/components/Nav/index.tsx @@ -1,11 +1,16 @@ +import dynamic from "next/dynamic" import { getLocale, getTranslations } from "next-intl/server" import { EthHomeIcon } from "@/components/icons" import { BaseLink } from "../ui/Link" -import { DesktopNav } from "./DesktopNav" -import { MobileNav } from "./MobileNav" +const DesktopNav = dynamic(() => import("./DesktopNav"), { + ssr: false, +}) +const MobileNav = dynamic(() => import("./MobileNav"), { + ssr: false, +}) const Nav = async () => { const locale = await getLocale() From 5bb962397421e53d426fc058a73e1a46da54f3a2 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 25 Aug 2025 14:59:04 +0200 Subject: [PATCH 13/33] compute progress on the server --- src/components/LanguagePicker/MenuItem.tsx | 15 ++----------- .../LanguagePicker/localeToDisplayInfo.ts | 22 +++++++++++++++++++ src/lib/types.ts | 2 ++ 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/components/LanguagePicker/MenuItem.tsx b/src/components/LanguagePicker/MenuItem.tsx index f75a59d025c..7ca3d3b34cc 100644 --- a/src/components/LanguagePicker/MenuItem.tsx +++ b/src/components/LanguagePicker/MenuItem.tsx @@ -22,24 +22,13 @@ const MenuItem = ({ displayInfo, ...props }: ItemProps) => { sourceName, targetName, approvalProgress, - wordsApproved, + progress, + words, } = displayInfo const { t } = useTranslation("common") const locale = useLocale() const isCurrent = localeOption === locale - const getProgressInfo = (approvalProgress: number, wordsApproved: number) => { - const percentage = new Intl.NumberFormat(locale!, { - style: "percent", - }).format(approvalProgress / 100) - const progress = - approvalProgress === 0 ? "<" + percentage.replace("0", "1") : percentage - const words = new Intl.NumberFormat(locale!).format(wordsApproved) - return { progress, words } - } - - const { progress, words } = getProgressInfo(approvalProgress, wordsApproved) - return ( { + const percentage = new Intl.NumberFormat(locale, { + style: "percent", + }).format(approvalProgress / 100) + const progress = + approvalProgress === 0 ? "<" + percentage.replace("0", "1") : percentage + const words = new Intl.NumberFormat(locale).format(wordsApproved) + return { progress, words } +} + export const localeToDisplayInfo = ( localeOption: Lang, sourceLocale: Lang, @@ -87,9 +101,17 @@ export const localeToDisplayInfo = ( ? totalWords || 0 : dataItem?.words.approved || 0 + const { progress, words } = getProgressInfo( + localeOption, + approvalProgress, + wordsApproved + ) + return { ...returnData, approvalProgress, wordsApproved, + progress, + words, } as LocaleDisplayInfo } diff --git a/src/lib/types.ts b/src/lib/types.ts index f3e29bda929..069fa6a06a8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -294,6 +294,8 @@ export type LocaleDisplayInfo = { englishName: string approvalProgress: number wordsApproved: number + progress: string + words: string isBrowserDefault?: boolean } From 65ae3840e12f388b2385c446f681e14b203ca4f3 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 28 Aug 2025 10:36:29 +0200 Subject: [PATCH 14/33] use collapsible instead of accordion --- package.json | 1 + pnpm-lock.yaml | 60 +++++++++++++++ src/components/Nav/MobileMenu/ExpandIcon.tsx | 4 +- .../Nav/MobileMenu/LvlAccordion.tsx | 75 +++++++++---------- src/components/Nav/MobileMenu/index.tsx | 64 +++++++++------- src/components/ui/collapsible.tsx | 9 +++ 6 files changed, 142 insertions(+), 71 deletions(-) create mode 100644 src/components/ui/collapsible.tsx diff --git a/package.json b/package.json index 062599c01c0..5e8de57827e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-compose-refs": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3d24c82daa..4804aa54230 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.1.1 version: 1.3.2(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': specifier: ^1.1.0 version: 1.1.2(@types/react@18.2.57)(react@18.3.1) @@ -2240,6 +2243,9 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-accordion@1.2.11': resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==} peerDependencies: @@ -2305,6 +2311,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -2493,6 +2512,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -11625,6 +11657,8 @@ snapshots: '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-accordion@1.2.11(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -11696,6 +11730,22 @@ snapshots: '@types/react': 18.2.57 '@types/react-dom': 18.2.19 + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.57)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.57)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.2.57)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.57)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.57)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.57 + '@types/react-dom': 18.2.19 + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.57)(react@18.3.1) @@ -11909,6 +11959,16 @@ snapshots: '@types/react': 18.2.57 '@types/react-dom': 18.2.19 + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.57)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.57)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.57 + '@types/react-dom': 18.2.19 + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.2.19)(@types/react@18.2.57)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@18.2.57)(react@18.3.1) diff --git a/src/components/Nav/MobileMenu/ExpandIcon.tsx b/src/components/Nav/MobileMenu/ExpandIcon.tsx index ab30a6a7818..2303355ac7e 100644 --- a/src/components/Nav/MobileMenu/ExpandIcon.tsx +++ b/src/components/Nav/MobileMenu/ExpandIcon.tsx @@ -2,9 +2,9 @@ import { Minus, Plus } from "lucide-react" const ExpandIcon = () => ( <> - + - + ) diff --git a/src/components/Nav/MobileMenu/LvlAccordion.tsx b/src/components/Nav/MobileMenu/LvlAccordion.tsx index 79fed6d2756..9f888984ebe 100644 --- a/src/components/Nav/MobileMenu/LvlAccordion.tsx +++ b/src/components/Nav/MobileMenu/LvlAccordion.tsx @@ -1,7 +1,8 @@ -import { getLocale } from "next-intl/server" -import * as AccordionPrimitive from "@radix-ui/react-accordion" - -import { Lang } from "@/lib/types" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" import { cn } from "@/lib/utils/cn" // import { trackCustomEvent } from "@/lib/utils/matomo" @@ -12,12 +13,6 @@ import { BaseLink } from "../../ui/Link" import type { Level, NavItem, NavSectionKey } from "../types" import ExpandIcon from "./ExpandIcon" -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "./MenuAccordion" type LvlAccordionProps = { lvl: Level @@ -39,14 +34,16 @@ const backgroundColorPerLevel = { 4: "bg-background-high", } -const LvlAccordion = async (props: LvlAccordionProps) => { - const locale = await getLocale() +const nestedAccordionSpacingMap = { + 2: "ps-8", + 3: "ps-12", + 4: "ps-16", + 5: "ps-20", + 6: "ps-24", +} - return ( - - - - ) +const LvlAccordion = async (props: LvlAccordionProps) => { + return } const LvlAccordionItems = async ({ lvl, @@ -63,22 +60,13 @@ const LvlAccordionItems = async ({ const isLink = "href" in action const isActivePage = isLink && cleanPath(pathname) === action.href - const nestedAccordionSpacingMap = { - 2: "ps-8", - 3: "ps-12", - 4: "ps-16", - 5: "ps-20", - 6: "ps-24", - } - if (isLink) return ( - - +

{/* TODO: replace this with ButtonLink when is implemented */} - - +

+
) return ( - - svg]:rotate-90 [&[data-state=open]_[data-label=icon-container]>svg]:-rotate-90", + "flex h-full justify-start whitespace-normal px-4 py-4 text-start text-body no-underline", + "text-body", + nestedAccordionSpacingMap[lvl] + )} // onClick={() => { // trackCustomEvent({ // eventCategory: "Mobile navigation menu", @@ -159,18 +150,22 @@ const LvlAccordionItems = async ({ {description}

- + - - - + + ) })} diff --git a/src/components/Nav/MobileMenu/index.tsx b/src/components/Nav/MobileMenu/index.tsx index 152bfbe017f..e15e464fb25 100644 --- a/src/components/Nav/MobileMenu/index.tsx +++ b/src/components/Nav/MobileMenu/index.tsx @@ -7,13 +7,12 @@ import { Lang } from "@/lib/types" import MobileLanguagePicker from "@/components/LanguagePicker/Mobile" import ExpandIcon from "@/components/Nav/MobileMenu/ExpandIcon" import LvlAccordion from "@/components/Nav/MobileMenu/LvlAccordion" -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/Nav/MobileMenu/MenuAccordion" import Search from "@/components/Search" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" import { Sheet, SheetContent, @@ -113,30 +112,37 @@ async function NavigationContent() { return ( ) } diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 00000000000..cae892563cd --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleContent, CollapsibleTrigger } From 5aaaa6d61502e64b0130c68428edc6bbc5a0a078 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 28 Aug 2025 10:43:46 +0200 Subject: [PATCH 15/33] active link styles --- .../Nav/MobileMenu/LvlAccordion.tsx | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/components/Nav/MobileMenu/LvlAccordion.tsx b/src/components/Nav/MobileMenu/LvlAccordion.tsx index 9f888984ebe..25827570e48 100644 --- a/src/components/Nav/MobileMenu/LvlAccordion.tsx +++ b/src/components/Nav/MobileMenu/LvlAccordion.tsx @@ -5,9 +5,8 @@ import { } from "@/components/ui/collapsible" import { cn } from "@/lib/utils/cn" -// import { trackCustomEvent } from "@/lib/utils/matomo" -import { cleanPath } from "@/lib/utils/url" +// import { trackCustomEvent } from "@/lib/utils/matomo" import { Button } from "../../ui/buttons/Button" import { BaseLink } from "../../ui/Link" import type { Level, NavItem, NavSectionKey } from "../types" @@ -50,15 +49,10 @@ const LvlAccordionItems = async ({ items, activeSection, }: LvlAccordionProps) => { - // const locale = await getLocale() - // TODO: get pathname from the current page - const pathname = "/" - return ( <> {items.map(({ label, description, ...action }) => { const isLink = "href" in action - const isActivePage = isLink && cleanPath(pathname) === action.href if (isLink) return ( @@ -78,6 +72,9 @@ const LvlAccordionItems = async ({ > { // trackCustomEvent({ // eventCategory: "Mobile navigation menu", @@ -90,9 +87,8 @@ const LvlAccordionItems = async ({

{label} @@ -100,9 +96,8 @@ const LvlAccordionItems = async ({

{description} From 849c00156867b78a02522a9d1e229139d2fc1673 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 28 Aug 2025 10:58:47 +0200 Subject: [PATCH 16/33] track matomo events --- .../Nav/MobileMenu/LvlAccordion.tsx | 35 ++++++-------- .../Nav/MobileMenu/TrackedCollapsible.tsx | 41 +++++++++++++++++ src/components/Nav/MobileMenu/index.tsx | 7 ++- src/components/ui/collapsible.tsx | 46 ++++++++++++++++++- 4 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 src/components/Nav/MobileMenu/TrackedCollapsible.tsx diff --git a/src/components/Nav/MobileMenu/LvlAccordion.tsx b/src/components/Nav/MobileMenu/LvlAccordion.tsx index 25827570e48..8c49cd27361 100644 --- a/src/components/Nav/MobileMenu/LvlAccordion.tsx +++ b/src/components/Nav/MobileMenu/LvlAccordion.tsx @@ -1,12 +1,11 @@ import { - Collapsible, CollapsibleContent, + CollapsibleTracked, CollapsibleTrigger, } from "@/components/ui/collapsible" import { cn } from "@/lib/utils/cn" -// import { trackCustomEvent } from "@/lib/utils/matomo" import { Button } from "../../ui/buttons/Button" import { BaseLink } from "../../ui/Link" import type { Level, NavItem, NavSectionKey } from "../types" @@ -17,6 +16,7 @@ type LvlAccordionProps = { lvl: Level items: NavItem[] activeSection: NavSectionKey + locale: string } const subtextColorPerLevel = { @@ -48,6 +48,7 @@ const LvlAccordionItems = async ({ lvl, items, activeSection, + locale, }: LvlAccordionProps) => { return ( <> @@ -75,13 +76,11 @@ const LvlAccordionItems = async ({ isPartiallyActive={false} activeClassName="is-active" className="group/lnk block" - // onClick={() => { - // trackCustomEvent({ - // eventCategory: "Mobile navigation menu", - // eventAction: `Menu: ${locale} - ${activeSection}`, - // eventName: action.href!, - // }) - // }} + customEventOptions={{ + eventCategory: "Mobile navigation menu", + eventAction: `Menu: ${locale} - ${activeSection}`, + eventName: action.href!, + }} >

{ - // trackCustomEvent({ - // eventCategory: "Mobile navigation menu", - // eventAction: `Level ${lvl - 1} section changed`, - // eventName: `${ - // isExpanded ? "Close" : "Open" - // } section: ${label} - ${description.slice(0, 16)}...`, - // }) - // }} >

@@ -158,9 +152,10 @@ const LvlAccordionItems = async ({ lvl={(lvl + 1) as Level} items={action.items} activeSection={activeSection} + locale={locale} /> - + ) })} diff --git a/src/components/Nav/MobileMenu/TrackedCollapsible.tsx b/src/components/Nav/MobileMenu/TrackedCollapsible.tsx new file mode 100644 index 00000000000..a3f4ed09120 --- /dev/null +++ b/src/components/Nav/MobileMenu/TrackedCollapsible.tsx @@ -0,0 +1,41 @@ +"use client" + +import { PropsWithChildren, useCallback } from "react" + +import { Collapsible } from "@/components/ui/collapsible" + +import { trackCustomEvent } from "@/lib/utils/matomo" + +type TrackedCollapsibleProps = PropsWithChildren<{ + className?: string + eventCategory: string + eventAction: string + openEventName: string + closeEventName: string +}> + +export default function TrackedCollapsible({ + className, + children, + eventCategory, + eventAction, + openEventName, + closeEventName, +}: TrackedCollapsibleProps) { + const handleOpenChange = useCallback( + (open: boolean) => { + trackCustomEvent({ + eventCategory, + eventAction, + eventName: open ? openEventName : closeEventName, + }) + }, + [eventCategory, eventAction, openEventName, closeEventName] + ) + + return ( + + {children} + + ) +} diff --git a/src/components/Nav/MobileMenu/index.tsx b/src/components/Nav/MobileMenu/index.tsx index e15e464fb25..f14cc16b910 100644 --- a/src/components/Nav/MobileMenu/index.tsx +++ b/src/components/Nav/MobileMenu/index.tsx @@ -138,7 +138,12 @@ async function NavigationContent() { "mt-0 bg-background-low p-0" )} > - + ) diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx index cae892563cd..ee7163c1414 100644 --- a/src/components/ui/collapsible.tsx +++ b/src/components/ui/collapsible.tsx @@ -1,9 +1,53 @@ +"use client" + +import { PropsWithChildren, useCallback } from "react" import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" +import { trackCustomEvent } from "@/lib/utils/matomo" + const Collapsible = CollapsiblePrimitive.Root const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent -export { Collapsible, CollapsibleContent, CollapsibleTrigger } +type CollapsibleTrackedProps = PropsWithChildren<{ + className?: string + eventCategory: string + eventAction: string + openEventName: string + closeEventName: string +}> + +const CollapsibleTracked = ({ + className, + children, + eventCategory, + eventAction, + openEventName, + closeEventName, +}: CollapsibleTrackedProps) => { + const handleOpenChange = useCallback( + (open: boolean) => { + trackCustomEvent({ + eventCategory, + eventAction, + eventName: open ? openEventName : closeEventName, + }) + }, + [eventCategory, eventAction, openEventName, closeEventName] + ) + + return ( + + {children} + + ) +} + +export { + Collapsible, + CollapsibleContent, + CollapsibleTracked, + CollapsibleTrigger, +} From 56e96bd75fe5bc0849c66c7545b4100d4c6414fb Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 28 Aug 2025 14:12:44 +0200 Subject: [PATCH 17/33] nav: lazy render & loading skeletons --- src/components/ClientOnly.tsx | 19 +++++++++++++++++++ src/components/Nav/index.tsx | 19 ++++++++++--------- src/components/Nav/loading.tsx | 29 +++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 src/components/ClientOnly.tsx create mode 100644 src/components/Nav/loading.tsx diff --git a/src/components/ClientOnly.tsx b/src/components/ClientOnly.tsx new file mode 100644 index 00000000000..f894f08a2d0 --- /dev/null +++ b/src/components/ClientOnly.tsx @@ -0,0 +1,19 @@ +"use client" + +import { type ReactNode } from "react" + +import { useIsClient } from "@/hooks/useIsClient" + +type ClientOnlyProps = { + children: ReactNode + fallback?: ReactNode +} + +const ClientOnly = ({ children, fallback = null }: ClientOnlyProps) => { + const isClient = useIsClient() + + if (!isClient) return <>{fallback} + return <>{children} +} + +export default ClientOnly diff --git a/src/components/Nav/index.tsx b/src/components/Nav/index.tsx index eaade75b909..a45fcd8a4f5 100644 --- a/src/components/Nav/index.tsx +++ b/src/components/Nav/index.tsx @@ -1,16 +1,13 @@ -import dynamic from "next/dynamic" import { getLocale, getTranslations } from "next-intl/server" import { EthHomeIcon } from "@/components/icons" +import ClientOnly from "../ClientOnly" import { BaseLink } from "../ui/Link" -const DesktopNav = dynamic(() => import("./DesktopNav"), { - ssr: false, -}) -const MobileNav = dynamic(() => import("./MobileNav"), { - ssr: false, -}) +import DesktopNav from "./DesktopNav" +import { DesktopNavLoading, MobileNavLoading } from "./loading" +import MobileNav from "./MobileNav" const Nav = async () => { const locale = await getLocale() @@ -31,8 +28,12 @@ const Nav = async () => {
- - + }> + + + }> + +
) diff --git a/src/components/Nav/loading.tsx b/src/components/Nav/loading.tsx new file mode 100644 index 00000000000..88f5c8a11e0 --- /dev/null +++ b/src/components/Nav/loading.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export const DesktopNavLoading = () => { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) +} + +export const MobileNavLoading = () => { + return ( + <> +
+ + +
+
+ + +
+ + ) +} From 2049df862bc3717324b08d03c995a0dc14e8269c Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 28 Aug 2025 14:17:12 +0200 Subject: [PATCH 18/33] hide sheet overlay only for the mobile menu --- src/components/Nav/MobileMenu/index.tsx | 2 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/sheet.tsx | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/Nav/MobileMenu/index.tsx b/src/components/Nav/MobileMenu/index.tsx index f14cc16b910..953b6eac62e 100644 --- a/src/components/Nav/MobileMenu/index.tsx +++ b/src/components/Nav/MobileMenu/index.tsx @@ -54,8 +54,8 @@ export default async function MobileMenu({ /> diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 03f4316e1da..8eea2cf29a0 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -32,7 +32,7 @@ const DialogContent = React.forwardRef< React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - {/* - Disabled for performance reasons. See https://github.com/radix-ui/primitives/issues/1634 for details on floating element performance issues */} + , - VariantProps {} + VariantProps { + hideOverlay?: boolean +} const SheetContent = React.forwardRef< React.ElementRef, SheetContentProps ->(({ side = "right", className, ...props }, ref) => ( +>(({ side = "right", hideOverlay = false, className, ...props }, ref) => ( - {/* - Disabled for performance reasons. See https://github.com/radix-ui/primitives/issues/1634 for details on floating element performance issues */} + {!hideOverlay && } Date: Thu, 28 Aug 2025 14:40:31 +0200 Subject: [PATCH 19/33] implement SheetDismiss to close menu when a link is clicked --- .../Nav/MobileMenu/LvlAccordion.tsx | 83 ++++++++++--------- src/components/ui/sheet.tsx | 7 ++ 2 files changed, 50 insertions(+), 40 deletions(-) diff --git a/src/components/Nav/MobileMenu/LvlAccordion.tsx b/src/components/Nav/MobileMenu/LvlAccordion.tsx index 8c49cd27361..be6f22e4d79 100644 --- a/src/components/Nav/MobileMenu/LvlAccordion.tsx +++ b/src/components/Nav/MobileMenu/LvlAccordion.tsx @@ -3,6 +3,7 @@ import { CollapsibleTracked, CollapsibleTrigger, } from "@/components/ui/collapsible" +import { SheetDismiss } from "@/components/ui/sheet" import { cn } from "@/lib/utils/cn" @@ -63,47 +64,49 @@ const LvlAccordionItems = async ({ >

{/* TODO: replace this with ButtonLink when is implemented */} - + +

+

+ {label} +

+

+ {description} +

+
+ + +

) diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 91124acd01b..cbb96074c71 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -22,6 +22,12 @@ const SheetClose = React.forwardRef< )) SheetClose.displayName = SheetPrimitive.Close.displayName +const SheetDismiss = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => ) +SheetDismiss.displayName = SheetPrimitive.Close.displayName + const SheetOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -137,6 +143,7 @@ export { SheetClose, SheetContent, SheetDescription, + SheetDismiss, SheetFooter, SheetHeader, SheetOverlay, From 19f73bd6d431546ff789b54635c427287a0fb057 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 28 Aug 2025 22:05:07 +0200 Subject: [PATCH 20/33] cleanup duplicated code --- src/components/LanguagePicker/Desktop.tsx | 10 ++--- .../LanguagePicker/LanguagePickerBody.tsx | 37 +++++++++++++++++ src/components/LanguagePicker/Mobile.tsx | 40 ++++++++++--------- 3 files changed, 61 insertions(+), 26 deletions(-) create mode 100644 src/components/LanguagePicker/LanguagePickerBody.tsx diff --git a/src/components/LanguagePicker/Desktop.tsx b/src/components/LanguagePicker/Desktop.tsx index cf249a4a445..d83cf542f3e 100644 --- a/src/components/LanguagePicker/Desktop.tsx +++ b/src/components/LanguagePicker/Desktop.tsx @@ -8,8 +8,7 @@ import { cn } from "@/lib/utils/cn" import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" -import LanguagePickerFooter from "./LanguagePickerFooter" -import LanguagePickerMenu from "./LanguagePickerMenu" +import LanguagePickerBody from "./LanguagePickerBody" import { useLanguagePicker } from "./useLanguagePicker" import { useEventListener } from "@/hooks/useEventListener" @@ -80,18 +79,15 @@ const DesktopLanguagePicker = ({ className )} > - + onNoResultsClose={() => onClose({ eventAction: "Translation program link (no results)", eventName: "/contributing/translation-program", }) } - /> - - diff --git a/src/components/LanguagePicker/LanguagePickerBody.tsx b/src/components/LanguagePicker/LanguagePickerBody.tsx new file mode 100644 index 00000000000..aa85ce38fd3 --- /dev/null +++ b/src/components/LanguagePicker/LanguagePickerBody.tsx @@ -0,0 +1,37 @@ +import type { LocaleDisplayInfo } from "@/lib/types" + +import LanguagePickerFooter from "./LanguagePickerFooter" +import LanguagePickerMenu from "./LanguagePickerMenu" + +type LanguagePickerBodyProps = { + languages: LocaleDisplayInfo[] + onSelect: (value: string) => void + onNoResultsClose: () => void + intlLanguagePreference?: LocaleDisplayInfo + onTranslationProgramClick: () => void +} + +const LanguagePickerBody = ({ + languages, + onSelect, + onNoResultsClose, + intlLanguagePreference, + onTranslationProgramClick, +}: LanguagePickerBodyProps) => { + return ( + <> + + + + + ) +} + +export default LanguagePickerBody diff --git a/src/components/LanguagePicker/Mobile.tsx b/src/components/LanguagePicker/Mobile.tsx index a477bd4c86b..24d53b5c778 100644 --- a/src/components/LanguagePicker/Mobile.tsx +++ b/src/components/LanguagePicker/Mobile.tsx @@ -4,8 +4,7 @@ import { useParams } from "next/navigation" import type { LocaleDisplayInfo } from "@/lib/types" -import LanguagePickerFooter from "./LanguagePickerFooter" -import LanguagePickerMenu from "./LanguagePickerMenu" +import LanguagePickerBody from "./LanguagePickerBody" import { useLanguagePicker } from "./useLanguagePicker" import { usePathname, useRouter } from "@/i18n/routing" @@ -18,8 +17,12 @@ const MobileLanguagePicker = ({ languages }: MobileLanguagePickerProps) => { const pathname = usePathname() const { push } = useRouter() const params = useParams() - const { languages: sortedLanguages, intlLanguagePreference } = - useLanguagePicker(languages) + const { + disclosure, + languages: sortedLanguages, + intlLanguagePreference, + } = useLanguagePicker(languages) + const { onClose } = disclosure const handleMenuItemSelect = (currentValue: string) => { push( @@ -33,30 +36,29 @@ const MobileLanguagePicker = ({ languages }: MobileLanguagePickerProps) => { ) } - const handleNoResultsClose = () => { - // Navigate to translation program or handle as needed - } + const handleNoResultsClose = () => + onClose({ + eventAction: "Translation program link (no results)", + eventName: "/contributing/translation-program", + }) - const handleTranslationProgramClick = () => { - // Navigate to translation program - } + const handleTranslationProgramClick = () => + onClose({ + eventAction: "Translation program link (menu footer)", + eventName: "/contributing/translation-program", + }) return (
- {/* Language picker menu */}
-
- - {/* Footer */} -
) } From f0638e51e29c67f9ac4a77f1b7020831fcf63d22 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 28 Aug 2025 22:28:43 +0200 Subject: [PATCH 21/33] create a new sheet component to close on navigation --- .../Nav/MobileMenu/LvlAccordion.tsx | 84 +++++++++---------- src/components/Nav/MobileMenu/index.tsx | 6 +- src/components/ui/sheet-close-on-navigate.tsx | 30 +++++++ 3 files changed, 73 insertions(+), 47 deletions(-) create mode 100644 src/components/ui/sheet-close-on-navigate.tsx diff --git a/src/components/Nav/MobileMenu/LvlAccordion.tsx b/src/components/Nav/MobileMenu/LvlAccordion.tsx index f445da2c167..3414bdeb466 100644 --- a/src/components/Nav/MobileMenu/LvlAccordion.tsx +++ b/src/components/Nav/MobileMenu/LvlAccordion.tsx @@ -3,7 +3,6 @@ import { CollapsibleTracked, CollapsibleTrigger, } from "@/components/ui/collapsible" -import { SheetDismiss } from "@/components/ui/sheet" import { cn } from "@/lib/utils/cn" import { slugify } from "@/lib/utils/url" @@ -64,50 +63,47 @@ const LvlAccordionItems = async ({ className="border-t border-body-light last:border-b" >

- {/* TODO: replace this with ButtonLink when is implemented */} - - - +

+

+ {label} +

+

+ {description} +

+
+ +

) diff --git a/src/components/Nav/MobileMenu/index.tsx b/src/components/Nav/MobileMenu/index.tsx index a764428550b..769ffe50f43 100644 --- a/src/components/Nav/MobileMenu/index.tsx +++ b/src/components/Nav/MobileMenu/index.tsx @@ -14,12 +14,12 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible" import { - Sheet, SheetContent, SheetFooter, SheetHeader, SheetTrigger, } from "@/components/ui/sheet" +import { SheetCloseOnNavigate } from "@/components/ui/sheet-close-on-navigate" import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs" import { cn } from "@/lib/utils/cn" @@ -46,7 +46,7 @@ export default async function MobileMenu({ const t = await getTranslations({ namespace: "common" }) return ( - + - + ) } diff --git a/src/components/ui/sheet-close-on-navigate.tsx b/src/components/ui/sheet-close-on-navigate.tsx new file mode 100644 index 00000000000..060c78abdd3 --- /dev/null +++ b/src/components/ui/sheet-close-on-navigate.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import { useState } from "react" +import { usePathname } from "next/navigation" + +import { Sheet as BaseSheet } from "./sheet" + +type BaseSheetProps = React.ComponentProps + +const SheetCloseOnNavigate: React.FC = ({ + children, + ...props +}) => { + const pathname = usePathname() + const [open, setOpen] = useState(false) + + React.useEffect(() => { + if (open) setOpen(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]) + + return ( + + {children} + + ) +} + +export { SheetCloseOnNavigate } From fa9099e7ac02789b03adf57923999441ae3c2022 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 29 Aug 2025 10:21:48 +0200 Subject: [PATCH 22/33] refactor lang picker to share code between desktop and mobile versions --- src/components/LanguagePicker/Desktop.tsx | 58 ++------- .../LanguagePicker/LanguagePickerBody.tsx | 37 ------ .../LanguagePicker/LanguagePickerFooter.tsx | 2 +- .../LanguagePicker/LanguagePickerMenu.tsx | 4 +- src/components/LanguagePicker/Mobile.tsx | 68 ----------- src/components/LanguagePicker/index.tsx | 111 +++++++++++++++--- .../LanguagePicker/useLanguagePicker.tsx | 41 +------ src/components/Nav/DesktopNav.tsx | 9 +- src/components/Nav/MobileMenu/index.tsx | 40 +++---- 9 files changed, 135 insertions(+), 235 deletions(-) delete mode 100644 src/components/LanguagePicker/LanguagePickerBody.tsx delete mode 100644 src/components/LanguagePicker/Mobile.tsx diff --git a/src/components/LanguagePicker/Desktop.tsx b/src/components/LanguagePicker/Desktop.tsx index d83cf542f3e..1bd783d2499 100644 --- a/src/components/LanguagePicker/Desktop.tsx +++ b/src/components/LanguagePicker/Desktop.tsx @@ -1,41 +1,28 @@ "use client" -import { useParams } from "next/navigation" - import type { LocaleDisplayInfo } from "@/lib/types" import { cn } from "@/lib/utils/cn" import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" -import LanguagePickerBody from "./LanguagePickerBody" -import { useLanguagePicker } from "./useLanguagePicker" +import LanguagePicker from "." +import { useDisclosure } from "@/hooks/useDisclosure" import { useEventListener } from "@/hooks/useEventListener" -import { usePathname, useRouter } from "@/i18n/routing" type DesktopLanguagePickerProps = { children: React.ReactNode languages: LocaleDisplayInfo[] className?: string - handleClose?: () => void } const DesktopLanguagePicker = ({ children, languages, - handleClose, className, }: DesktopLanguagePickerProps) => { - const pathname = usePathname() - const { push } = useRouter() - const params = useParams() - const { - disclosure, - languages: sortedLanguages, - intlLanguagePreference, - } = useLanguagePicker(languages, handleClose) - const { isOpen, setValue, onClose, onOpen } = disclosure + const { isOpen, setValue, onClose, onOpen } = useDisclosure() /** * Adds a keydown event listener to focus filter input (\). @@ -47,28 +34,6 @@ const DesktopLanguagePicker = ({ onOpen() }) - // onClick handlers - const handleMenuItemSelect = (currentValue: string) => { - push( - // @ts-expect-error -- TypeScript will validate that only known `params` - // are used in combination with a given `pathname`. Since the two will - // always match for the current route, we can skip runtime checks. - { pathname, params }, - { - locale: currentValue, - } - ) - onClose({ - eventAction: "Locale chosen", - eventName: currentValue, - }) - } - const handleBaseLinkClose = () => - onClose({ - eventAction: "Translation program link (menu footer)", - eventName: "/contributing/translation-program", - }) - return ( {children} @@ -79,17 +44,12 @@ const DesktopLanguagePicker = ({ className )} > - - onClose({ - eventAction: "Translation program link (no results)", - eventName: "/contributing/translation-program", - }) - } - intlLanguagePreference={intlLanguagePreference} - onTranslationProgramClick={handleBaseLinkClose} + diff --git a/src/components/LanguagePicker/LanguagePickerBody.tsx b/src/components/LanguagePicker/LanguagePickerBody.tsx deleted file mode 100644 index aa85ce38fd3..00000000000 --- a/src/components/LanguagePicker/LanguagePickerBody.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { LocaleDisplayInfo } from "@/lib/types" - -import LanguagePickerFooter from "./LanguagePickerFooter" -import LanguagePickerMenu from "./LanguagePickerMenu" - -type LanguagePickerBodyProps = { - languages: LocaleDisplayInfo[] - onSelect: (value: string) => void - onNoResultsClose: () => void - intlLanguagePreference?: LocaleDisplayInfo - onTranslationProgramClick: () => void -} - -const LanguagePickerBody = ({ - languages, - onSelect, - onNoResultsClose, - intlLanguagePreference, - onTranslationProgramClick, -}: LanguagePickerBodyProps) => { - return ( - <> - - - - - ) -} - -export default LanguagePickerBody diff --git a/src/components/LanguagePicker/LanguagePickerFooter.tsx b/src/components/LanguagePicker/LanguagePickerFooter.tsx index f5422332f63..0e707a1f981 100644 --- a/src/components/LanguagePicker/LanguagePickerFooter.tsx +++ b/src/components/LanguagePicker/LanguagePickerFooter.tsx @@ -21,7 +21,7 @@ const LanguagePickerFooter = ({ const locale = useLocale() return (
-
+
{locale === DEFAULT_LOCALE ? (

diff --git a/src/components/LanguagePicker/LanguagePickerMenu.tsx b/src/components/LanguagePicker/LanguagePickerMenu.tsx index cb2fe9b3843..62f2de291ca 100644 --- a/src/components/LanguagePicker/LanguagePickerMenu.tsx +++ b/src/components/LanguagePicker/LanguagePickerMenu.tsx @@ -14,12 +14,14 @@ import NoResultsCallout from "./NoResultsCallout" import { useTranslation } from "@/hooks/useTranslation" type LanguagePickerMenuProps = { + className?: string languages: LocaleDisplayInfo[] onClose: () => void onSelect: (value: string) => void } const LanguagePickerMenu = ({ + className, languages, onClose, onSelect, @@ -28,7 +30,7 @@ const LanguagePickerMenu = ({ return ( { const item = languages.find((name) => name.localeOption === value) diff --git a/src/components/LanguagePicker/Mobile.tsx b/src/components/LanguagePicker/Mobile.tsx deleted file mode 100644 index 24d53b5c778..00000000000 --- a/src/components/LanguagePicker/Mobile.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client" - -import { useParams } from "next/navigation" - -import type { LocaleDisplayInfo } from "@/lib/types" - -import LanguagePickerBody from "./LanguagePickerBody" -import { useLanguagePicker } from "./useLanguagePicker" - -import { usePathname, useRouter } from "@/i18n/routing" - -type MobileLanguagePickerProps = { - languages: LocaleDisplayInfo[] -} - -const MobileLanguagePicker = ({ languages }: MobileLanguagePickerProps) => { - const pathname = usePathname() - const { push } = useRouter() - const params = useParams() - const { - disclosure, - languages: sortedLanguages, - intlLanguagePreference, - } = useLanguagePicker(languages) - const { onClose } = disclosure - - const handleMenuItemSelect = (currentValue: string) => { - push( - // @ts-expect-error -- TypeScript will validate that only known `params` - // are used in combination with a given `pathname`. Since the two will - // always match for the current route, we can skip runtime checks. - { pathname, params }, - { - locale: currentValue, - } - ) - } - - const handleNoResultsClose = () => - onClose({ - eventAction: "Translation program link (no results)", - eventName: "/contributing/translation-program", - }) - - const handleTranslationProgramClick = () => - onClose({ - eventAction: "Translation program link (menu footer)", - eventName: "/contributing/translation-program", - }) - - return ( -

-
- -
-
- ) -} - -MobileLanguagePicker.displayName = "MobileLanguagePicker" - -export default MobileLanguagePicker diff --git a/src/components/LanguagePicker/index.tsx b/src/components/LanguagePicker/index.tsx index 22837778087..d9ec60c433f 100644 --- a/src/components/LanguagePicker/index.tsx +++ b/src/components/LanguagePicker/index.tsx @@ -1,29 +1,108 @@ -import DesktopLanguagePicker from "./Desktop" +"use client" -import { getLanguagesDisplayInfo } from "@/lib/nav/links" +import { useEffect } from "react" +import { useParams, usePathname, useRouter } from "next/navigation" + +import type { LocaleDisplayInfo } from "@/lib/types" + +import { cn } from "@/lib/utils/cn" +import { trackCustomEvent } from "@/lib/utils/matomo" + +import LanguagePickerFooter from "./LanguagePickerFooter" +import LanguagePickerMenu from "./LanguagePickerMenu" +import { useLanguagePicker } from "./useLanguagePicker" type LanguagePickerProps = { - children: React.ReactNode className?: string - handleClose?: () => void - dialog?: boolean + languages: LocaleDisplayInfo[] + onSelect?: (value: string) => void + onNoResultsClose?: () => void + onTranslationProgramClick?: () => void } -const LanguagePicker = async ({ - children, - handleClose, +const LanguagePicker = ({ + languages, className, + onSelect, + onNoResultsClose, + onTranslationProgramClick, }: LanguagePickerProps) => { - const languages = await getLanguagesDisplayInfo() + const pathname = usePathname() + const { push } = useRouter() + const params = useParams() + const { languages: sortedLanguages, intlLanguagePreference } = + useLanguagePicker(languages) + + useEffect(() => { + trackCustomEvent({ + eventCategory: `Language picker`, + eventAction: "Open or close language picker", + eventName: "Opened", + }) + + return () => { + trackCustomEvent({ + eventCategory: `Language picker`, + eventAction: "Open or close language picker", + eventName: "Closed", + }) + } + }, []) + + const handleMenuItemSelect = (currentValue: string) => { + onSelect?.(currentValue) + + push( + // @ts-expect-error -- TypeScript will validate that only known `params` + // are used in combination with a given `pathname`. Since the two will + // always match for the current route, we can skip runtime checks. + { pathname, params }, + { + locale: currentValue, + } + ) + + trackCustomEvent({ + eventCategory: `Language picker`, + eventAction: "Locale chosen", + eventName: currentValue, + }) + } + + const handleNoResultsClose = () => { + onNoResultsClose?.() + + trackCustomEvent({ + eventCategory: `Language picker`, + eventAction: "Translation program link (no results)", + eventName: "/contributing/translation-program", + }) + } + + const handleTranslationProgramClick = () => { + onTranslationProgramClick?.() + + trackCustomEvent({ + eventCategory: `Language picker`, + eventAction: "Translation program link (menu footer)", + eventName: "/contributing/translation-program", + }) + } return ( - - {children} - +
+ + + +
) } diff --git a/src/components/LanguagePicker/useLanguagePicker.tsx b/src/components/LanguagePicker/useLanguagePicker.tsx index 73666118a5f..35f584d135c 100644 --- a/src/components/LanguagePicker/useLanguagePicker.tsx +++ b/src/components/LanguagePicker/useLanguagePicker.tsx @@ -3,20 +3,14 @@ import { useLocale } from "next-intl" import type { Lang, LocaleDisplayInfo } from "@/lib/types" -import { MatomoEventOptions, trackCustomEvent } from "@/lib/utils/matomo" import { filterRealLocales } from "@/lib/utils/translations" import { LOCALES_CODES } from "@/lib/constants" -import { useDisclosure } from "@/hooks/useDisclosure" - // Move locales computation outside component to make it stable const FILTERED_LOCALES = filterRealLocales(LOCALES_CODES) -export const useLanguagePicker = ( - languages: LocaleDisplayInfo[], - handleClose?: () => void -) => { +export const useLanguagePicker = (languages: LocaleDisplayInfo[]) => { const locale = useLocale() // Find all matching browser language preferences in order @@ -83,40 +77,7 @@ export const useLanguagePicker = ( (lang) => lang.localeOption === intlLocalePreference ) - const { isOpen, setValue, ...menu } = useDisclosure() - - const eventBase: Pick = { - eventCategory: `Language picker`, - eventAction: "Open or close language picker", - } - - const onOpen = () => { - menu.onOpen() - trackCustomEvent({ - ...eventBase, - eventName: "Opened", - } as MatomoEventOptions) - } - - /** - * When closing the menu, track whether this is following a link, or simply closing the menu - * @param customMatomoEvent Optional custom event property overrides - */ - const onClose = ( - customMatomoEvent?: Required> & - Partial - ): void => { - handleClose && handleClose() - menu.onClose() - trackCustomEvent( - (customMatomoEvent - ? { ...eventBase, ...customMatomoEvent } - : { ...eventBase, eventName: "Closed" }) satisfies MatomoEventOptions - ) - } - return { - disclosure: { isOpen, setValue, onOpen, onClose }, languages: sortedLanguages, intlLanguagePreference, } diff --git a/src/components/Nav/DesktopNav.tsx b/src/components/Nav/DesktopNav.tsx index 4adc6658677..c3d279c971a 100644 --- a/src/components/Nav/DesktopNav.tsx +++ b/src/components/Nav/DesktopNav.tsx @@ -5,15 +5,18 @@ import { cn } from "@/lib/utils/cn" import { DESKTOP_LANGUAGE_BUTTON_NAME } from "@/lib/constants" -import LanguagePicker from "../LanguagePicker" +import DesktopLanguagePicker from "../LanguagePicker/Desktop" import Search from "../Search" import { Button } from "../ui/buttons/Button" import Menu from "./Menu" import { ThemeToggleButton } from "./ThemeToggleButton" +import { getLanguagesDisplayInfo } from "@/lib/nav/links" + const DesktopNav = async ({ className }: { className?: string }) => { const t = await getTranslations({ namespace: "common" }) + const languages = await getLanguagesDisplayInfo() const locale = await getLocale() @@ -28,7 +31,7 @@ const DesktopNav = async ({ className }: { className?: string }) => { - + - +
) diff --git a/src/components/Nav/MobileMenu/index.tsx b/src/components/Nav/MobileMenu/index.tsx index 769ffe50f43..d4329f83780 100644 --- a/src/components/Nav/MobileMenu/index.tsx +++ b/src/components/Nav/MobileMenu/index.tsx @@ -4,7 +4,7 @@ import { Trigger as TabsTrigger } from "@radix-ui/react-tabs" import { Lang } from "@/lib/types" -import MobileLanguagePicker from "@/components/LanguagePicker/Mobile" +import LanguagePicker from "@/components/LanguagePicker" import ExpandIcon from "@/components/Nav/MobileMenu/ExpandIcon" import LvlAccordion from "@/components/Nav/MobileMenu/LvlAccordion" import Search from "@/components/Search" @@ -66,18 +66,22 @@ export default async function MobileMenu({ -
- - - - - - -
- - + + + + + + + +
@@ -111,12 +115,12 @@ export default async function MobileMenu({ ) } -async function NavigationContent() { +async function NavigationContent({ className }: { className?: string }) { const locale = await getLocale() const linkSections = await getNavigation(locale as Lang) return ( -
) From 0b94574f55273fe0a56b4ffaa423150d9fc5acea Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 29 Aug 2025 21:43:35 +0200 Subject: [PATCH 27/33] fix rtl support in mobile menu tab content --- src/components/Nav/MobileMenu/index.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Nav/MobileMenu/index.tsx b/src/components/Nav/MobileMenu/index.tsx index afea5dc7150..88c5d6b35cd 100644 --- a/src/components/Nav/MobileMenu/index.tsx +++ b/src/components/Nav/MobileMenu/index.tsx @@ -2,6 +2,8 @@ import { Languages, SearchIcon } from "lucide-react" import { getLocale, getTranslations } from "next-intl/server" import { Trigger as TabsTrigger } from "@radix-ui/react-tabs" +import type { Lang } from "@/lib/types" + import LanguagePicker from "@/components/LanguagePicker" import ExpandIcon from "@/components/Nav/MobileMenu/ExpandIcon" import LvlAccordion from "@/components/Nav/MobileMenu/LvlAccordion" @@ -21,6 +23,7 @@ import { SheetCloseOnNavigate } from "@/components/ui/sheet-close-on-navigate" import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs" import { cn } from "@/lib/utils/cn" +import { isLangRightToLeft } from "@/lib/utils/translations" import { slugify } from "@/lib/utils/url" import { MOBILE_LANGUAGE_BUTTON_NAME, SECTION_LABELS } from "@/lib/constants" @@ -42,6 +45,10 @@ export default async function MobileMenu({ ...props }: MobileMenuProps) { const t = await getTranslations({ namespace: "common" }) + const locale = await getLocale() + const isRtl = isLangRightToLeft(locale as Lang) + const side = isRtl ? "right" : "left" + const dir = isRtl ? "rtl" : "ltr" return ( @@ -53,7 +60,7 @@ export default async function MobileMenu({ /> From ba34098f8e2142f55af67c1923025ed079643f57 Mon Sep 17 00:00:00 2001 From: Pablo Date: Sat, 30 Aug 2025 17:37:53 +0200 Subject: [PATCH 28/33] fix hydration issue with media queries --- src/components/MediaQuery.tsx | 41 +++++++++++++++++++++++++++++++++++ src/components/Nav/index.tsx | 11 ++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 src/components/MediaQuery.tsx diff --git a/src/components/MediaQuery.tsx b/src/components/MediaQuery.tsx new file mode 100644 index 00000000000..ad31f265323 --- /dev/null +++ b/src/components/MediaQuery.tsx @@ -0,0 +1,41 @@ +"use client" + +import { type ReactNode } from "react" + +import { useMediaQuery } from "@/hooks/useMediaQuery" + +type MediaQueryChildren = + | ((args: { matches: boolean[]; any: boolean }) => ReactNode) + | ReactNode + +type MediaQueryProps = { + /** + * Array of CSS media query strings, e.g. ["(min-width: 768px)"] + */ + queries: string[] + /** + * Optional SSR fallbacks for each query. If omitted, defaults to false. + */ + fallbackMatches?: boolean[] + /** + * Either render props or slot children + */ + children: MediaQueryChildren +} + +const MediaQuery = ({ + queries, + fallbackMatches, + children, +}: MediaQueryProps) => { + const matches = useMediaQuery(queries, fallbackMatches) + const any = matches.some(Boolean) + + if (typeof children === "function") { + return <>{children({ matches, any })} + } + + return <>{any ? children : null} +} + +export default MediaQuery diff --git a/src/components/Nav/index.tsx b/src/components/Nav/index.tsx index 41919604842..011110fc494 100644 --- a/src/components/Nav/index.tsx +++ b/src/components/Nav/index.tsx @@ -2,7 +2,10 @@ import { getTranslations } from "next-intl/server" import { EthHomeIcon } from "@/components/icons" +import { breakpointAsNumber } from "@/lib/utils/screen" + import ClientOnly from "../ClientOnly" +import MediaQuery from "../MediaQuery" import { BaseLink } from "../ui/Link" import DesktopNav from "./DesktopNav" @@ -28,10 +31,14 @@ const Nav = async () => {
}> - + + + }> - + + +
From 75d3c7e2a6040c735e8a914d937b4a13634cd892 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 1 Sep 2025 10:47:06 +0200 Subject: [PATCH 29/33] update openLanguagePickerMobile function with correct test id --- tests/e2e/pages/BasePage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/pages/BasePage.ts b/tests/e2e/pages/BasePage.ts index 82e953b0843..41b5f392a80 100644 --- a/tests/e2e/pages/BasePage.ts +++ b/tests/e2e/pages/BasePage.ts @@ -118,7 +118,7 @@ export class BasePage { */ async openLanguagePickerMobile(): Promise { await this.mobileMenuButton.click() - await this.mobileSidebar.getByRole("button", { name: /languages/i }).click() + await this.mobileSidebar.getByTestId("mobile-menu-language-picker").click() } /** From afec995ed39e0439d74ff53e3a353f0ea99b63f5 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 1 Sep 2025 20:28:17 +0200 Subject: [PATCH 30/33] move localeToDisplayInfo function to lib --- src/lib/nav/links.ts | 2 +- .../LanguagePicker => lib/nav}/localeToDisplayInfo.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{components/LanguagePicker => lib/nav}/localeToDisplayInfo.ts (100%) diff --git a/src/lib/nav/links.ts b/src/lib/nav/links.ts index af12693f83f..b64468d8fc2 100644 --- a/src/lib/nav/links.ts +++ b/src/lib/nav/links.ts @@ -2,7 +2,6 @@ import { getLocale, getTranslations } from "next-intl/server" import type { Lang, LocaleDisplayInfo } from "@/lib/types" -import { localeToDisplayInfo } from "@/components/LanguagePicker/localeToDisplayInfo" import type { NavSections } from "@/components/Nav/types" import { filterRealLocales } from "@/lib/utils/translations" @@ -10,6 +9,7 @@ import { filterRealLocales } from "@/lib/utils/translations" import { LOCALES_CODES } from "@/lib/constants" import { buildNavigation } from "@/lib/nav/buildNavigation" +import { localeToDisplayInfo } from "@/lib/nav/localeToDisplayInfo" // Pre-filtered locales for server use const FILTERED_LOCALES = filterRealLocales(LOCALES_CODES) diff --git a/src/components/LanguagePicker/localeToDisplayInfo.ts b/src/lib/nav/localeToDisplayInfo.ts similarity index 100% rename from src/components/LanguagePicker/localeToDisplayInfo.ts rename to src/lib/nav/localeToDisplayInfo.ts From 7fd2a85847f9b36577a030855c772bb6947a6421 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 1 Sep 2025 20:28:42 +0200 Subject: [PATCH 31/33] cleanup redundant code component --- src/components/LanguagePicker/index.tsx | 1 - src/components/Nav/MobileMenu/LvlAccordion.tsx | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/LanguagePicker/index.tsx b/src/components/LanguagePicker/index.tsx index 1b9584dbc85..f8f48529843 100644 --- a/src/components/LanguagePicker/index.tsx +++ b/src/components/LanguagePicker/index.tsx @@ -52,7 +52,6 @@ const LanguagePicker = ({ }, []) const handleMenuItemSelect = (currentValue: string) => { - console.log("handleMenuItemSelect", currentValue) onSelect?.(currentValue) push( diff --git a/src/components/Nav/MobileMenu/LvlAccordion.tsx b/src/components/Nav/MobileMenu/LvlAccordion.tsx index c6deb3fed0e..c26d7621d45 100644 --- a/src/components/Nav/MobileMenu/LvlAccordion.tsx +++ b/src/components/Nav/MobileMenu/LvlAccordion.tsx @@ -42,10 +42,7 @@ const nestedAccordionSpacingMap = { 6: "ps-24", } -const LvlAccordion = async (props: LvlAccordionProps) => { - return -} -const LvlAccordionItems = async ({ +const LvlAccordion = async ({ lvl, items, activeSection, From f8f3f5e36dce772a3983b918e2510137ad675e01 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 1 Sep 2025 20:51:00 +0200 Subject: [PATCH 32/33] reorg menu footer buttons and replace the serach with menu button --- src/components/Nav/MobileMenu/index.tsx | 23 +++++++++++------------ src/intl/en/common.json | 1 + 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/Nav/MobileMenu/index.tsx b/src/components/Nav/MobileMenu/index.tsx index 88c5d6b35cd..89e52a2d279 100644 --- a/src/components/Nav/MobileMenu/index.tsx +++ b/src/components/Nav/MobileMenu/index.tsx @@ -1,4 +1,4 @@ -import { Languages, SearchIcon } from "lucide-react" +import { Languages, Menu } from "lucide-react" import { getLocale, getTranslations } from "next-intl/server" import { Trigger as TabsTrigger } from "@radix-ui/react-tabs" @@ -7,7 +7,6 @@ import type { Lang } from "@/lib/types" import LanguagePicker from "@/components/LanguagePicker" import ExpandIcon from "@/components/Nav/MobileMenu/ExpandIcon" import LvlAccordion from "@/components/Nav/MobileMenu/LvlAccordion" -import Search from "@/components/Search" import { Collapsible, CollapsibleContent, @@ -90,26 +89,26 @@ export default async function MobileMenu({
- + - {t("search")} + {t("languages")} - +
- + - {t("languages")} + {t("menu")}
diff --git a/src/intl/en/common.json b/src/intl/en/common.json index 137649d57fb..29ab3f2055a 100644 --- a/src/intl/en/common.json +++ b/src/intl/en/common.json @@ -227,6 +227,7 @@ "loading-error-try-again-later": "Unable to load data. Try again later.", "logo": "logo", "mainnet-ethereum": "Mainnet Ethereum", + "menu": "Menu", "merge": "Merge", "more": "More", "nav-about-description": "A public, open-source project for the Ethereum community", From 92ffc7fe96259b13d2154bd8757c2715170d0634 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 3 Sep 2025 19:12:57 +0200 Subject: [PATCH 33/33] highlight selected footer button in menu --- src/components/Nav/MobileMenu/FooterButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Nav/MobileMenu/FooterButton.tsx b/src/components/Nav/MobileMenu/FooterButton.tsx index 7421cc1d3b8..78f27f90205 100644 --- a/src/components/Nav/MobileMenu/FooterButton.tsx +++ b/src/components/Nav/MobileMenu/FooterButton.tsx @@ -11,7 +11,7 @@ const FooterButton = forwardRef( ({ icon: Icon, children, ...props }, ref) => (