Răsfoiți Sursa

Merge pull request #18996 from overleaf/td-bs5-nav-react

Main navigation React component

GitOrigin-RevId: c99a4b4a2f6fd02618689f829681118b2b64aa8d
Tim Down 2 ani în urmă
părinte
comite
d5643d53b3
33 a modificat fișierele cu 669 adăugiri și 15 ștergeri
  1. 20 0
      services/web/app/src/infrastructure/ExpressLocals.js
  2. 1 1
      services/web/app/views/_mixins/navbar.pug
  3. 61 0
      services/web/app/views/layout-react.pug
  4. 1 0
      services/web/app/views/layout/navbar-marketing-react-bootstrap-5.pug
  5. 1 1
      services/web/app/views/project/list-react.pug
  6. 1 1
      services/web/app/views/user/settings.pug
  7. 5 0
      services/web/frontend/extracted-translations.json
  8. 9 0
      services/web/frontend/js/features/header-footer-react/index.tsx
  9. 2 2
      services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx
  10. 49 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/admin-menu.tsx
  11. 25 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/contact-us-item.tsx
  12. 119 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/default-navbar.tsx
  13. 32 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/header-logo-or-title.tsx
  14. 60 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/logged-in-items.tsx
  15. 5 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-divider.tsx
  16. 44 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-from-data.tsx
  17. 5 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-item.tsx
  18. 22 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item.tsx
  19. 23 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu.tsx
  20. 29 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-item-from-data.tsx
  21. 10 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-item.tsx
  22. 23 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-link-item.tsx
  23. 23 0
      services/web/frontend/js/features/ui/components/bootstrap-5/navbar/util.ts
  24. 20 0
      services/web/frontend/js/features/ui/components/types/default-navbar-metadata.ts
  25. 1 0
      services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts
  26. 52 0
      services/web/frontend/js/features/ui/components/types/navbar.ts
  27. 2 2
      services/web/frontend/js/features/utils/bootstrap-5.ts
  28. 1 0
      services/web/frontend/js/marketing.js
  29. 1 1
      services/web/frontend/js/pages/project-list.jsx
  30. 2 0
      services/web/frontend/js/utils/meta.ts
  31. 3 0
      services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss
  32. 12 3
      services/web/frontend/stylesheets/bootstrap-5/components/navbar.scss
  33. 5 4
      services/web/locales/en.json

+ 20 - 0
services/web/app/src/infrastructure/ExpressLocals.js

@@ -227,6 +227,26 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
   webRouter.use(function (req, res, next) {
     res.locals.translate = req.i18n.translate
 
+    const addTranslatedTextDeep = obj => {
+      if (_.isObject(obj)) {
+        if (_.has(obj, 'text')) {
+          obj.translatedText = req.i18n.translate(obj.text)
+        }
+        _.forOwn(obj, value => {
+          addTranslatedTextDeep(value)
+        })
+      }
+    }
+
+    // This function is used to add translations from the server for main
+    // navigation items because it's tricky to get them in the front end
+    // otherwise.
+    res.locals.cloneAndTranslateText = obj => {
+      const clone = _.cloneDeep(obj)
+      addTranslatedTextDeep(clone)
+      return clone
+    }
+
     // Don't include the query string parameters, otherwise Google
     // treats ?nocdn=true as the canonical version
     try {

+ 1 - 1
services/web/app/views/_mixins/navbar.pug

@@ -3,7 +3,7 @@ mixin nav-item
 		block
 
 mixin nav-link
-	a(role="menuitem")&attributes(attributes)
+	a(role="menuitem").nav-link&attributes(attributes)
 		block
 
 mixin dropdown-menu

+ 61 - 0
services/web/app/views/layout-react.pug

@@ -0,0 +1,61 @@
+//- This is used for pages that are migrated to Bootstrap 5 but don't use Bootstrap's own JS, instead using
+//- react-bootstrap for all Bootstrap components
+extends ./layout-base
+
+include ./_mixins/formMessages
+include ./_mixins/bootstrap_js
+
+block entrypointVar
+	- entrypoint = 'marketing'
+		
+block append meta
+	if bootstrapVersion === 5
+		- const canDisplayAdminMenu = hasAdminAccess()
+		- const canDisplayAdminRedirect = canRedirectToAdminDomain()
+		- const sessionUser = getSessionUser()
+		- const staffAccess = sessionUser?.staffAccess
+		- const canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || staffAccess?.splitTestMetrics || staffAccess?.splitTestManagement)
+		- const canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu
+		- const enableUpgradeButton = projectDashboardReact && usersBestSubscription && usersBestSubscription.type === 'free'
+
+		meta(name="ol-navbar" data-type="json" content={
+			customLogo: settings.nav.custom_logo,
+			title: nav.title,
+			canDisplayAdminMenu,
+			canDisplayAdminRedirect,
+			canDisplaySplitTestMenu,
+			canDisplaySurveyMenu,
+			enableUpgradeButton,
+			suppressNavbarRight: !!suppressNavbarRight,
+			suppressNavContentLinks: !!suppressNavContentLinks,
+			showSubscriptionLink: nav.showSubscriptionLink,
+			sessionUser: sessionUser ? { email: sessionUser.email} : undefined,
+			adminUrl: settings.adminUrl,
+			items: cloneAndTranslateText(nav.header_extras)
+		})
+
+block body
+	if (typeof suppressNavbar === "undefined")
+		if bootstrapVersion === 5
+			include layout/navbar-marketing-react-bootstrap-5
+		else
+			include layout/navbar-marketing
+
+	block content
+
+	if (typeof suppressFooter === "undefined")
+		if showThinFooter
+			include layout/footer-marketing
+		else
+			include layout/fat-footer
+
+	if (typeof suppressCookieBanner === "undefined")
+		include _cookie_banner
+
+	if bootstrapVersion === 3
+		!= moduleIncludes("contactModal-marketing", locals)
+		
+block prepend foot-scripts
+	//- Only include Bootstrap JS if using Bootstrap 3 
+	if bootstrapVersion === 3
+		+bootstrap-js(3)

+ 1 - 0
services/web/app/views/layout/navbar-marketing-react-bootstrap-5.pug

@@ -0,0 +1 @@
+#navbar-container

+ 1 - 1
services/web/app/views/project/list-react.pug

@@ -1,4 +1,4 @@
-extends ../layout-marketing
+extends ../layout-react
 
 block entrypointVar
 	- entrypoint = 'pages/project-list'

+ 1 - 1
services/web/app/views/user/settings.pug

@@ -1,4 +1,4 @@
-extends ../layout-marketing
+extends ../layout-react
 
 block entrypointVar
 	- entrypoint = 'pages/user/settings'

+ 5 - 0
services/web/frontend/extracted-translations.json

@@ -2,6 +2,8 @@
   "1_2_width": "",
   "1_4_width": "",
   "3_4_width": "",
+  "Account": "",
+  "Account Settings": "",
   "a_custom_size_has_been_used_in_the_latex_code": "",
   "a_fatal_compile_error_that_completely_blocks_compilation": "",
   "a_file_with_that_name_already_exists_and_will_be_overriden": "",
@@ -770,6 +772,7 @@
   "log_entry_maximum_entries_title": "",
   "log_hint_extra_info": "",
   "log_in_with_primary_email_address": "",
+  "log_out": "",
   "log_out_lowercase_dot": "",
   "log_viewer_error": "",
   "logging_in_or_managing_your_account": "",
@@ -782,6 +785,7 @@
   "lost_connection": "",
   "main_document": "",
   "main_file_not_found": "",
+  "main_navigation": "",
   "make_a_copy": "",
   "make_email_primary_description": "",
   "make_owner": "",
@@ -1358,6 +1362,7 @@
   "submit_title": "",
   "subscribe": "",
   "subscribe_to_find_the_symbols_you_need_faster": "",
+  "subscription": "",
   "subscription_admins_cannot_be_deleted": "",
   "subscription_canceled": "",
   "subscription_canceled_and_terminate_on_x": "",

+ 9 - 0
services/web/frontend/js/features/header-footer-react/index.tsx

@@ -0,0 +1,9 @@
+import ReactDOM from 'react-dom'
+import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
+import getMeta from '@/utils/meta'
+
+const element = document.getElementById('navbar-container')
+if (element) {
+  const navbarProps = getMeta('ol-navbar')
+  ReactDOM.render(<DefaultNavbar {...navbarProps} />, element)
+}

+ 2 - 2
services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx

@@ -99,8 +99,8 @@ export const DropdownMenu = forwardRef<
 })
 DropdownMenu.displayName = 'DropdownMenu'
 
-export function DropdownDivider({ as = 'li' }: DropdownDividerProps) {
-  return <BS5DropdownDivider as={as} />
+export function DropdownDivider({ as = 'li', ...props }: DropdownDividerProps) {
+  return <BS5DropdownDivider as={as} {...props} />
 }
 
 export function DropdownHeader({ as = 'li', ...props }: DropdownHeaderProps) {

+ 49 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/admin-menu.tsx

@@ -0,0 +1,49 @@
+import type { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
+import NavDropdownMenu from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu'
+import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
+
+export default function AdminMenu({
+  canDisplayAdminMenu,
+  canDisplayAdminRedirect,
+  canDisplaySplitTestMenu,
+  canDisplaySurveyMenu,
+  adminUrl,
+}: Pick<
+  DefaultNavbarMetadata,
+  | 'canDisplayAdminMenu'
+  | 'canDisplayAdminRedirect'
+  | 'canDisplaySplitTestMenu'
+  | 'canDisplaySurveyMenu'
+  | 'adminUrl'
+>) {
+  return (
+    <NavDropdownMenu title="Admin" className="subdued">
+      {canDisplayAdminMenu ? (
+        <>
+          <NavDropdownLinkItem href="/admin">Manage Site</NavDropdownLinkItem>
+          <NavDropdownLinkItem href="/admin/user">
+            Manage Users
+          </NavDropdownLinkItem>
+          <NavDropdownLinkItem href="/admin/project">
+            Project URL lookup
+          </NavDropdownLinkItem>
+        </>
+      ) : null}
+      {canDisplayAdminRedirect && adminUrl ? (
+        <NavDropdownLinkItem href={adminUrl}>
+          Switch to Admin
+        </NavDropdownLinkItem>
+      ) : null}
+      {canDisplaySplitTestMenu ? (
+        <NavDropdownLinkItem href="/admin/split-test">
+          Manage Feature Flags
+        </NavDropdownLinkItem>
+      ) : null}
+      {canDisplaySurveyMenu ? (
+        <NavDropdownLinkItem href="/admin/survey">
+          Manage Surveys
+        </NavDropdownLinkItem>
+      ) : null}
+    </NavDropdownMenu>
+  )
+}

+ 25 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/contact-us-item.tsx

@@ -0,0 +1,25 @@
+import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
+import { sendMB } from '@/infrastructure/event-tracking'
+import { useTranslation } from 'react-i18next'
+import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
+import { UserProvider } from '@/shared/context/user-context'
+
+export default function ContactUsItem() {
+  const { t } = useTranslation()
+  const { modal, showModal } = useContactUsModal({ autofillProjectUrl: false })
+
+  return (
+    <>
+      <NavDropdownLinkItem
+        href="#"
+        onClick={() => {
+          sendMB('menu-clicked-contact')
+          showModal()
+        }}
+      >
+        {t('contact_us')}
+      </NavDropdownLinkItem>
+      <UserProvider>{modal}</UserProvider>
+    </>
+  )
+}

+ 119 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/default-navbar.tsx

@@ -0,0 +1,119 @@
+import { sendMB } from '@/infrastructure/event-tracking'
+import { useTranslation } from 'react-i18next'
+import { Button, Container, Nav, Navbar } from 'react-bootstrap-5'
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import AdminMenu from '@/features/ui/components/bootstrap-5/navbar/admin-menu'
+import type { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
+import NavItemFromData from '@/features/ui/components/bootstrap-5/navbar/nav-item-from-data'
+import LoggedInItems from '@/features/ui/components/bootstrap-5/navbar/logged-in-items'
+import HeaderLogoOrTitle from '@/features/ui/components/bootstrap-5/navbar/header-logo-or-title'
+import MaterialIcon from '@/shared/components/material-icon'
+
+function LoggedOutItems() {
+  return <span>Logged out</span>
+}
+
+function DefaultNavbar(props: DefaultNavbarMetadata) {
+  const {
+    customLogo,
+    title,
+    canDisplayAdminMenu,
+    canDisplayAdminRedirect,
+    canDisplaySplitTestMenu,
+    canDisplaySurveyMenu,
+    enableUpgradeButton,
+    suppressNavbarRight,
+    suppressNavContentLinks,
+    showSubscriptionLink,
+    sessionUser,
+    adminUrl,
+    items,
+  } = props
+  const { t } = useTranslation()
+  const { isReady } = useWaitForI18n()
+
+  if (!isReady) {
+    return null
+  }
+
+  return (
+    <>
+      <Navbar className="navbar-default navbar-main" expand="lg">
+        <Container className="navbar-container" fluid>
+          <div className="navbar-header">
+            <HeaderLogoOrTitle title={title} customLogo={customLogo} />
+            {enableUpgradeButton ? (
+              <Button
+                as="a"
+                href="/user/subscription/plans"
+                className="me-2 d-md-none"
+                onClick={() => {
+                  sendMB('upgrade-button-click', {
+                    source: 'dashboard-top',
+                    'project-dashboard-react': 'enabled',
+                    'is-dashboard-sidebar-hidden': 'true',
+                    'is-screen-width-less-than-768px': 'true',
+                  })
+                }}
+              >
+                {t('upgrade')}
+              </Button>
+            ) : null}
+          </div>
+          {suppressNavbarRight ? null : (
+            <>
+              <Navbar.Toggle
+                aria-controls="navbar-main-collapse"
+                aria-expanded="false"
+                aria-label={t('main_navigation')}
+              >
+                <MaterialIcon type="menu" />
+              </Navbar.Toggle>
+              <Navbar.Collapse
+                id="navbar-main-collapse"
+                className="justify-content-end"
+              >
+                <Nav as="ul" className="ms-auto" role="menubar">
+                  {canDisplayAdminMenu ||
+                  canDisplayAdminRedirect ||
+                  canDisplaySplitTestMenu ? (
+                    <AdminMenu
+                      canDisplayAdminMenu={canDisplayAdminMenu}
+                      canDisplayAdminRedirect={canDisplayAdminRedirect}
+                      canDisplaySplitTestMenu={canDisplaySplitTestMenu}
+                      canDisplaySurveyMenu={canDisplaySurveyMenu}
+                      adminUrl={adminUrl}
+                    />
+                  ) : null}
+                  {items.map((item, index) => {
+                    const showNavItem =
+                      (item.only_when_logged_in && sessionUser) ||
+                      (item.only_when_logged_out && sessionUser) ||
+                      (!item.only_when_logged_out &&
+                        !item.only_when_logged_in &&
+                        !item.only_content_pages) ||
+                      (item.only_content_pages && !suppressNavContentLinks)
+
+                    return showNavItem ? (
+                      <NavItemFromData item={item} key={index} />
+                    ) : null
+                  })}
+                  {sessionUser ? (
+                    <LoggedInItems
+                      sessionUser={sessionUser}
+                      showSubscriptionLink={showSubscriptionLink}
+                    />
+                  ) : (
+                    <LoggedOutItems />
+                  )}
+                </Nav>
+              </Navbar.Collapse>
+            </>
+          )}
+        </Container>
+      </Navbar>
+    </>
+  )
+}
+
+export default DefaultNavbar

+ 32 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/header-logo-or-title.tsx

@@ -0,0 +1,32 @@
+import type { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
+import getMeta from '@/utils/meta'
+
+export default function HeaderLogoOrTitle({
+  customLogo,
+  title,
+}: Pick<DefaultNavbarMetadata, 'customLogo' | 'title'>) {
+  const { appName } = getMeta('ol-ExposedSettings')
+
+  if (customLogo) {
+    return (
+      // eslint-disable-next-line jsx-a11y/anchor-has-content
+      <a
+        href="/"
+        aria-label={appName}
+        className="navbar-brand"
+        style={{ backgroundImage: `url("${customLogo}")` }}
+      />
+    )
+  } else if (title) {
+    return (
+      <a href="/" aria-label={appName} className="navbar-title">
+        {title}
+      </a>
+    )
+  } else {
+    return (
+      // eslint-disable-next-line jsx-a11y/anchor-has-content
+      <a href="/" aria-label={appName} className="navbar-brand" />
+    )
+  }
+}

+ 60 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/logged-in-items.tsx

@@ -0,0 +1,60 @@
+import { useTranslation } from 'react-i18next'
+import { Dropdown } from 'react-bootstrap-5'
+import NavDropdownDivider from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-divider'
+import getMeta from '@/utils/meta'
+import NavDropdownMenu from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu'
+import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
+import NavDropdownItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-item'
+import type { NavbarSessionUser } from '@/features/ui/components/types/navbar'
+import NavLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-link-item'
+
+export default function LoggedInItems({
+  sessionUser,
+  showSubscriptionLink,
+}: {
+  sessionUser: NavbarSessionUser
+  showSubscriptionLink: boolean
+}) {
+  const { t } = useTranslation()
+  const logOutFormId = 'logOutForm'
+
+  return (
+    <>
+      <NavLinkItem href="/project">{t('projects')}</NavLinkItem>
+      <NavDropdownMenu title={t('Account')}>
+        <Dropdown.Item as="li" disabled role="menuitem">
+          {sessionUser.email}
+        </Dropdown.Item>
+        <NavDropdownDivider />
+        <NavDropdownLinkItem href="/user/settings">
+          {t('Account Settings')}
+        </NavDropdownLinkItem>
+        {showSubscriptionLink ? (
+          <NavDropdownLinkItem href="/user/subscription">
+            {t('subscription')}
+          </NavDropdownLinkItem>
+        ) : null}
+        <NavDropdownDivider />
+        <NavDropdownItem>
+          {
+            // The button is outside the form but still belongs to it via the
+            // form attribute. The reason to do this is that if the button is
+            // inside the form, screen readers will not count it in the total
+            // number of menu items
+          }
+          <Dropdown.Item
+            as="button"
+            type="submit"
+            form={logOutFormId}
+            role="menuitem"
+          >
+            {t('log_out')}
+          </Dropdown.Item>
+          <form id={logOutFormId} method="POST" action="/logout">
+            <input type="hidden" name="_csrf" value={getMeta('ol-csrfToken')} />
+          </form>
+        </NavDropdownItem>
+      </NavDropdownMenu>
+    </>
+  )
+}

+ 5 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-divider.tsx

@@ -0,0 +1,5 @@
+import { DropdownDivider } from '@/features/ui/components/bootstrap-5/dropdown-menu'
+
+export default function NavDropdownDivider() {
+  return <DropdownDivider className="d-none d-lg-block" />
+}

+ 44 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-from-data.tsx

@@ -0,0 +1,44 @@
+import type { NavbarDropdownItemData } from '@/features/ui/components/types/navbar'
+import NavDropdownDivider from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-divider'
+import { sendMB } from '@/infrastructure/event-tracking'
+import { isDropdownLinkItem } from '@/features/ui/components/bootstrap-5/navbar/util'
+import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
+import NavDropdownItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-item'
+import NavDropdownMenu from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu'
+import ContactUsItem from '@/features/ui/components/bootstrap-5/navbar/contact-us-item'
+
+export default function NavDropdownFromData({
+  item,
+}: {
+  item: NavbarDropdownItemData
+}) {
+  return (
+    <NavDropdownMenu title={item.translatedText} className={item.class}>
+      {item.dropdown.map((child, index) => {
+        if ('divider' in child) {
+          return <NavDropdownDivider key={index} />
+        } else if ('isContactUs' in child) {
+          return <ContactUsItem key={index} />
+        } else if (isDropdownLinkItem(child)) {
+          return (
+            <NavDropdownLinkItem
+              key={index}
+              href={child.url}
+              onClick={() => {
+                sendMB(child.event)
+              }}
+            >
+              {child.translatedText}
+            </NavDropdownLinkItem>
+          )
+        } else {
+          return (
+            <NavDropdownItem key={index}>
+              {child.translatedText}
+            </NavDropdownItem>
+          )
+        }
+      })}
+    </NavDropdownMenu>
+  )
+}

+ 5 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-item.tsx

@@ -0,0 +1,5 @@
+import { ReactNode } from 'react'
+
+export default function NavDropdownItem({ children }: { children: ReactNode }) {
+  return <li role="none">{children}</li>
+}

+ 22 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item.tsx

@@ -0,0 +1,22 @@
+import { ReactNode } from 'react'
+import NavDropdownItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-item'
+import { DropdownItem } from 'react-bootstrap-5'
+import { DropdownItemProps } from 'react-bootstrap-5/DropdownItem'
+
+export default function NavDropdownLinkItem({
+  href,
+  onClick,
+  children,
+}: {
+  href: string
+  onClick?: DropdownItemProps['onClick']
+  children: ReactNode
+}) {
+  return (
+    <NavDropdownItem>
+      <DropdownItem href={href} role="menuitem" onClick={onClick}>
+        {children}
+      </DropdownItem>
+    </NavDropdownItem>
+  )
+}

+ 23 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu.tsx

@@ -0,0 +1,23 @@
+import { ReactNode } from 'react'
+import { Dropdown } from 'react-bootstrap-5'
+
+export default function NavDropdownMenu({
+  title,
+  className,
+  children,
+}: {
+  title: string
+  className?: string
+  children: ReactNode
+}) {
+  // Can't use a NavDropdown here because it's impossible to render the menu as
+  // a <ul> element using NavDropdown
+  return (
+    <Dropdown as="li" role="none" className={className}>
+      <Dropdown.Toggle role="menuitem">{title}</Dropdown.Toggle>
+      <Dropdown.Menu as="ul" role="menu" align="end">
+        {children}
+      </Dropdown.Menu>
+    </Dropdown>
+  )
+}

+ 29 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-item-from-data.tsx

@@ -0,0 +1,29 @@
+import type { NavbarItemData } from '@/features/ui/components/types/navbar'
+import {
+  isDropdownItem,
+  isLinkItem,
+} from '@/features/ui/components/bootstrap-5/navbar/util'
+import NavDropdownFromData from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-from-data'
+import NavItem from '@/features/ui/components/bootstrap-5/navbar/nav-item'
+import { sendMB } from '@/infrastructure/event-tracking'
+import NavLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-link-item'
+
+export default function NavItemFromData({ item }: { item: NavbarItemData }) {
+  if (isDropdownItem(item)) {
+    return <NavDropdownFromData item={item} />
+  } else if (isLinkItem(item)) {
+    return (
+      <NavLinkItem
+        className={item.class}
+        href={item.url}
+        onClick={() => {
+          sendMB(item.event)
+        }}
+      >
+        {item.translatedText}
+      </NavLinkItem>
+    )
+  } else {
+    return <NavItem className={item.class}>{item.translatedText}</NavItem>
+  }
+}

+ 10 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-item.tsx

@@ -0,0 +1,10 @@
+import { Nav, NavItemProps } from 'react-bootstrap-5'
+
+export default function NavItem(props: Omit<NavItemProps, 'as'>) {
+  const { children, ...rest } = props
+  return (
+    <Nav.Item as="li" role="none" {...rest}>
+      {children}
+    </Nav.Item>
+  )
+}

+ 23 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/nav-link-item.tsx

@@ -0,0 +1,23 @@
+import { ReactNode } from 'react'
+import { Nav } from 'react-bootstrap-5'
+import NavItem from '@/features/ui/components/bootstrap-5/navbar/nav-item'
+
+export default function NavLinkItem({
+  href,
+  className,
+  onClick,
+  children,
+}: {
+  href: string
+  className?: string
+  onClick?: React.ComponentProps<typeof Nav.Link>['onClick']
+  children: ReactNode
+}) {
+  return (
+    <NavItem className={className}>
+      <Nav.Link role="menuitem" href={href} onClick={onClick}>
+        {children}
+      </Nav.Link>
+    </NavItem>
+  )
+}

+ 23 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/navbar/util.ts

@@ -0,0 +1,23 @@
+import type {
+  NavbarDropdownItem,
+  NavbarDropdownItemData,
+  NavbarDropdownLinkItem,
+  NavbarItemData,
+  NavbarLinkItemData,
+} from '@/features/ui/components/types/navbar'
+
+export function isDropdownLinkItem(
+  item: NavbarDropdownItem
+): item is NavbarDropdownLinkItem {
+  return 'url' in item
+}
+
+export function isDropdownItem(
+  item: NavbarItemData
+): item is NavbarDropdownItemData {
+  return 'dropdown' in item
+}
+
+export function isLinkItem(item: NavbarItemData): item is NavbarLinkItemData {
+  return 'url' in item
+}

+ 20 - 0
services/web/frontend/js/features/ui/components/types/default-navbar-metadata.ts

@@ -0,0 +1,20 @@
+import type {
+  NavbarItemData,
+  NavbarSessionUser,
+} from '@/features/ui/components/types/navbar'
+
+export type DefaultNavbarMetadata = {
+  customLogo?: string
+  title?: string
+  canDisplayAdminMenu: boolean
+  canDisplayAdminRedirect: boolean
+  canDisplaySplitTestMenu: boolean
+  canDisplaySurveyMenu: boolean
+  enableUpgradeButton: boolean
+  suppressNavbarRight: boolean
+  suppressNavContentLinks: boolean
+  showSubscriptionLink: boolean
+  sessionUser?: NavbarSessionUser
+  adminUrl?: string
+  items: NavbarItemData[]
+}

+ 1 - 0
services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts

@@ -55,6 +55,7 @@ export type DropdownMenuProps = PropsWithChildren<{
 
 export type DropdownDividerProps = PropsWithChildren<{
   as?: ElementType
+  className?: string
 }>
 
 export type DropdownHeaderProps = PropsWithChildren<{

+ 52 - 0
services/web/frontend/js/features/ui/components/types/navbar.ts

@@ -0,0 +1,52 @@
+export interface NavbarDropdownDivider {
+  divider: true
+}
+
+export interface NavbarDropdownContactUsItem {
+  isContactUs: true
+}
+
+export interface NavbarDropdownTextItem {
+  text: string
+  translatedText: string
+  class?: string
+}
+
+export interface NavbarDropdownLinkItem extends NavbarDropdownTextItem {
+  url: string
+  event: string
+  eventSegmentation?: Record<string, any>
+}
+
+export type NavbarDropdownItem =
+  | NavbarDropdownDivider
+  | NavbarDropdownContactUsItem
+  | NavbarDropdownTextItem
+  | NavbarDropdownLinkItem
+
+export type NavbarItemDropdownData = NavbarDropdownItem[]
+
+export interface NavbarTextItemData {
+  text: string
+  translatedText: string
+  only_when_logged_in?: boolean
+  only_when_logged_out?: boolean
+  only_content_pages?: boolean
+  class?: string
+}
+
+export interface NavbarDropdownItemData extends NavbarTextItemData {
+  dropdown: NavbarItemDropdownData
+}
+
+export interface NavbarLinkItemData extends NavbarTextItemData {
+  url: string
+  event: string
+}
+
+export type NavbarItemData =
+  | NavbarDropdownItemData
+  | NavbarLinkItemData
+  | NavbarTextItemData
+
+export type NavbarSessionUser = { email: string }

+ 2 - 2
services/web/frontend/js/features/utils/bootstrap-5.ts

@@ -1,7 +1,7 @@
 import getMeta from '@/utils/meta'
 
-// The reason this is a function to ensure that meta tag is read before any
-// isBootstrap5 check is performed
+// The reason this is a function is to ensure that the meta tag is read before
+// any isBootstrap5 check is performed
 export const isBootstrap5 = () => getMeta('ol-bootstrapVersion') === 5
 
 export const bsVersion = ({ bs5, bs3 }: { bs5?: string; bs3?: string }) => {

+ 1 - 0
services/web/frontend/js/marketing.js

@@ -9,3 +9,4 @@ import './features/multi-submit'
 import './features/cookie-banner'
 import './features/autoplay-video'
 import './features/mathjax'
+import './features/header-footer-react'

+ 1 - 1
services/web/frontend/js/pages/project-list.jsx

@@ -2,10 +2,10 @@ import './../utils/meta'
 import './../utils/webpack-public-path'
 import './../infrastructure/error-reporter'
 import './../i18n'
-import '../features/contact-form'
 import '../features/event-tracking'
 import '../features/cookie-banner'
 import '../features/link-helpers/slow-link'
+import '../features/header-footer-react'
 import ReactDOM from 'react-dom'
 import ProjectListRoot from '../features/project-list/components/project-list-root'
 

+ 2 - 0
services/web/frontend/js/utils/meta.ts

@@ -46,6 +46,7 @@ import { PasswordStrengthOptions } from '../../../types/password-strength-option
 import { Subscription as ProjectDashboardSubscription } from '../../../types/project/dashboard/subscription'
 import { ThirdPartyIds } from '../../../types/third-party-ids'
 import { Publisher } from '../../../types/subscription/dashboard/publisher'
+import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
 
 export interface Meta {
   'ol-ExposedSettings': ExposedSettings
@@ -135,6 +136,7 @@ export interface Meta {
   'ol-memberGroupSubscriptions': MemberGroupSubscription[]
   'ol-memberOfSSOEnabledGroups': GroupSSOLinkingStatus[]
   'ol-members': MinimalUser[]
+  'ol-navbar': DefaultNavbarMetadata
   'ol-no-single-dollar': boolean
   'ol-notifications': NotificationType[]
   'ol-notificationsInstitution': InstitutionType[]

+ 3 - 0
services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss

@@ -204,6 +204,9 @@ $navbar-toggler-padding-x: var(--spacing-05);
 $navbar-toggler-padding-y: var(--spacing-02);
 $navbar-toggler-border-radius: var(--border-radius-base);
 
+// Nav links
+$navbar-nav-link-padding-x: var(--navbar-btn-padding-h);
+
 // Spacing scale used by Bootstrap utilities
 $spacer: $spacing-06;
 $spacers: (

+ 12 - 3
services/web/frontend/stylesheets/bootstrap-5/components/navbar.scss

@@ -45,7 +45,7 @@
     > li {
       display: inline-flex;
 
-      > a,
+      > .nav-link,
       > .dropdown-toggle {
         display: block;
         color: var(--navbar-link-color);
@@ -69,7 +69,7 @@
         }
       }
 
-      &.subdued > a,
+      &.subdued > .nav-link,
       &.subdued > .dropdown-toggle {
         border: 0;
         color: var(--navbar-subdued-color);
@@ -87,6 +87,8 @@
   }
 
   .navbar-toggler {
+    --bs-navbar-toggler-padding-x: var(--spacing-04);
+
     color: var(--navbar-link-color);
     border-radius: var(--border-radius-base);
     border-width: 0;
@@ -97,6 +99,12 @@
       background-color: var(--navbar-toggler-expanded-bg);
       transition: 0.2s ease-in;
     }
+
+    & .material-symbols {
+      font-size: inherit;
+      font-weight: bold;
+      vertical-align: middle;
+    }
   }
 
   .navbar-collapse,
@@ -129,7 +137,8 @@
         > a,
         > .dropdown-toggle {
           margin: 0;
-          padding: var(--spacing-05) var(--navbar-padding-h);
+          padding-top: var(--spacing-05);
+          padding-bottom: var(--spacing-05);
           width: 100%;
           text-align: left;
           border-radius: 0;

+ 5 - 4
services/web/locales/en.json

@@ -674,11 +674,11 @@
   "footer_about_us": "About us",
   "footer_contact_us": "Contact us",
   "footer_navigation": "Footer navigation",
-  "footer_plans_and_pricing": "Plans &amp; pricing",
+  "footer_plans_and_pricing": "Plans & pricing",
   "for_business": "For business",
   "for_enterprise": "For enterprise",
   "for_groups_or_site_wide": "For groups or site-wide",
-  "for_individuals_and_groups": "For individuals &amp; groups",
+  "for_individuals_and_groups": "For individuals & groups",
   "for_large_institutions_and_organizations_need_sitewide_on_premise": "For large institutions and organizations that need site-wide access or an on-premises solution.",
   "for_more_information_see_managed_accounts_section": "For more information, see the \"Managed Accounts\" section in <0>our terms of use</0>, which you agree to by clicking Accept invitation.",
   "for_publishers": "For publishers",
@@ -1151,6 +1151,7 @@
   "lost_connection": "Lost Connection",
   "main_document": "Main document",
   "main_file_not_found": "Unknown main document",
+  "main_navigation": "Main navigation",
   "maintenance": "Maintenance",
   "make_a_copy": "Make a copy",
   "make_email_primary_description": "Make this the primary email, used to log in",
@@ -1443,7 +1444,7 @@
   "plan": "Plan",
   "plan_tooltip": "You’re on the __plan__ plan. Click to find out how to make the most of your Overleaf premium features.",
   "planned_maintenance": "Planned Maintenance",
-  "plans_amper_pricing": "Plans &amp; Pricing",
+  "plans_amper_pricing": "Plans & Pricing",
   "plans_and_pricing": "Plans and Pricing",
   "plans_and_pricing_lowercase": "plans and pricing",
   "please_ask_the_project_owner_to_upgrade_to_track_changes": "Please ask the project owner to upgrade to use track changes",
@@ -1486,7 +1487,7 @@
   "premium_plan_label": "You’re using <b>Overleaf Premium</b>",
   "presentation": "Presentation",
   "presentation_mode": "Presentation mode",
-  "press_and_awards": "Press &amp; awards",
+  "press_and_awards": "Press & awards",
   "previous_page": "Previous page",
   "price": "Price",
   "primarily_work_study_question": "Where do you primarily work or study?",