first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import { Dropdown } from 'react-bootstrap-5'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import type { NavbarSessionUser } from '@/features/ui/components/types/navbar'
import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item'
import NavDropdownDivider from './nav-dropdown-divider'
import NavDropdownLinkItem from './nav-dropdown-link-item'
import { useDsNavStyle } from '@/features/project-list/components/use-is-ds-nav'
import { SignOut } from '@phosphor-icons/react'
export function AccountMenuItems({
sessionUser,
showSubscriptionLink,
}: {
sessionUser: NavbarSessionUser
showSubscriptionLink: boolean
}) {
const { t } = useTranslation()
const logOutFormId = 'logOutForm'
const dsNavStyle = useDsNavStyle()
return (
<>
<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 />
<DropdownListItem>
{
// 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"
className="d-flex align-items-center justify-content-between"
>
<span>{t('log_out')}</span>
{dsNavStyle && <SignOut size={16} />}
</Dropdown.Item>
<form id={logOutFormId} method="POST" action="/logout">
<input type="hidden" name="_csrf" value={getMeta('ol-csrfToken')} />
</form>
</DropdownListItem>
</>
)
}

View File

@@ -0,0 +1,69 @@
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'
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
export default function AdminMenu({
canDisplayAdminMenu,
canDisplayAdminRedirect,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
canDisplayScriptLogMenu,
adminUrl,
}: Pick<
DefaultNavbarMetadata,
| 'canDisplayAdminMenu'
| 'canDisplayAdminRedirect'
| 'canDisplaySplitTestMenu'
| 'canDisplaySurveyMenu'
| 'canDisplayScriptLogMenu'
| 'adminUrl'
>) {
const sendProjectListMB = useSendProjectListMB()
return (
<NavDropdownMenu
title="Admin"
className="subdued"
onToggle={nextShow => {
if (nextShow) {
sendProjectListMB('menu-expand', {
item: 'admin',
location: 'top-menu',
})
}
}}
>
{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}
{canDisplayScriptLogMenu ? (
<NavDropdownLinkItem href="/admin/script-logs">
View Script Logs
</NavDropdownLinkItem>
) : null}
</NavDropdownMenu>
)
}

View File

@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'
import { DropdownItem } from 'react-bootstrap-5'
import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item'
import {
type ExtraSegmentations,
useSendProjectListMB,
} from '@/features/project-list/components/project-list-events'
export default function ContactUsItem({
showModal,
location,
}: {
showModal: (event?: Event) => void
location: ExtraSegmentations['menu-click']['location']
}) {
const { t } = useTranslation()
const sendMB = useSendProjectListMB()
return (
<DropdownListItem>
<DropdownItem
as="button"
role="menuitem"
onClick={() => {
sendMB('menu-click', { item: 'contact', location })
showModal()
}}
>
{t('contact_us')}
</DropdownItem>
</DropdownListItem>
)
}

View File

@@ -0,0 +1,145 @@
import { useState } from 'react'
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 LoggedOutItems from '@/features/ui/components/bootstrap-5/navbar/logged-out-items'
import HeaderLogoOrTitle from '@/features/ui/components/bootstrap-5/navbar/header-logo-or-title'
import MaterialIcon from '@/shared/components/material-icon'
import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
import { UserProvider } from '@/shared/context/user-context'
import { X } from '@phosphor-icons/react'
function DefaultNavbar(props: DefaultNavbarMetadata) {
const {
customLogo,
title,
canDisplayAdminMenu,
canDisplayAdminRedirect,
canDisplaySplitTestMenu,
canDisplaySurveyMenu,
canDisplayScriptLogMenu,
enableUpgradeButton,
suppressNavbarRight,
suppressNavContentLinks,
showCloseIcon = false,
showSubscriptionLink,
showSignUpLink,
sessionUser,
adminUrl,
items,
} = props
const { t } = useTranslation()
const { isReady } = useWaitForI18n()
const [expanded, setExpanded] = useState(false)
// The Contact Us modal is rendered at this level rather than inside the nav
// bar because otherwise the help wiki search results dropdown doesn't show up
const { modal: contactUsModal, showModal: showContactUsModal } =
useContactUsModal({
autofillProjectUrl: false,
})
if (!isReady) {
return null
}
return (
<>
<Navbar
className="navbar-default navbar-main"
expand="lg"
onToggle={expanded => setExpanded(expanded)}
>
<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')}
>
{showCloseIcon && expanded ? (
<X />
) : (
<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}
canDisplayScriptLogMenu={canDisplayScriptLogMenu}
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}
showContactUsModal={showContactUsModal}
/>
) : null
})}
{sessionUser ? (
<LoggedInItems
sessionUser={sessionUser}
showSubscriptionLink={showSubscriptionLink}
/>
) : (
<LoggedOutItems showSignUpLink={showSignUpLink} />
)}
</Nav>
</Navbar.Collapse>
</>
)}
</Container>
</Navbar>
<UserProvider>{contactUsModal}</UserProvider>
</>
)
}
export default DefaultNavbar

View File

@@ -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" />
)
}
}

View File

@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next'
import NavDropdownMenu from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-menu'
import type { NavbarSessionUser } from '@/features/ui/components/types/navbar'
import NavLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-link-item'
import { AccountMenuItems } from './account-menu-items'
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
export default function LoggedInItems({
sessionUser,
showSubscriptionLink,
}: {
sessionUser: NavbarSessionUser
showSubscriptionLink: boolean
}) {
const { t } = useTranslation()
const sendProjectListMB = useSendProjectListMB()
return (
<>
<NavLinkItem href="/project" className="nav-item-projects">
{t('projects')}
</NavLinkItem>
<NavDropdownMenu
title={t('Account')}
className="nav-item-account"
onToggle={nextShow => {
if (nextShow) {
sendProjectListMB('menu-expand', {
item: 'account',
location: 'top-menu',
})
}
}}
>
<AccountMenuItems
sessionUser={sessionUser}
showSubscriptionLink={showSubscriptionLink}
/>
</NavDropdownMenu>
</>
)
}

View File

@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next'
import NavLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-link-item'
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
export default function LoggedOutItems({
showSignUpLink,
}: {
showSignUpLink: boolean
}) {
const { t } = useTranslation()
const sendMB = useSendProjectListMB()
return (
<>
{showSignUpLink ? (
<NavLinkItem
href="/register"
className="primary nav-account-item"
onClick={() => {
sendMB('menu-click', { item: 'register', location: 'top-menu' })
}}
>
{t('sign_up')}
</NavLinkItem>
) : null}
<NavLinkItem
href="/login"
className="nav-account-item"
onClick={() => {
sendMB('menu-click', { item: 'login', location: 'top-menu' })
}}
>
{t('log_in')}
</NavLinkItem>
</>
)
}

View File

@@ -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" />
}

View File

@@ -0,0 +1,95 @@
import type {
NavbarDropdownItemData,
NavbarItemDropdownData,
} from '@/features/ui/components/types/navbar'
import NavDropdownDivider from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-divider'
import { isDropdownLinkItem } from '@/features/ui/components/bootstrap-5/navbar/util'
import NavDropdownLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-link-item'
import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-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'
import {
type ExtraSegmentations,
useSendProjectListMB,
} from '@/features/project-list/components/project-list-events'
export default function NavDropdownFromData({
item,
showContactUsModal,
}: {
item: NavbarDropdownItemData
showContactUsModal: (event?: Event) => void
}) {
const sendProjectListMB = useSendProjectListMB()
return (
<NavDropdownMenu
title={item.translatedText}
className={item.class}
onToggle={nextShow => {
if (nextShow) {
sendProjectListMB('menu-expand', {
item: item.trackingKey,
location: 'top-menu',
})
}
}}
>
<NavDropdownMenuItems
dropdown={item.dropdown}
showContactUsModal={showContactUsModal}
location="top-menu"
/>
</NavDropdownMenu>
)
}
export function NavDropdownMenuItems({
dropdown,
showContactUsModal,
location,
}: {
dropdown: NavbarItemDropdownData
showContactUsModal: (event?: Event) => void
location: ExtraSegmentations['menu-expand']['location']
}) {
const sendProjectListMB = useSendProjectListMB()
return (
<>
{dropdown.map((child, index) => {
if ('divider' in child) {
return <NavDropdownDivider key={index} />
} else if ('isContactUs' in child) {
return (
<ContactUsItem
key={index}
showModal={showContactUsModal}
location={location}
/>
)
} else if (isDropdownLinkItem(child)) {
return (
<NavDropdownLinkItem
key={index}
href={child.url}
onClick={() => {
sendProjectListMB('menu-click', {
item: child.trackingKey as ExtraSegmentations['menu-click']['item'],
location,
destinationURL: child.url,
})
}}
>
{child.translatedText}
</NavDropdownLinkItem>
)
} else {
return (
<DropdownListItem key={index}>
{child.translatedText}
</DropdownListItem>
)
}
})}
</>
)
}

View File

@@ -0,0 +1,22 @@
import { ReactNode } from 'react'
import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-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 (
<DropdownListItem>
<DropdownItem href={href} role="menuitem" onClick={onClick}>
{children}
</DropdownItem>
</DropdownListItem>
)
}

View File

@@ -0,0 +1,41 @@
import { type ReactNode, useState } from 'react'
import { Dropdown } from 'react-bootstrap-5'
import { CaretUp, CaretDown } from '@phosphor-icons/react'
import { useDsNavStyle } from '@/features/project-list/components/use-is-ds-nav'
export default function NavDropdownMenu({
title,
className,
children,
onToggle,
}: {
title: string
className?: string
children: ReactNode
onToggle?: (nextShow: boolean) => void
}) {
const [show, setShow] = useState(false)
const dsNavStyle = useDsNavStyle()
// Can't use a NavDropdown here because it's impossible to render the menu as
// a <ul> element using NavDropdown
const Caret = show ? CaretUp : CaretDown
return (
<Dropdown
as="li"
role="none"
className={className}
onToggle={nextShow => {
setShow(nextShow)
onToggle?.(nextShow)
}}
>
<Dropdown.Toggle role="menuitem">
{title}
{dsNavStyle && <Caret weight="bold" className="ms-2" />}
</Dropdown.Toggle>
<Dropdown.Menu as="ul" role="menu" align="end">
{children}
</Dropdown.Menu>
</Dropdown>
)
}

View File

@@ -0,0 +1,45 @@
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 NavLinkItem from '@/features/ui/components/bootstrap-5/navbar/nav-link-item'
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
export default function NavItemFromData({
item,
showContactUsModal,
}: {
item: NavbarItemData
showContactUsModal: (event?: Event) => void
}) {
const sendProjectListMB = useSendProjectListMB()
if (isDropdownItem(item)) {
return (
<NavDropdownFromData
item={item}
showContactUsModal={showContactUsModal}
/>
)
} else if (isLinkItem(item)) {
return (
<NavLinkItem
className={item.class}
href={item.url}
onClick={() => {
sendProjectListMB('menu-click', {
item: item.trackingKey as any,
location: 'top-menu',
destinationURL: item.url,
})
}}
>
{item.translatedText}
</NavLinkItem>
)
} else {
return <NavItem className={item.class}>{item.translatedText}</NavItem>
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}