diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 8e155e0e267..49f50e79b7d 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -46,7 +46,7 @@ export default async function LocaleLayout({ // Enable static rendering setRequestLocale(locale) - const allMessages = await getMessages({ locale }) + const allMessages = await getMessages() const messages = pick(allMessages, "common") const lastDeployDate = getLastDeployDate() diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 3c7fdd9d64a..231cff82a4a 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -157,6 +157,7 @@ const Page = async ({ params }: { params: Promise<{ locale: Lang }> }) => { if (!LOCALES_CODES.includes(locale)) return notFound() setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "page-index" }) const tCommon = await getTranslations({ locale, namespace: "common" }) const { direction: dir, isRtl } = getDirection(locale) diff --git a/package.json b/package.json index 8eba975ae9a..401ed5f7981 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/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/LanguagePicker/Desktop.tsx b/src/components/LanguagePicker/Desktop.tsx new file mode 100644 index 00000000000..1bd783d2499 --- /dev/null +++ b/src/components/LanguagePicker/Desktop.tsx @@ -0,0 +1,59 @@ +"use client" + +import type { LocaleDisplayInfo } from "@/lib/types" + +import { cn } from "@/lib/utils/cn" + +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" + +import LanguagePicker from "." + +import { useDisclosure } from "@/hooks/useDisclosure" +import { useEventListener } from "@/hooks/useEventListener" + +type DesktopLanguagePickerProps = { + children: React.ReactNode + languages: LocaleDisplayInfo[] + className?: string +} + +const DesktopLanguagePicker = ({ + children, + languages, + className, +}: DesktopLanguagePickerProps) => { + const { isOpen, setValue, onClose, onOpen } = useDisclosure() + + /** + * 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() + }) + + return ( + + {children} + + + + + ) +} + +export default DesktopLanguagePicker diff --git a/src/components/LanguagePicker/LanguagePickerFooter.tsx b/src/components/LanguagePicker/LanguagePickerFooter.tsx new file mode 100644 index 00000000000..0e707a1f981 --- /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..62f2de291ca --- /dev/null +++ b/src/components/LanguagePicker/LanguagePickerMenu.tsx @@ -0,0 +1,85 @@ +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 = { + className?: string + languages: LocaleDisplayInfo[] + onClose: () => void + onSelect: (value: string) => void +} + +const LanguagePickerMenu = ({ + className, + 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/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 ( -} - -export const MobileCloseBar = ({ handleClick }: MobileCloseBarProps) => { - const { t } = useTranslation() - - return ( -
- -
- ) -} diff --git a/src/components/LanguagePicker/index.tsx b/src/components/LanguagePicker/index.tsx index 354f071da94..f8f48529843 100644 --- a/src/components/LanguagePicker/index.tsx +++ b/src/components/LanguagePicker/index.tsx @@ -1,68 +1,59 @@ "use client" +import { useEffect } 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 { cn } from "@/lib/utils/cn" +import { trackCustomEvent } from "@/lib/utils/matomo" -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 LanguagePickerFooter from "./LanguagePickerFooter" +import LanguagePickerMenu from "./LanguagePickerMenu" import { useLanguagePicker } from "./useLanguagePicker" -import { useEventListener } from "@/hooks/useEventListener" -import { useTranslation } from "@/hooks/useTranslation" import { usePathname, useRouter } from "@/i18n/routing" type LanguagePickerProps = { - children: React.ReactNode className?: string - handleClose?: () => void - dialog?: boolean + languages: LocaleDisplayInfo[] + onSelect?: (value: string) => void + onNoResultsClose?: () => void + onTranslationProgramClick?: () => void } const LanguagePicker = ({ - children, - handleClose, + languages, className, - dialog, + onSelect, + onNoResultsClose, + onTranslationProgramClick, }: 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 { 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 @@ -72,171 +63,47 @@ const LanguagePicker = ({ locale: currentValue, } ) - onClose({ + + trackCustomEvent({ + eventCategory: `Language picker`, eventAction: "Locale chosen", eventName: currentValue, }) } - const handleBaseLinkClose = () => - onClose({ - eventAction: "Translation program link (menu footer)", + + const handleNoResultsClose = () => { + onNoResultsClose?.() + + trackCustomEvent({ + eventCategory: `Language picker`, + eventAction: "Translation program link (no results)", 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 handleTranslationProgramClick = () => { + onTranslationProgramClick?.() -const LanguagePickerMenu = ({ languages, onClose, onSelect }) => { - const { t } = useTranslation("common") + trackCustomEvent({ + eventCategory: `Language picker`, + eventAction: "Translation program link (menu footer)", + eventName: "/contributing/translation-program", + }) + } 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")} - -
+
) } diff --git a/src/components/LanguagePicker/useLanguagePicker.tsx b/src/components/LanguagePicker/useLanguagePicker.tsx index 34da208be57..35f584d135c 100644 --- a/src/components/LanguagePicker/useLanguagePicker.tsx +++ b/src/components/LanguagePicker/useLanguagePicker.tsx @@ -3,22 +3,16 @@ 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 { localeToDisplayInfo } from "./localeToDisplayInfo" +// Move locales computation outside component to make it stable +const FILTERED_LOCALES = filterRealLocales(LOCALES_CODES) -import { useDisclosure } from "@/hooks/useDisclosure" -import { useTranslation } from "@/hooks/useTranslation" - -export const useLanguagePicker = (handleClose?: () => void) => { - const { t } = useTranslation("common") +export const useLanguagePicker = (languages: LocaleDisplayInfo[]) => { const locale = useLocale() - const locales = useMemo(() => filterRealLocales(LOCALES_CODES), []) - // Find all matching browser language preferences in order const intlLocalePreferences = useMemo(() => { // Get the preferred languages for the users browser @@ -26,7 +20,7 @@ export const useLanguagePicker = (handleClose?: () => void) => { const preferences: Lang[] = [] for (const navLang of navLangs) { - const match = locales?.find((locale) => { + const match = FILTERED_LOCALES?.find((locale) => { // Exact match first if (locale.toLowerCase() === navLang.toLowerCase()) return true // Then partial match (e.g., 'en-US' matches 'en') @@ -40,90 +34,51 @@ export const useLanguagePicker = (handleClose?: () => void) => { } return preferences - }, [locales]) + }, []) // Keep the first preference for backward compatibility const intlLocalePreference = intlLocalePreferences[0] || "" - const languages = useMemo( - () => - (locales as Lang[]) - ?.map((localeOption) => { - const displayInfo = localeToDisplayInfo( - localeOption, - locale as Lang, - t - ) - const isBrowserDefault = intlLocalePreferences.includes( - localeOption as Lang - ) - return { - ...displayInfo, - isBrowserDefault, - } - }) - .sort((a, b) => { - const aPreferenceIndex = intlLocalePreferences.indexOf( - a.localeOption as Lang - ) - const bPreferenceIndex = intlLocalePreferences.indexOf( - b.localeOption as Lang - ) - - // First, sort by browser preferences (all browser preferences come first) - if (a.isBrowserDefault && !b.isBrowserDefault) return -1 - if (!a.isBrowserDefault && b.isBrowserDefault) return 1 - - // If both are browser preferences, sort by preference order - if (a.isBrowserDefault && b.isBrowserDefault) { - return aPreferenceIndex - bPreferenceIndex - } - - // Otherwise, sort alphabetically by source name using localeCompare - return a.sourceName.localeCompare(b.sourceName, locale) - }) || [], - [intlLocalePreferences, locale, locales, t] - ) - - const intlLanguagePreference = languages.find( + // Sort languages client-side to prioritize browser preference + const sortedLanguages = useMemo(() => { + return [...languages] + .map((displayInfo) => { + const isBrowserDefault = intlLocalePreferences.includes( + displayInfo.localeOption as Lang + ) + return { + ...displayInfo, + isBrowserDefault, + } + }) + .sort((a, b) => { + const aPreferenceIndex = intlLocalePreferences.indexOf( + a.localeOption as Lang + ) + const bPreferenceIndex = intlLocalePreferences.indexOf( + b.localeOption as Lang + ) + + // First, sort by browser preferences (all browser preferences come first) + if (a.isBrowserDefault && !b.isBrowserDefault) return -1 + if (!a.isBrowserDefault && b.isBrowserDefault) return 1 + + // If both are browser preferences, sort by preference order + if (a.isBrowserDefault && b.isBrowserDefault) { + return aPreferenceIndex - bPreferenceIndex + } + + // Otherwise, sort alphabetically by source name using localeCompare + return a.sourceName.localeCompare(b.sourceName, locale) + }) + }, [languages, intlLocalePreferences, locale]) + + const intlLanguagePreference = sortedLanguages.find( (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, + languages: sortedLanguages, intlLanguagePreference, } } 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/Client/index.tsx b/src/components/Nav/Client/index.tsx deleted file mode 100644 index fd1409ed661..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 - - 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..c3d279c971a --- /dev/null +++ b/src/components/Nav/DesktopNav.tsx @@ -0,0 +1,51 @@ +import { Languages } from "lucide-react" +import { getLocale, getTranslations } from "next-intl/server" + +import { cn } from "@/lib/utils/cn" + +import { DESKTOP_LANGUAGE_BUTTON_NAME } from "@/lib/constants" + +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() + + return ( +
+ + +
+ + + + + + + +
+
+ ) +} + +export default DesktopNav 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/ExpandIcon.tsx b/src/components/Nav/Mobile/ExpandIcon.tsx deleted file mode 100644 index bceaa6f343f..00000000000 --- a/src/components/Nav/Mobile/ExpandIcon.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Minus, Plus } from "lucide-react" - -type ExpandIconProps = { - isOpen: boolean -} - -const ExpandIcon = ({ isOpen }: ExpandIconProps) => - isOpen ? ( - - ) : ( - - ) - -export default ExpandIcon diff --git a/src/components/Nav/Mobile/LvlAccordion.tsx b/src/components/Nav/Mobile/LvlAccordion.tsx deleted file mode 100644 index e03ea6ff7aa..00000000000 --- a/src/components/Nav/Mobile/LvlAccordion.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useState } from "react" -import { useLocale } from "next-intl" -import * as AccordionPrimitive from "@radix-ui/react-accordion" - -import { cn } from "@/lib/utils/cn" -import { trackCustomEvent } from "@/lib/utils/matomo" -import { slugify } from "@/lib/utils/url" -import { cleanPath } from "@/lib/utils/url" - -import { Button } from "../../ui/buttons/Button" -import { BaseLink } from "../../ui/Link" -import type { Level, NavItem, NavSectionKey } from "../types" - -import ExpandIcon from "./ExpandIcon" -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "./MenuAccordion" - -import { usePathname } from "@/i18n/routing" - -type LvlAccordionProps = { - lvl: Level - items: NavItem[] - activeSection: NavSectionKey - onToggle: () => void -} - -const subtextColorPerLevel = { - 1: "text-menu-1-subtext", - 2: "text-menu-2-subtext", - 3: "text-menu-3-subtext", - 4: "text-menu-4-subtext", -} - -const backgroundColorPerLevel = { - 1: "bg-background", - 2: "bg-background-low", - 3: "bg-background-medium", - 4: "bg-background-high", -} - -const LvlAccordion = ({ - lvl, - items, - activeSection, - onToggle, -}: LvlAccordionProps) => { - const pathname = usePathname() - const locale = useLocale() - const [value, setValue] = useState("") - - 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", - 3: "ps-12", - 4: "ps-16", - 5: "ps-20", - 6: "ps-24", - } - - if (isLink) - return ( - - - {/* TODO: replace this with ButtonLink when is implemented */} - - - - ) - - return ( - - { - trackCustomEvent({ - eventCategory: "Mobile navigation menu", - eventAction: `Level ${lvl - 1} section changed`, - eventName: `${ - isExpanded ? "Close" : "Open" - } section: ${label} - ${description.slice(0, 16)}...`, - }) - }} - > - -
-

- {label} -

-

- {description} -

-
-
- - - - -
- ) - })} -
- ) -} - -export default LvlAccordion diff --git a/src/components/Nav/Mobile/MenuBody.tsx b/src/components/Nav/Mobile/MenuBody.tsx deleted file mode 100644 index 24f4d2f89ad..00000000000 --- a/src/components/Nav/Mobile/MenuBody.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useState } from "react" -import { useLocale } from "next-intl" - -import { cn } from "@/lib/utils/cn" -import { trackCustomEvent } from "@/lib/utils/matomo" -import { slugify } from "@/lib/utils/url" - -import { SECTION_LABELS } from "@/lib/constants" - -import type { Level, NavSections } from "../types" - -import ExpandIcon from "./ExpandIcon" -import LvlAccordion from "./LvlAccordion" -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "./MenuAccordion" - -type MenuBodyProps = { - onToggle: () => void - linkSections: NavSections -} - -const MenuBody = ({ linkSections, onToggle }: MenuBodyProps) => { - const locale = useLocale() - const [value, setValue] = useState("") - - 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 28e011d8e8a..00000000000 --- a/src/components/Nav/Mobile/MenuFooter.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Languages, Moon, Search, Sun } from "lucide-react" - -import LanguagePicker from "@/components/LanguagePicker" - -import { MOBILE_LANGUAGE_BUTTON_NAME } from "@/lib/constants" - -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 { t } = useTranslation("common") - const ThemeIcon = useColorModeValue(Moon, Sun) - const themeLabelKey = useColorModeValue("dark-mode", "light-mode") - - return ( -
- { - // Workaround to ensure the input for the search modal can have focus - onToggle() - toggleSearch() - }} - > - {t("search")} - - - - {t(themeLabelKey)} - - - - - {t("languages")} - - -
- ) -} - -export default MenuFooter diff --git a/src/components/Nav/Mobile/index.tsx b/src/components/Nav/Mobile/index.tsx deleted file mode 100644 index e151d082006..00000000000 --- a/src/components/Nav/Mobile/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client" - -import { - Sheet, - SheetContent, - SheetFooter, - SheetHeader, - SheetTrigger, -} from "@/components/ui/sheet" - -import { cn } from "@/lib/utils/cn" - -import { ButtonProps } from "../../ui/buttons/Button" -import type { NavSections } from "../types" - -import HamburgerButton from "./HamburgerButton" -import MenuBody from "./MenuBody" -import MenuFooter from "./MenuFooter" -import MenuHeader from "./MenuHeader" - -import { useDisclosure } from "@/hooks/useDisclosure" - -type MobileNavMenuProps = ButtonProps & { - toggleColorMode: () => void - toggleSearch: () => void - linkSections: NavSections -} - -const MobileNavMenu = ({ - toggleColorMode, - toggleSearch, - linkSections, - className, - ...props -}: MobileNavMenuProps) => { - const { isOpen, onToggle } = useDisclosure() - - // DRAWER MENU - return ( - - - - - - {/* HEADER ELEMENTS: SITE NAME, CLOSE BUTTON */} - - - - - {/* MAIN NAV ACCORDION CONTENTS OF MOBILE MENU */} -
- -
- - {/* FOOTER ELEMENTS: SEARCH, LIGHT/DARK, LANGUAGES */} - - - -
-
- ) -} - -export default MobileNavMenu diff --git a/src/components/Nav/MobileMenu/ExpandIcon.tsx b/src/components/Nav/MobileMenu/ExpandIcon.tsx new file mode 100644 index 00000000000..2303355ac7e --- /dev/null +++ b/src/components/Nav/MobileMenu/ExpandIcon.tsx @@ -0,0 +1,11 @@ +import { Minus, Plus } from "lucide-react" + +const ExpandIcon = () => ( + <> + + + + +) + +export default ExpandIcon diff --git a/src/components/Nav/Mobile/FooterButton.tsx b/src/components/Nav/MobileMenu/FooterButton.tsx similarity index 79% rename from src/components/Nav/Mobile/FooterButton.tsx rename to src/components/Nav/MobileMenu/FooterButton.tsx index 9d182cc40ca..78f27f90205 100644 --- a/src/components/Nav/Mobile/FooterButton.tsx +++ b/src/components/Nav/MobileMenu/FooterButton.tsx @@ -4,18 +4,18 @@ 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( ({ icon: Icon, children, ...props }, ref) => ( ) 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 99% rename from src/components/Nav/Mobile/HamburgerButton.tsx rename to src/components/Nav/MobileMenu/HamburgerButton.tsx index 90ec5d53834..b0d5e695ccf 100644 --- a/src/components/Nav/Mobile/HamburgerButton.tsx +++ b/src/components/Nav/MobileMenu/HamburgerButton.tsx @@ -1,3 +1,5 @@ +"use client" + import { forwardRef } from "react" import { motion } from "framer-motion" diff --git a/src/components/Nav/MobileMenu/LvlAccordion.tsx b/src/components/Nav/MobileMenu/LvlAccordion.tsx new file mode 100644 index 00000000000..c26d7621d45 --- /dev/null +++ b/src/components/Nav/MobileMenu/LvlAccordion.tsx @@ -0,0 +1,159 @@ +import { + CollapsibleContent, + CollapsibleTracked, + CollapsibleTrigger, +} from "@/components/ui/collapsible" + +import { cn } from "@/lib/utils/cn" +import { slugify } from "@/lib/utils/url" + +import { Button } from "../../ui/buttons/Button" +import { BaseLink } from "../../ui/Link" +import type { Level, NavItem, NavSectionKey } from "../types" + +import ExpandIcon from "./ExpandIcon" + +type LvlAccordionProps = { + lvl: Level + items: NavItem[] + activeSection: NavSectionKey + locale: string +} + +const subtextColorPerLevel = { + 1: "text-menu-1-subtext", + 2: "text-menu-2-subtext", + 3: "text-menu-3-subtext", + 4: "text-menu-4-subtext", +} + +const backgroundColorPerLevel = { + 1: "bg-background", + 2: "bg-background-low", + 3: "bg-background-medium", + 4: "bg-background-high", +} + +const nestedAccordionSpacingMap = { + 2: "ps-8", + 3: "ps-12", + 4: "ps-16", + 5: "ps-20", + 6: "ps-24", +} + +const LvlAccordion = async ({ + lvl, + items, + activeSection, + locale, +}: LvlAccordionProps) => { + return ( + <> + {items.map(({ label, description, ...action }) => { + const isLink = "href" in action + + if (isLink) + return ( +
+ +
+ ) + + 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] + )} + > + +
+

+ {label} +

+

+ {description} +

+
+
+ + + + +
+ ) + })} + + ) +} + +export default LvlAccordion diff --git a/src/components/Nav/Mobile/MenuAccordion.tsx b/src/components/Nav/MobileMenu/MenuAccordion.tsx similarity index 51% rename from src/components/Nav/Mobile/MenuAccordion.tsx rename to src/components/Nav/MobileMenu/MenuAccordion.tsx index f9a3ef3db3a..970569f9ca9 100644 --- a/src/components/Nav/Mobile/MenuAccordion.tsx +++ b/src/components/Nav/MobileMenu/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/MenuHeader.tsx b/src/components/Nav/MobileMenu/MenuHeader.tsx similarity index 100% rename from src/components/Nav/Mobile/MenuHeader.tsx rename to src/components/Nav/MobileMenu/MenuHeader.tsx diff --git a/src/components/Nav/MobileMenu/ThemeToggleFooterButton.tsx b/src/components/Nav/MobileMenu/ThemeToggleFooterButton.tsx new file mode 100644 index 00000000000..5e1e77805bd --- /dev/null +++ b/src/components/Nav/MobileMenu/ThemeToggleFooterButton.tsx @@ -0,0 +1,30 @@ +"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 ThemeToggleFooterButton = () => { + const { t } = useTranslation("common") + const ThemeIcon = useColorModeValue(Moon, Sun) + const themeLabelKey = useColorModeValue("dark-mode", "light-mode") + const { toggleColorMode } = useThemeToggle() + + return ( + + {t(themeLabelKey)} + + ) +} + +export default ThemeToggleFooterButton 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 new file mode 100644 index 00000000000..89e52a2d279 --- /dev/null +++ b/src/components/Nav/MobileMenu/index.tsx @@ -0,0 +1,174 @@ +import { Languages, Menu } 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" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { + 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" +import { isLangRightToLeft } from "@/lib/utils/translations" +import { slugify } from "@/lib/utils/url" + +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" }) + const locale = await getLocale() + const isRtl = isLangRightToLeft(locale as Lang) + const side = isRtl ? "right" : "left" + const dir = isRtl ? "rtl" : "ltr" + + return ( + + + + + + + + + + + + + + + + + + + +
+ + + {t("languages")} + + +
+
+ +
+
+ + + {t("menu")} + + +
+
+
+
+
+
+ ) +} + +async function NavigationContent({ className }: { className?: string }) { + const locale = await getLocale() + const linkSections = await getNavigation() + + return ( + + ) +} + +async function LanguageContent({ className }: { className?: string }) { + const languages = await getLanguagesDisplayInfo() + + return +} diff --git a/src/components/Nav/MobileNav.tsx b/src/components/Nav/MobileNav.tsx new file mode 100644 index 00000000000..2557047ba0c --- /dev/null +++ b/src/components/Nav/MobileNav.tsx @@ -0,0 +1,16 @@ +import { cn } from "@/lib/utils/cn" + +import Search from "../Search" + +import MobileMenu from "./MobileMenu" + +const MobileNav = ({ className }: { className?: string }) => { + return ( +
+ + +
+ ) +} + +export default MobileNav 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 ( + + ) +} diff --git a/src/components/Nav/index.tsx b/src/components/Nav/index.tsx index 62cc661bd72..011110fc494 100644 --- a/src/components/Nav/index.tsx +++ b/src/components/Nav/index.tsx @@ -1,14 +1,19 @@ -import { getLocale, getTranslations } from "next-intl/server" +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 ClientSideNav from "./Client" +import DesktopNav from "./DesktopNav" +import { DesktopNavLoading, MobileNavLoading } from "./loading" +import MobileNav from "./MobileNav" const Nav = async () => { - const locale = await getLocale() - const t = await getTranslations({ locale, namespace: "common" }) + const t = await getTranslations({ namespace: "common" }) return ( ) 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 ( + <> +
+ + +
+
+ + +
+ + ) +} diff --git a/src/components/Nav/types.ts b/src/components/Nav/types.ts index d3dd7254ad1..94fc1014ca6 100644 --- a/src/components/Nav/types.ts +++ b/src/components/Nav/types.ts @@ -6,6 +6,7 @@ type ItemsOnly = { items: NavItem[]; href?: never } type LinkXorItems = LinkOnly | ItemsOnly export type NavItem = { + id?: string label: string description: string icon?: LucideIcon | FC> diff --git a/src/components/Nav/useNavigation.ts b/src/components/Nav/useNavigation.ts index c939c59d715..d0a469bdec6 100644 --- a/src/components/Nav/useNavigation.ts +++ b/src/components/Nav/useNavigation.ts @@ -17,477 +17,72 @@ import SlidersHorizontalCircles from "@/components/icons/sliders-horizontal-circ import UiChecksGridIcon from "@/components/icons/ui-checks-grid.svg" import UsersFourLight from "@/components/icons/users-four-light.svg" -import type { NavSections } from "./types" +import type { NavItem, NavSections } from "./types" import useTranslation from "@/hooks/useTranslation" +import { buildNavigation } from "@/lib/nav/buildNavigation" export const useNavigation = () => { const { t } = useTranslation("common") - const linkSections: NavSections = { + const linkSections: NavSections = buildNavigation(t) + + const iconById: Record = { + "learn/overview": CompassIcon, + "learn/basics": UiChecksGridIcon, + "learn/advanced": SlidersHorizontalCircles, + "learn/quizzes": MortarboardIcon, + "use/get-started": PinAngleIcon, + "use/use-cases": LightbulbIcon, + "use/stake": SafeIcon, + "use/networks": LayersIcon, + "build/home": CodeSquareIcon, + "build/get-started": FlagIcon, + "build/docs": JournalCodeIcon, + "build/enterprise": BuildingsIcon, + "participate/community-hub": UsersFourLight, + "participate/events": MegaphoneIcon, + "participate/ethereum-org": EthereumIcon, + "research/whitepaper": BookIcon, + "research/roadmap": SignpostIcon, + "research/research": Flask, + } + + const applyIconsToItems = (items: NavItem[]): NavItem[] => + items.map((item) => { + const icon = item.id ? iconById[item.id] : undefined + if ("items" in item && item.items) { + return { + ...item, + ...(icon ? { icon } : {}), + items: applyIconsToItems(item.items), + } + } + return { ...item, ...(icon ? { icon } : {}) } as NavItem + }) + + const linkSectionsWithIcons: 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/", - }, - ], + ...linkSections.learn, + items: applyIconsToItems(linkSections.learn.items), }, 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/", - }, - ], - }, - ], + ...linkSections.use, + items: applyIconsToItems(linkSections.use.items), }, 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/", - }, - ], + ...linkSections.build, + items: applyIconsToItems(linkSections.build.items), }, 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/", - }, - ], - }, - ], + ...linkSections.participate, + items: applyIconsToItems(linkSections.participate.items), }, 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/", - }, - ], - }, - ], + ...linkSections.research, + items: applyIconsToItems(linkSections.research.items), }, } - return { linkSections } + return { linkSections: linkSectionsWithIcons } } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 7262ef0b5c6..b661208dd6f 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -5,21 +5,26 @@ 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" 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 +interface SearchProps { + asChild?: boolean + children?: React.ReactElement } -const Search = ({ children }: Props) => { +const Search = ({ asChild = false, children }: SearchProps) => { const disclosure = useDisclosure() const { isOpen, onOpen, onClose } = disclosure @@ -48,75 +53,87 @@ const Search = ({ children }: Props) => { 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 ( <> - {children({ ...disclosure, onOpen: handleOpen })} - - {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 && } ) diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 00000000000..ee7163c1414 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +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 + +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, +} 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 } diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 5e643c0034e..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 @@ -58,14 +64,16 @@ const sheetVariants = cva( interface SheetContentProps extends React.ComponentPropsWithoutRef, - VariantProps {} + VariantProps { + hideOverlay?: boolean +} const SheetContent = React.forwardRef< React.ElementRef, SheetContentProps ->(({ side = "right", className, ...props }, ref) => ( +>(({ side = "right", hideOverlay = false, className, ...props }, ref) => ( - + {!hideOverlay && } string + +export const buildNavigation = (t: TranslateFn): NavSections => { + return { + learn: { + label: t("learn"), + ariaLabel: t("learn-menu"), + items: [ + { + id: "learn/overview", + label: t("nav-overview-label"), + description: t("nav-overview-description"), + href: "/learn/", + }, + { + id: "learn/basics", + label: t("nav-basics-label"), + description: t("nav-basics-description"), + 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/", + }, + ], + }, + { + id: "learn/advanced", + label: t("nav-advanced-label"), + description: t("nav-advanced-description"), + 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/", + }, + ], + }, + { + id: "learn/quizzes", + label: t("nav-quizzes-label"), + description: t("nav-quizzes-description"), + href: "/quizzes/", + }, + ], + }, + use: { + label: t("use"), + ariaLabel: t("use-menu"), + items: [ + { + id: "use/get-started", + label: t("get-started"), + description: t("nav-get-started-description"), + 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/", + }, + ], + }, + ], + }, + { + id: "use/use-cases", + label: t("nav-use-cases-label"), + description: t("nav-use-cases-description"), + 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/", + }, + ], + }, + ], + }, + { + id: "use/stake", + label: t("nav-stake-label"), + description: t("nav-stake-description"), + 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/", + }, + ], + }, + { + id: "use/networks", + label: t("nav-ethereum-networks"), + description: t("nav-ethereum-networks-description"), + 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: [ + { + id: "build/home", + label: t("nav-builders-home-label"), + description: t("nav-builders-home-description"), + href: "/developers/", + }, + { + id: "build/get-started", + label: t("get-started"), + description: t("nav-start-building-description"), + 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/", + }, + ], + }, + { + id: "build/docs", + label: t("documentation"), + description: t("nav-docs-description"), + 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/", + }, + ], + }, + { + id: "build/enterprise", + label: t("enterprise"), + description: t("nav-mainnet-description"), + href: "/enterprise/", + }, + ], + }, + participate: { + label: t("participate"), + ariaLabel: t("participate-menu"), + items: [ + { + id: "participate/community-hub", + label: t("community-hub"), + description: t("nav-participate-overview-description"), + href: "/community/", + }, + { + id: "participate/events", + label: t("nav-events-label"), + description: t("nav-events-description"), + 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/", + }, + ], + }, + { + id: "participate/ethereum-org", + label: t("site-title"), + description: t("nav-ethereum-org-description"), + 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: [ + { + id: "research/whitepaper", + label: t("ethereum-whitepaper"), + description: t("nav-whitepaper-description"), + href: "/whitepaper/", + }, + { + id: "research/roadmap", + label: t("nav-roadmap-label"), + description: t("nav-roadmap-description"), + 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/", + }, + ], + }, + { + id: "research/research", + label: t("nav-research-label"), + description: t("nav-research-description"), + 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/", + }, + ], + }, + ], + }, + } +} diff --git a/src/lib/nav/links.ts b/src/lib/nav/links.ts new file mode 100644 index 00000000000..b64468d8fc2 --- /dev/null +++ b/src/lib/nav/links.ts @@ -0,0 +1,43 @@ +import { getLocale, getTranslations } from "next-intl/server" + +import type { Lang, LocaleDisplayInfo } from "@/lib/types" + +import type { NavSections } from "@/components/Nav/types" + +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) + +/** + * 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 () => { + const t = await getTranslations({ + namespace: "common", + }) + + const linkSections: NavSections = buildNavigation(t) + + return linkSections +} diff --git a/src/components/LanguagePicker/localeToDisplayInfo.ts b/src/lib/nav/localeToDisplayInfo.ts similarity index 83% rename from src/components/LanguagePicker/localeToDisplayInfo.ts rename to src/lib/nav/localeToDisplayInfo.ts index 916bd868746..50f97a03efc 100644 --- a/src/components/LanguagePicker/localeToDisplayInfo.ts +++ b/src/lib/nav/localeToDisplayInfo.ts @@ -13,6 +13,20 @@ import { DEFAULT_LOCALE } from "@/lib/constants" const progressData = progressDataJson satisfies ProjectProgressData[] +const getProgressInfo = ( + locale: Lang, + 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 } +} + 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 8648cfd3020..219d10c765d 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 } 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() } /**