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,156 @@
import { useCallback, useEffect, useRef } from 'react'
import { callFnsInSequence } from '../../utils/functions'
import { MergeAndOverride } from '../../../../types/utils'
type AutoExpandingTextAreaProps = MergeAndOverride<
React.ComponentProps<'textarea'>,
{
onResize?: () => void
onAutoFocus?: (textarea: HTMLTextAreaElement) => void
}
>
function AutoExpandingTextArea({
onChange,
onResize,
autoFocus,
onAutoFocus,
...rest
}: AutoExpandingTextAreaProps) {
const ref = useRef<HTMLTextAreaElement>(null)
const previousHeightRef = useRef<number | null>(null)
const previousMeasurementRef = useRef<{
heightAdjustment: number
value: string
} | null>(null)
const resetHeight = useCallback(() => {
const el = ref.current
if (!el) {
return
}
const { value } = el
const previousMeasurement = previousMeasurementRef.current
// Do nothing if the textarea value hasn't changed since the last reset
if (previousMeasurement !== null && value === previousMeasurement.value) {
return
}
let heightAdjustment
if (previousMeasurement === null) {
const computedStyle = window.getComputedStyle(el)
heightAdjustment =
computedStyle.boxSizing === 'border-box'
? Math.ceil(
parseFloat(computedStyle.borderTopWidth) +
parseFloat(computedStyle.borderBottomWidth)
)
: -Math.floor(
parseFloat(computedStyle.paddingTop) +
parseFloat(computedStyle.paddingBottom)
)
} else {
heightAdjustment = previousMeasurement.heightAdjustment
}
const curHeight = el.clientHeight
const fitHeight = el.scrollHeight
// Clear height if text area is empty
if (value === '') {
el.style.removeProperty('height')
}
// Otherwise, expand to fit text
else if (fitHeight > curHeight) {
el.style.height = fitHeight + heightAdjustment + 'px'
}
previousMeasurementRef.current = { heightAdjustment, value }
}, [])
useEffect(() => {
if (!ref.current || !onResize || !('ResizeObserver' in window)) {
return
}
const resizeObserver = new ResizeObserver(() => {
if (!ref.current) {
return
}
const newHeight = ref.current.offsetHeight
// Ignore the resize when the height of the element is less than or equal to 0
if (newHeight <= 0) {
return
}
const heightChanged = newHeight !== previousHeightRef.current
previousHeightRef.current = newHeight
if (heightChanged) {
// Prevent errors like "ResizeObserver loop completed with undelivered
// notifications" that occur if onResize triggers another repaint. The
// cost of this is that onResize lags one frame behind, but it's
// unlikely to matter.
// Wrap onResize to prevent extra parameters being passed
window.requestAnimationFrame(() => onResize())
}
})
resizeObserver.observe(ref.current)
return () => {
resizeObserver.disconnect()
}
}, [onResize])
// Maintain a copy onAutoFocus in a ref for use in the autofocus effect
// below so that the effect doesn't run when onAutoFocus changes
const onAutoFocusRef = useRef(onAutoFocus)
useEffect(() => {
onAutoFocusRef.current = onAutoFocus
}, [onAutoFocus])
// Implement autofocus manually so that the cursor is placed at the end of
// the textarea content
useEffect(() => {
const el = ref.current
if (!el) {
return
}
resetHeight()
if (autoFocus) {
const cursorPos = el.value.length
const timer = window.setTimeout(() => {
el.focus()
el.setSelectionRange(cursorPos, cursorPos)
if (onAutoFocusRef.current) {
onAutoFocusRef.current(el)
}
}, 100)
return () => {
window.clearTimeout(timer)
}
}
}, [autoFocus, resetHeight])
// Reset height when the value changes via the `value` prop. If the textarea
// is controlled, this means resetHeight is called twice per keypress, but
// this is mitigated by a check on whether the value has actually changed in
// resetHeight()
useEffect(() => {
resetHeight()
}, [rest.value, resetHeight])
return (
<textarea
onChange={callFnsInSequence(onChange, resetHeight)}
{...rest}
ref={ref}
/>
)
}
export default AutoExpandingTextArea

View File

@@ -0,0 +1,34 @@
import type { FC } from 'react'
import MaterialIcon from '@/shared/components/material-icon'
import OLBadge from '@/features/ui/components/ol/ol-badge'
const BetaBadgeIcon: FC<{
phase?: string
}> = ({ phase = 'beta' }) => {
const badgeClass = chooseBadgeClass(phase)
if (badgeClass === 'info-badge') {
return <MaterialIcon type="info" className="align-middle info-badge" />
} else if (badgeClass === 'alpha-badge') {
return (
<OLBadge bg="primary" className="alpha-badge">
α
</OLBadge>
)
} else {
return <OLBadge bg="warning">β</OLBadge>
}
}
function chooseBadgeClass(phase?: string) {
switch (phase) {
case 'release':
return 'info-badge'
case 'alpha':
return 'alpha-badge'
case 'beta':
default:
return 'beta-badge'
}
}
export default BetaBadgeIcon

View File

@@ -0,0 +1,64 @@
import type { FC, MouseEventHandler, ReactNode } from 'react'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import BetaBadgeIcon from '@/shared/components/beta-badge-icon'
type TooltipProps = {
id: string
text: ReactNode
className?: string
placement?: NonNullable<
React.ComponentProps<typeof OLTooltip>['overlayProps']
>['placement']
}
type LinkProps = {
href?: string
ref?: React.Ref<HTMLAnchorElement>
className?: string
onMouseDown?: MouseEventHandler<HTMLAnchorElement>
}
const defaultHref = '/beta/participate'
const BetaBadge: FC<{
tooltip?: TooltipProps
link?: LinkProps
description?: ReactNode
phase?: string
}> = ({
tooltip,
link = { href: defaultHref },
description,
phase = 'beta',
}) => {
const { href, ...linkProps } = link
const linkedBadge = (
<a
target="_blank"
rel="noopener noreferrer"
href={href || defaultHref}
{...linkProps}
>
<span className="visually-hidden">{description || tooltip?.text}</span>
<BetaBadgeIcon phase={phase} />
</a>
)
return tooltip ? (
<OLTooltip
id={tooltip.id}
description={tooltip.text}
tooltipProps={{ className: tooltip.className }}
overlayProps={{
placement: tooltip.placement || 'bottom',
delay: 100,
}}
>
{linkedBadge}
</OLTooltip>
) : (
linkedBadge
)
}
export default BetaBadge

View File

@@ -0,0 +1,75 @@
import { useRef, useEffect } from 'react'
import PolymorphicComponent, {
PolymorphicComponentProps,
} from '@/shared/components/polymorphic-component'
import { MergeAndOverride } from '../../../../types/utils'
// Performs a click event on elements that has been clicked,
// but when releasing the mouse button are no longer hovered
// by the cursor (which by default cancels the event).
type ClickableElementEnhancerOwnProps = {
onClick: () => void
onMouseDown?: (e: React.MouseEvent) => void
offset?: number
}
type ClickableElementEnhancerProps<E extends React.ElementType> =
MergeAndOverride<
PolymorphicComponentProps<E>,
ClickableElementEnhancerOwnProps
>
function ClickableElementEnhancer<E extends React.ElementType>({
onClick,
onMouseDown,
offset = 50, // the offset around the clicked element which should still trigger the click
...rest
}: ClickableElementEnhancerProps<E>) {
const isClickedRef = useRef(false)
const elRectRef = useRef<DOMRect>()
const restProps = rest as PolymorphicComponentProps<E>
const handleMouseDown = (e: React.MouseEvent) => {
isClickedRef.current = true
elRectRef.current = (e.target as HTMLElement).getBoundingClientRect()
onMouseDown?.(e)
}
useEffect(() => {
const handleMouseUp = (e: MouseEvent) => {
if (isClickedRef.current) {
isClickedRef.current = false
if (!elRectRef.current) {
return
}
const halfWidth = elRectRef.current.width / 2
const halfHeight = elRectRef.current.height / 2
const centerX = elRectRef.current.x + halfWidth
const centerY = elRectRef.current.y + halfHeight
const deltaX = Math.abs(e.clientX - centerX)
const deltaY = Math.abs(e.clientY - centerY)
// Check if the mouse has moved significantly from the element position
if (deltaX < halfWidth + offset && deltaY < halfHeight + offset) {
// If the mouse hasn't moved much, consider it a click
onClick()
}
}
}
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mouseup', handleMouseUp)
}
}, [onClick, offset])
return <PolymorphicComponent onMouseDown={handleMouseDown} {...restProps} />
}
export default ClickableElementEnhancer

View File

@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
type CloseProps = {
onDismiss: React.MouseEventHandler<HTMLButtonElement>
variant?: 'light' | 'dark'
}
function Close({ onDismiss, variant = 'light' }: CloseProps) {
const { t } = useTranslation()
return (
<button
type="button"
className={`close pull-right ${variant}`}
onClick={onDismiss}
>
<MaterialIcon
type="close"
className="align-text-bottom"
accessibilityLabel={t('close')}
/>
<span className="sr-only">{t('close')}</span>
</button>
)
}
export default Close

View File

@@ -0,0 +1,21 @@
import MaterialIcon from '@/shared/components/material-icon'
import { Dispatch, FC, SetStateAction } from 'react'
export const CollapsibleFileHeader: FC<{
name: string
count: number
collapsed: boolean
toggleCollapsed: Dispatch<SetStateAction<any>>
}> = ({ name, count, collapsed, toggleCollapsed }) => (
<button
type="button"
className="collapsible-file-header"
onClick={toggleCollapsed}
>
<MaterialIcon
type={collapsed ? 'keyboard_arrow_right' : 'keyboard_arrow_down'}
/>
{name}
<div className="collapsible-file-header-count">{count}</div>
</button>
)

View File

@@ -0,0 +1,57 @@
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
export const CopyToClipboard = memo<{
content: string
tooltipId: string
kind?: 'text' | 'icon'
}>(({ content, tooltipId, kind = 'icon' }) => {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const handleClick = useCallback(() => {
navigator.clipboard.writeText(content).then(() => {
setCopied(true)
window.setTimeout(() => {
setCopied(false)
}, 1500)
})
}, [content])
if (!navigator.clipboard?.writeText) {
return null
}
return (
<OLTooltip
id={tooltipId}
description={copied ? `${t('copied')}!` : t('copy')}
overlayProps={{ delay: copied ? 1000 : 250 }}
>
{kind === 'text' ? (
<OLButton
onClick={handleClick}
size="sm"
variant="secondary"
className="copy-button"
>
{t('copy')}
</OLButton>
) : (
<OLIconButton
onClick={handleClick}
variant="link"
size="sm"
accessibilityLabel={t('copy')}
className="copy-button"
icon={copied ? 'check' : 'content_copy'}
/>
)}
</OLTooltip>
)
})
CopyToClipboard.displayName = 'CopyToClipboard'

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next'
type DefaultMessageProps = {
className?: string
style?: React.CSSProperties
}
export function DefaultMessage({ className, style }: DefaultMessageProps) {
const { t } = useTranslation()
return (
<>
<span style={style}>{`${t('generic_something_went_wrong')}. `}</span>
<span className={className}>{`${t('please_refresh')}`}</span>
</>
)
}

View File

@@ -0,0 +1,31 @@
import { FC } from 'react'
import Notification from '@/shared/components/notification'
import { Trans, useTranslation } from 'react-i18next'
import Bowser from 'bowser'
export const isDeprecatedBrowser = () => {
const parser = Bowser.getParser(window.navigator.userAgent)
return parser.satisfies({
safari: '~15',
})
}
export const DeprecatedBrowser: FC = () => {
const { t } = useTranslation()
return (
<Notification
type="warning"
title={t('support_for_your_browser_is_ending_soon')}
content={
<Trans
i18nKey="to_continue_using_upgrade_or_change_your_browser"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content,react/jsx-key
<a href="/learn/how-to/Which_browsers_does_Overleaf_support%3F" />,
]}
/>
}
/>
)
}

View File

@@ -0,0 +1,15 @@
import { FC, ReactNode } from 'react'
import { DefaultMessage } from './default-message'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export const ErrorBoundaryFallback: FC<{ modal?: ReactNode }> = ({
children,
modal,
}) => {
return (
<div className="error-boundary-alert">
<OLNotification type="error" content={children || <DefaultMessage />} />
{modal}
</div>
)
}

View File

@@ -0,0 +1,25 @@
import BetaBadge from './beta-badge'
import { FC, ReactNode, useMemo } from 'react'
export const FeedbackBadge: FC<{
url: string
id: string
text?: ReactNode
}> = ({ url, id, text }) => {
const tooltip = useMemo(() => {
return {
id: `${id}-tooltip`,
text: text || <DefaultContent />,
}
}, [id, text])
return <BetaBadge tooltip={tooltip} phase="release" link={{ href: url }} />
}
const DefaultContent = () => (
<>
We are testing this new feature.
<br />
Click to give feedback
</>
)

View File

@@ -0,0 +1,8 @@
import { memo } from 'react'
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
export const FormatTimeBasedOnYear = memo<{ date: string | number | Date }>(
function FormatTimeBasedOnYear({ date }) {
return <>{formatTimeBasedOnYear(date)}</>
}
)

View File

@@ -0,0 +1,31 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from '../hooks/use-location'
import { DefaultMessage } from './default-message'
import MaterialIcon from './material-icon'
import OLButton from '@/features/ui/components/ol/ol-button'
export const GenericErrorBoundaryFallback: FC = ({ children }) => {
const { t } = useTranslation()
const { reload: handleClick } = useLocation()
return (
<div className="error-boundary-container">
<MaterialIcon
accessibilityLabel={`${t('generic_something_went_wrong')} ${t(
'please_refresh'
)}`}
type="warning"
size="2x"
/>
{children || (
<div className="error-message">
<DefaultMessage className="small" style={{ fontWeight: 'bold' }} />
</div>
)}
<OLButton variant="primary" onClick={handleClick}>
{t('refresh')}
</OLButton>
</div>
)
}

View File

@@ -0,0 +1,7 @@
// window.history-related functions in a separate module so they can be mocked/stubbed in tests
export const history = {
pushState(data: any, unused: string, url?: string | URL | null) {
window.history.pushState(data, unused, url)
},
}

View File

@@ -0,0 +1,10 @@
import { useTranslation } from 'react-i18next'
import Icon from './icon'
function IconChecked() {
const { t } = useTranslation()
return <Icon type="check" fw accessibilityLabel={t('selected')} />
}
export default IconChecked

View File

@@ -0,0 +1,44 @@
import classNames from 'classnames'
type IconOwnProps = {
type: string
spin?: boolean
fw?: boolean
modifier?: string
accessibilityLabel?: string
}
export type IconProps = IconOwnProps &
Omit<React.ComponentProps<'i'>, keyof IconOwnProps>
function Icon({
type,
spin,
fw,
modifier,
className = '',
accessibilityLabel,
...rest
}: IconProps) {
const iconClassName = classNames(
'fa',
`fa-${type}`,
{
'fa-spin': spin,
'fa-fw': fw,
[`fa-${modifier}`]: modifier,
},
className
)
return (
<>
<i className={iconClassName} aria-hidden="true" {...rest} />
{accessibilityLabel && (
<span className="visually-hidden">{accessibilityLabel}</span>
)}
</>
)
}
export default Icon

View File

@@ -0,0 +1,26 @@
import classNames from 'classnames'
import overleafLogo from '@/shared/svgs/overleaf.svg'
type InterstitialProps = {
className?: string
contentClassName?: string
children: React.ReactNode
showLogo: boolean
title?: string
}
export function Interstitial({
className,
contentClassName,
children,
showLogo,
title,
}: InterstitialProps) {
return (
<div className={classNames('interstitial', className)}>
{showLogo && <img className="logo" src={overleafLogo} alt="Overleaf" />}
{title && <h1 className="h3 interstitial-header">{title}</h1>}
<div className={classNames(contentClassName)}>{children}</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
import { ReactNode, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import OLBadge from '@/features/ui/components/ol/ol-badge'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { postJSON } from '@/infrastructure/fetch-json'
import OLButton from '@/features/ui/components/ol/ol-button'
import getMeta from '@/utils/meta'
type IntegrationLinkingWidgetProps = {
logo: ReactNode
title: string
description: string
helpPath?: string
labsEnabled?: boolean
experimentName: string
setErrorMessage: (message: string) => void
optedIn: boolean
setOptedIn: (optedIn: boolean) => void
}
export function LabsExperimentWidget({
logo,
title,
description,
helpPath,
labsEnabled,
experimentName,
setErrorMessage,
optedIn,
setOptedIn,
}: IntegrationLinkingWidgetProps) {
const { t } = useTranslation()
const experimentsErrorMessage = t(
'we_are_unable_to_opt_you_into_this_experiment'
)
const allowedExperiments = getMeta('ol-allowedExperiments')
const disabled = !allowedExperiments.includes(experimentName) && !optedIn
const handleEnable = useCallback(async () => {
try {
const enablePath = `/labs/participate/experiments/${experimentName}/opt-in`
await postJSON(enablePath)
setOptedIn(true)
} catch (err) {
setErrorMessage(experimentsErrorMessage)
}
}, [experimentName, setErrorMessage, experimentsErrorMessage, setOptedIn])
const handleDisable = useCallback(async () => {
try {
const disablePath = `/labs/participate/experiments/${experimentName}/opt-out`
await postJSON(disablePath)
setOptedIn(false)
} catch (err) {
setErrorMessage(experimentsErrorMessage)
}
}, [experimentName, setErrorMessage, experimentsErrorMessage, setOptedIn])
return (
<div
className={`labs-experiment-widget-container ${disabled ? 'disabled-experiment' : ''}`}
>
<div className="experiment-logo-container">{logo}</div>
<div className="description-container">
<div className="title-row">
<h3 className="h4">{title}</h3>
{optedIn && <OLBadge bg="info">{t('enabled')}</OLBadge>}
</div>
<p className="small">
{description}{' '}
{helpPath && (
<a href={helpPath} target="_blank" rel="noreferrer">
{t('learn_more')}
</a>
)}
</p>
</div>
{disabled && (
<div className="disabled-explanation">{t('experiment_full')}</div>
)}
<div>
{labsEnabled && (
<ActionButton
optedIn={optedIn}
handleDisable={handleDisable}
handleEnable={handleEnable}
disabled={disabled}
/>
)}
</div>
</div>
)
}
type ActionButtonProps = {
optedIn?: boolean
disabled?: boolean
handleEnable: () => void
handleDisable: () => void
}
function ActionButton({
optedIn,
disabled,
handleEnable,
handleDisable,
}: ActionButtonProps) {
const { t } = useTranslation()
if (optedIn) {
return (
<OLButton variant="secondary" onClick={handleDisable}>
{t('turn_off')}
</OLButton>
)
} else if (disabled) {
const tooltipableButton = (
<div className="d-inline-block">
<OLButton variant="primary" disabled>
{t('turn_on')}
</OLButton>
</div>
)
return (
<OLTooltip
id="experiment-disabled"
description={t('this_experiment_isnt_accepting_new_participants')}
overlayProps={{ delay: 0 }}
>
{tooltipableButton}
</OLTooltip>
)
} else {
return (
<OLButton variant="primary" onClick={handleEnable}>
{t('turn_on')}
</OLButton>
)
}
}
export default LabsExperimentWidget

View File

@@ -0,0 +1,37 @@
type LoadingBrandedTypes = {
loadProgress: number // Percentage
label?: string
hasError?: boolean
}
export default function LoadingBranded({
loadProgress,
label,
hasError = false,
}: LoadingBrandedTypes) {
return (
<>
<div className="loading-screen-brand-container">
<div
className="loading-screen-brand"
style={{ height: `${loadProgress}%` }}
/>
</div>
{!hasError && (
<div className="h3 loading-screen-label" aria-live="polite">
{label}
<span className="loading-screen-ellip" aria-hidden="true">
.
</span>
<span className="loading-screen-ellip" aria-hidden="true">
.
</span>
<span className="loading-screen-ellip" aria-hidden="true">
.
</span>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import OLSpinner, {
OLSpinnerSize,
} from '@/features/ui/components/ol/ol-spinner'
import classNames from 'classnames'
function LoadingSpinner({
align,
delay = 0,
loadingText,
size = 'sm',
className,
}: {
align?: 'left' | 'center'
delay?: 0 | 500 // 500 is our standard delay
loadingText?: string
size?: OLSpinnerSize
className?: string
}) {
const { t } = useTranslation()
const [show, setShow] = useState(false)
useEffect(() => {
// Ensure that spinner is displayed immediately if delay is 0
if (delay === 0) {
setShow(true)
return
}
const timer = window.setTimeout(() => {
setShow(true)
}, delay)
return () => {
window.clearTimeout(timer)
}
}, [delay])
if (!show) {
return null
}
return (
<div
className={classNames(
'loading',
className,
align === 'left' ? 'align-items-start' : 'align-items-center'
)}
>
<OLSpinner size={size} />
&nbsp;
{loadingText || `${t('loading')}`}
</div>
)
}
export default LoadingSpinner
export function FullSizeLoadingSpinner({
delay = 0,
minHeight,
loadingText,
size = 'sm',
className,
}: {
delay?: 0 | 500
minHeight?: string
loadingText?: string
size?: OLSpinnerSize
className?: string
}) {
return (
<div
className={classNames('full-size-loading-spinner-container', className)}
style={{ minHeight }}
>
<LoadingSpinner size={size} loadingText={loadingText} delay={delay} />
</div>
)
}

View File

@@ -0,0 +1,27 @@
// window location-related functions in a separate module so they can be mocked/stubbed in tests
export const location = {
get href() {
// eslint-disable-next-line no-restricted-syntax
return window.location.href
},
assign(url) {
// eslint-disable-next-line no-restricted-syntax
window.location.assign(url)
},
replace(url) {
// eslint-disable-next-line no-restricted-syntax
window.location.replace(url)
},
reload() {
// eslint-disable-next-line no-restricted-syntax
window.location.reload()
},
setHash(hash) {
window.location.hash = hash
},
toString() {
// eslint-disable-next-line no-restricted-syntax
return window.location.toString()
},
}

View File

@@ -0,0 +1,51 @@
import classNames from 'classnames'
import React from 'react'
import unfilledIconTypes from '../../../fonts/material-symbols/unfilled-symbols.mjs'
export type AvailableUnfilledIcon = (typeof unfilledIconTypes)[number]
type BaseIconProps = React.ComponentProps<'i'> & {
accessibilityLabel?: string
modifier?: string
size?: '2x'
}
type FilledIconProps = BaseIconProps & {
type: string
unfilled?: false
}
type UnfilledIconProps = BaseIconProps & {
type: AvailableUnfilledIcon
unfilled: true
}
type IconProps = FilledIconProps | UnfilledIconProps
function MaterialIcon({
type,
className,
accessibilityLabel,
modifier,
size,
unfilled,
...rest
}: IconProps) {
const iconClassName = classNames('material-symbols', className, modifier, {
[`size-${size}`]: size,
unfilled,
})
return (
<>
<span className={iconClassName} aria-hidden="true" {...rest}>
{type}
</span>
{accessibilityLabel && (
<span className="visually-hidden">{accessibilityLabel}</span>
)}
</>
)
}
export default MaterialIcon

View File

@@ -0,0 +1,145 @@
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { FC, forwardRef, useCallback } from 'react'
import classNames from 'classnames'
import { useNestableDropdown } from '@/shared/hooks/use-nestable-dropdown'
import { NestableDropdownContextProvider } from '@/shared/context/nestable-dropdown-context'
import { AnchorProps } from 'react-bootstrap-5'
import MaterialIcon from '../material-icon'
import { DropdownMenuProps } from '@/features/ui/components/types/dropdown-menu-props'
type MenuBarDropdownProps = {
title: string
id: string
className?: string
align?: 'start' | 'end'
}
export const MenuBarDropdown: FC<MenuBarDropdownProps> = ({
title,
children,
id,
className,
align = 'start',
}) => {
const { menuId, selected, setSelected } = useNestableDropdown()
const onToggle = useCallback(
show => {
setSelected(show ? id : null)
},
[id, setSelected]
)
const onHover = useCallback(() => {
setSelected(prev => {
if (prev === null) {
return null
}
return id
})
}, [id, setSelected])
return (
<Dropdown
show={selected === id}
align={align}
onToggle={onToggle}
autoClose
>
<DropdownToggle
id={`${menuId}-${id}`}
variant="secondary"
className={classNames(className, 'menu-bar-toggle')}
onMouseEnter={onHover}
>
{title}
</DropdownToggle>
<NestableDropdownMenu renderOnMount id={`${menuId}-${id}`}>
{children}
</NestableDropdownMenu>
</Dropdown>
)
}
const NestableDropdownMenu: FC<DropdownMenuProps & { id: string }> = ({
children,
id,
...props
}) => {
return (
<DropdownMenu {...props}>
<NestableDropdownContextProvider id={id}>
{children}
</NestableDropdownContextProvider>
</DropdownMenu>
)
}
const NestedDropdownToggle: FC = forwardRef<HTMLAnchorElement, AnchorProps>(
function NestedDropdownToggle(
{ children, className, onMouseEnter, id },
ref
) {
return (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a
id={id}
href="#"
ref={ref}
onMouseEnter={onMouseEnter}
onClick={onMouseEnter}
className={classNames(
className,
'nested-dropdown-toggle',
'dropdown-item'
)}
>
{children}
<MaterialIcon type="chevron_right" />
</a>
)
}
)
export const NestedMenuBarDropdown: FC<{ id: string; title: string }> = ({
children,
id,
title,
}) => {
const { menuId, selected, setSelected } = useNestableDropdown()
const select = useCallback(() => {
setSelected(id)
}, [id, setSelected])
const onToggle = useCallback(
show => {
setSelected(show ? id : null)
},
[setSelected, id]
)
const active = selected === id
return (
<Dropdown
align="start"
drop="end"
show={active}
autoClose
onToggle={onToggle}
>
<DropdownToggle
id={`${menuId}-${id}`}
onMouseEnter={select}
className={classNames({ 'nested-dropdown-toggle-shown': active })}
as={NestedDropdownToggle}
>
{title}
</DropdownToggle>
<NestableDropdownMenu renderOnMount id={`${menuId}-${id}`}>
{children}
</NestableDropdownMenu>
</Dropdown>
)
}

View File

@@ -0,0 +1,41 @@
import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { useNestableDropdown } from '@/shared/hooks/use-nestable-dropdown'
import { MouseEventHandler, ReactNode } from 'react'
type MenuBarOptionProps = {
title: string
onClick?: MouseEventHandler
disabled?: boolean
trailingIcon?: ReactNode
href?: string
target?: string
rel?: string
}
export const MenuBarOption = ({
title,
onClick,
href,
disabled,
trailingIcon,
target,
rel,
}: MenuBarOptionProps) => {
const { setSelected } = useNestableDropdown()
return (
<DropdownListItem>
<DropdownItem
onMouseEnter={() => setSelected(null)}
onClick={onClick}
disabled={disabled}
trailingIcon={trailingIcon}
href={href}
rel={rel}
target={target}
>
{title}
</DropdownItem>
</DropdownListItem>
)
}

View File

@@ -0,0 +1,16 @@
import { NestableDropdownContextProvider } from '@/shared/context/nestable-dropdown-context'
import { FC, HTMLProps } from 'react'
export const MenuBar: FC<HTMLProps<HTMLDivElement> & { id: string }> = ({
children,
id,
...props
}) => {
return (
<div {...props}>
<NestableDropdownContextProvider id={id}>
{children}
</NestableDropdownContextProvider>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import Notification, {
NotificationProps,
} from '@/shared/components/notification'
import { useEffect } from 'react'
function elementIsInView(el: HTMLElement) {
const scroll = window.scrollY
const boundsTop = el.getBoundingClientRect().top + scroll
const viewport = {
top: scroll,
bottom: scroll + window.innerHeight,
}
const bounds = {
top: boundsTop,
bottom: boundsTop + el.clientHeight,
}
return (
(bounds.bottom >= viewport.top && bounds.bottom <= viewport.bottom) ||
(bounds.top <= viewport.bottom && bounds.top >= viewport.top)
)
}
function NotificationScrolledTo({ ...props }: NotificationProps) {
useEffect(() => {
if (props.id) {
const alert = document.getElementById(props.id)
if (alert && !elementIsInView(alert)) {
alert.scrollIntoView({ behavior: 'smooth' })
}
}
}, [props])
const notificationProps = { ...props }
if (!notificationProps.className) {
notificationProps.className = ''
}
notificationProps.className = `${notificationProps.className} notification-with-scroll-margin`
return (
<div className="notification-list">
<Notification {...notificationProps} />
</div>
)
}
export default NotificationScrolledTo

View File

@@ -0,0 +1,126 @@
// to be kept in sync with app/views/_mixins/notification.pug
import classNames from 'classnames'
import React, { ReactElement, useState } from 'react'
import { useTranslation } from 'react-i18next'
import MaterialIcon from './material-icon'
export type NotificationType =
| 'info'
| 'success'
| 'warning'
| 'error'
| 'offer'
export type NotificationProps = {
action?: React.ReactElement
ariaLive?: 'polite' | 'off' | 'assertive'
className?: string
content: React.ReactNode
customIcon?: React.ReactElement | null
disclaimer?: React.ReactElement | string
isDismissible?: boolean
isActionBelowContent?: boolean
onDismiss?: () => void
title?: string
type: NotificationType
id?: string
}
export function NotificationIcon({
notificationType,
customIcon,
}: {
notificationType: NotificationType
customIcon?: ReactElement
}) {
let icon = <MaterialIcon type="info" />
if (customIcon) {
icon = customIcon
} else if (notificationType === 'success') {
icon = <MaterialIcon type="check_circle" />
} else if (notificationType === 'warning') {
icon = <MaterialIcon type="warning" />
} else if (notificationType === 'error') {
icon = <MaterialIcon type="error" />
} else if (notificationType === 'offer') {
icon = <MaterialIcon type="campaign" />
}
return <div className="notification-icon">{icon}</div>
}
function Notification({
action,
ariaLive,
className = '',
content,
customIcon,
disclaimer,
isActionBelowContent,
isDismissible,
onDismiss,
title,
type,
id,
}: NotificationProps) {
type = type || 'info'
const { t } = useTranslation()
const [show, setShow] = useState(true)
const notificationClassName = classNames(
'notification',
`notification-type-${type}`,
isActionBelowContent ? 'notification-cta-below-content' : '',
className
)
const handleDismiss = () => {
setShow(false)
if (onDismiss) onDismiss()
}
// return null
if (!show) {
return null
}
return (
<div
className={notificationClassName}
aria-live={ariaLive || 'off'}
role="alert"
id={id}
>
{customIcon !== null && (
<NotificationIcon notificationType={type} customIcon={customIcon} />
)}
<div className="notification-content-and-cta">
<div className="notification-content">
{title && (
<p>
<b>{title}</b>
</p>
)}
{content}
</div>
{action && <div className="notification-cta">{action}</div>}
{disclaimer && (
<div className="notification-disclaimer">{disclaimer}</div>
)}
</div>
{isDismissible && (
<div className="notification-close-btn">
<button aria-label={t('close')} onClick={handleDismiss}>
<MaterialIcon type="close" />
</button>
</div>
)}
</div>
)
}
export default Notification

View File

@@ -0,0 +1,170 @@
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
function Pagination({ currentPage, totalPages, handlePageClick }) {
const { t } = useTranslation()
const maxOtherPageButtons = useMemo(() => {
let maxOtherPageButtons = 4 // does not include current page, prev/next buttons
if (totalPages < maxOtherPageButtons + 1) {
maxOtherPageButtons = totalPages - 1
}
return maxOtherPageButtons
}, [totalPages])
const pageButtons = useMemo(() => {
const result = []
let nextPage = currentPage + 1
let prevPage = currentPage - 1
function calcPages() {
if (nextPage && nextPage <= totalPages) {
result.push(nextPage)
nextPage++
} else {
nextPage = undefined
}
if (prevPage && prevPage > 0) {
result.push(prevPage)
prevPage--
} else {
prevPage = undefined
}
}
while (result.length < maxOtherPageButtons) {
calcPages()
}
result.push(currentPage) // wait until prev/next calculated to add current
result.sort((a, b) => a - b) // sort numerically
return result
}, [currentPage, totalPages, maxOtherPageButtons])
const morePrevPages = useMemo(() => {
return pageButtons[0] !== 1 && currentPage - maxOtherPageButtons / 2 > 1
}, [pageButtons, currentPage, maxOtherPageButtons])
const moreNextPages = useMemo(() => {
return pageButtons[pageButtons.length - 1] < totalPages
}, [pageButtons, totalPages])
return (
<nav role="navigation" aria-label={t('pagination_navigation')}>
<ul className="pagination">
{currentPage > 1 && (
<li>
<button
onClick={event => handlePageClick(event, currentPage - 1)}
aria-label={t('go_prev_page')}
>
«
</button>
</li>
)}
{morePrevPages && (
<li>
<span className="ellipses"></span>
</li>
)}
{pageButtons.map(page => (
<PaginationItem
key={`prev-page-${page}`}
page={page}
currentPage={currentPage}
handlePageClick={handlePageClick}
/>
))}
{moreNextPages && (
<li>
<span className="ellipses"></span>
</li>
)}
{currentPage < totalPages && (
<li>
<button
onClick={event => handlePageClick(event, currentPage + 1)}
aria-label={t('go_next_page')}
>
»
</button>
</li>
)}
</ul>
</nav>
)
}
function PaginationItem({ page, currentPage, handlePageClick }) {
const { t } = useTranslation()
const itemClassName = classNames({ active: currentPage === page })
const ariaCurrent = currentPage === page
const ariaLabel =
currentPage === page ? t('page_current', { page }) : t('go_page', { page })
return (
<li className={itemClassName}>
<button
aria-current={ariaCurrent}
onClick={event => handlePageClick(event, page)}
aria-label={ariaLabel}
>
{page}
</button>
</li>
)
}
function isPositiveNumber(value) {
return typeof value === 'number' && value > 0
}
function isCurrentPageWithinTotalPages(currentPage, totalPages) {
return currentPage <= totalPages
}
Pagination.propTypes = {
currentPage: function (props, propName, componentName) {
if (
!isPositiveNumber(props[propName]) ||
!isCurrentPageWithinTotalPages(props.currentPage, props.totalPages)
) {
return new Error(
'Invalid prop `' +
propName +
'` supplied to' +
' `' +
componentName +
'`. Validation failed.'
)
}
},
totalPages: function (props, propName, componentName) {
if (!isPositiveNumber(props[propName])) {
return new Error(
'Invalid prop `' +
propName +
'` supplied to' +
' `' +
componentName +
'`. Validation failed.'
)
}
},
handlePageClick: PropTypes.func.isRequired,
}
PaginationItem.propTypes = {
currentPage: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
handlePageClick: PropTypes.func.isRequired,
}
export default Pagination

View File

@@ -0,0 +1,38 @@
import { FC } from 'react'
import SplitTestBadge from '@/shared/components/split-test-badge'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
export const PanelHeading: FC<{
title: string
splitTestName?: string
children?: React.ReactNode
handleClose(): void
}> = ({ title, splitTestName, children, handleClose }) => {
const { t } = useTranslation()
return (
<div className="panel-heading">
<div className="panel-heading-label">
<span>{title}</span>
{splitTestName && (
<SplitTestBadge
splitTestName={splitTestName}
displayOnVariants={['enabled']}
/>
)}
</div>
{children}
<button
type="button"
className="btn panel-heading-close-button"
aria-label={t('close')}
onClick={handleClose}
>
<MaterialIcon type="close" />
</button>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { MergeAndOverride } from '../../../../types/utils'
type PolymorphicComponentOwnProps<E extends React.ElementType> = {
as?: E
}
export type PolymorphicComponentProps<E extends React.ElementType> =
MergeAndOverride<React.ComponentProps<E>, PolymorphicComponentOwnProps<E>>
function PolymorphicComponent<E extends React.ElementType = 'div'>({
as,
...props
}: PolymorphicComponentProps<E>) {
const Component = as || 'div'
return <Component {...props} />
}
export default PolymorphicComponent

View File

@@ -0,0 +1,23 @@
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import Icon from './icon'
function Processing({ isProcessing }) {
const { t } = useTranslation()
if (isProcessing) {
return (
<div aria-live="polite">
{t('processing')}&nbsp;
<Icon type="refresh" fw spin />
</div>
)
} else {
return <></>
}
}
Processing.propTypes = {
isProcessing: PropTypes.bool.isRequired,
}
export default Processing

View File

@@ -0,0 +1,42 @@
import React from 'react'
type RadioChipProps<ValueType> = {
checked?: boolean
disabled?: boolean
name: string
onChange: (value: ValueType) => void
required?: boolean
label: React.ReactElement | string
value: ValueType
}
const RadioChip = <T extends string>({
checked,
disabled,
name,
onChange,
label,
required,
value,
}: RadioChipProps<T>) => {
const handleChange = () => {
onChange(value)
}
return (
<label className="radio-chip" data-disabled={disabled ? 'true' : undefined}>
<input
checked={checked}
disabled={disabled}
name={name}
onChange={handleChange}
type="radio"
required={required}
value={value}
/>
{label}
</label>
)
}
export default RadioChip

View File

@@ -0,0 +1,36 @@
import ReCAPTCHA from 'react-google-recaptcha'
import getMeta from '@/utils/meta'
import { ExposedSettings } from '../../../../types/exposed-settings'
interface ReCaptcha2Props
extends Pick<React.ComponentProps<typeof ReCAPTCHA>, 'onChange'> {
page: keyof ExposedSettings['recaptchaDisabled']
recaptchaRef: React.LegacyRef<ReCAPTCHA>
}
export function ReCaptcha2({
page: site,
onChange,
recaptchaRef,
}: ReCaptcha2Props) {
const { recaptchaSiteKey, recaptchaDisabled } = getMeta('ol-ExposedSettings')
if (!recaptchaSiteKey) {
return null
}
if (site && recaptchaDisabled[site]) {
return null
}
if (process.env.NODE_ENV === 'development' && window.Cypress) {
return null // Disable captcha for E2E tests in dev-env.
}
return (
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey={recaptchaSiteKey}
onChange={onChange}
badge="inline"
/>
)
}

View File

@@ -0,0 +1,32 @@
import { Trans } from 'react-i18next'
export default function RecaptchaConditions() {
// the component link children below will be overwritten by the translation string
return (
<div className="recaptcha-branding">
<Trans
i18nKey="recaptcha_conditions"
components={{
1: (
<a
rel="noopener noreferrer"
target="_blank"
href="https://policies.google.com/privacy"
>
Privacy Policy
</a>
),
2: (
<a
rel="noopener noreferrer"
target="_blank"
href="https://policies.google.com/terms"
>
Terms of Service
</a>
),
}}
/>
</div>
)
}

View File

@@ -0,0 +1,220 @@
/* eslint-disable jsx-a11y/label-has-for */
/* eslint-disable jsx-a11y/label-has-associated-control */
import {
useRef,
useEffect,
KeyboardEventHandler,
useCallback,
ReactNode,
useState,
} from 'react'
import classNames from 'classnames'
import { useSelect } from 'downshift'
import { useTranslation } from 'react-i18next'
import { Form, Spinner } from 'react-bootstrap-5'
import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
import MaterialIcon from '@/shared/components/material-icon'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
export type SelectProps<T> = {
// The items rendered as dropdown options.
items: T[]
// Stringifies an item of type T. The resulting string is rendered as a dropdown option.
itemToString: (item: T | null | undefined) => string
// Caption for the dropdown.
label?: ReactNode
// Attribute used to identify the component inside a Form. This name is used to
// retrieve FormData when the form is submitted. The value of the FormData entry
// is the string returned by `itemToString(selectedItem)`.
name?: string
// Hint text displayed in the initial render.
defaultText?: string
// Initial selected item, displayed in the initial render. When both `defaultText`
// and `defaultItem` are set the latter is ignored.
defaultItem?: T | null
// Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option.
itemToSubtitle?: (item: T | null | undefined) => string
// Stringifies an item. The resulting string is rendered as a React `key` for each item.
itemToKey: (item: T) => string
// Callback invoked after the selected item is updated.
onSelectedItemChanged?: (item: T | null | undefined) => void
// Optionally directly control the selected item.
selected?: T | null
// When `true` item selection is disabled.
disabled?: boolean
// Determine which items should be disabled
itemToDisabled?: (item: T | null | undefined) => boolean
// When `true` displays an "Optional" subtext after the `label` caption.
optionalLabel?: boolean
// When `true` displays a spinner next to the `label` caption.
loading?: boolean
// Show a checkmark next to the selected item
selectedIcon?: boolean
// testId for the input element
dataTestId?: string
}
export const Select = <T,>({
items,
itemToString = item => (item === null ? '' : String(item)),
label,
name,
defaultText = 'Items',
defaultItem,
itemToSubtitle,
itemToKey,
onSelectedItemChanged,
selected,
disabled = false,
itemToDisabled,
optionalLabel = false,
loading = false,
selectedIcon = false,
dataTestId,
}: SelectProps<T>) => {
const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
defaultItem
)
const { t } = useTranslation()
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getItemProps,
highlightedIndex,
openMenu,
closeMenu,
} = useSelect({
items: items ?? [],
itemToString,
selectedItem: selected || defaultItem,
onSelectedItemChange: changes => {
if (onSelectedItemChanged) {
onSelectedItemChanged(changes.selectedItem)
}
setSelectedItem(changes.selectedItem)
},
})
useEffect(() => {
setSelectedItem(selected)
}, [selected])
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!name || !rootRef.current) return
const parentForm: HTMLFormElement | null | undefined =
rootRef.current?.closest('form')
if (!parentForm) return
function handleFormDataEvent(event: FormDataEvent) {
const data = event.formData
const key = name as string // can't be undefined due to early exit in the effect
if (selectedItem || defaultItem) {
data.append(key, itemToString(selectedItem || defaultItem))
}
}
parentForm.addEventListener('formdata', handleFormDataEvent)
return () => {
parentForm.removeEventListener('formdata', handleFormDataEvent)
}
}, [name, itemToString, selectedItem, defaultItem])
const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
if (event.key === 'Escape' && isOpen) {
event.stopPropagation()
closeMenu()
}
}
const onKeyDown: KeyboardEventHandler<HTMLButtonElement> = useCallback(
event => {
if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
event.preventDefault()
;(event.nativeEvent as any).preventDownshiftDefault = true
openMenu()
}
},
[isOpen, openMenu]
)
let value: string | undefined
if (selectedItem || defaultItem) {
value = itemToString(selectedItem || defaultItem)
} else {
value = defaultText
}
return (
<div className="select-wrapper" ref={rootRef}>
{label ? (
<Form.Label {...getLabelProps()}>
{label}{' '}
{optionalLabel && (
<span className="fw-normal">({t('optional')})</span>
)}{' '}
{loading && (
<span data-testid="spinner">
<Spinner
animation="border"
aria-hidden="true"
as="span"
role="status"
size="sm"
/>
</span>
)}
</Form.Label>
) : null}
<FormControl
data-testid={dataTestId}
{...getToggleButtonProps({
disabled,
onKeyDown,
className: 'select-trigger',
})}
value={value}
readOnly
append={
<MaterialIcon
type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
className="align-text-bottom"
/>
}
/>
<ul
{...getMenuProps({ disabled, onKeyDown: handleMenuKeyDown })}
className={classNames('dropdown-menu w-100', { show: isOpen })}
>
{isOpen &&
items?.map((item, index) => {
const isDisabled = itemToDisabled && itemToDisabled(item)
return (
<li role="none" key={itemToKey(item)}>
<DropdownItem
as="button"
className={classNames({
'select-highlighted': highlightedIndex === index,
})}
active={selectedItem === item}
trailingIcon={
selectedIcon && selectedItem === item ? 'check' : undefined
}
description={
itemToSubtitle ? itemToSubtitle(item) : undefined
}
{...getItemProps({ item, index, disabled: isDisabled })}
>
{itemToString(item)}
</DropdownItem>
</li>
)
})}
</ul>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { useSplitTestContext } from '../context/split-test-context'
import BetaBadge from './beta-badge'
type TooltipProps = {
id?: string
className?: string
}
type SplitTestBadgeProps = {
splitTestName: string
displayOnVariants: string[]
tooltip?: TooltipProps
}
export default function SplitTestBadge({
splitTestName,
displayOnVariants,
tooltip = {},
}: SplitTestBadgeProps) {
const { splitTestVariants, splitTestInfo } = useSplitTestContext()
const testInfo = splitTestInfo[splitTestName]
if (!testInfo) {
return null
}
const variant = splitTestVariants[splitTestName]
if (!variant || !displayOnVariants.includes(variant)) {
return null
}
return (
<BetaBadge
tooltip={{
id: tooltip.id || `${splitTestName}-badge-tooltip`,
className: `split-test-badge-tooltip ${tooltip.className}`,
text: testInfo.badgeInfo?.tooltipText || (
<>
We are testing this new feature.
<br />
Click to give feedback
</>
),
}}
phase={testInfo.phase}
link={{
href: testInfo.badgeInfo?.url?.length
? testInfo.badgeInfo?.url
: undefined,
}}
/>
)
}

View File

@@ -0,0 +1,54 @@
import { MouseEventHandler, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { startFreeTrial } from '@/main/account-upgrade'
import * as eventTracking from '../../infrastructure/event-tracking'
import OLButton from '@/features/ui/components/ol/ol-button'
type StartFreeTrialButtonProps = {
source: string
variant?: string
buttonProps?: React.ComponentProps<typeof OLButton>
children?: React.ReactNode
handleClick?: MouseEventHandler<typeof OLButton>
}
export default function StartFreeTrialButton({
buttonProps = {
variant: 'secondary',
},
children,
handleClick,
source,
variant,
}: StartFreeTrialButtonProps) {
const { t } = useTranslation()
useEffect(() => {
const eventSegmentation: { [key: string]: unknown } = {
'paywall-type': source,
}
if (variant) {
eventSegmentation.variant = variant
}
eventTracking.sendMB('paywall-prompt', eventSegmentation)
}, [source, variant])
const onClick = useCallback(
event => {
event.preventDefault()
if (handleClick) {
handleClick(event)
}
startFreeTrial(source, variant)
},
[handleClick, source, variant]
)
return (
<OLButton {...buttonProps} onClick={onClick}>
{children || t('start_free_trial')}
</OLButton>
)
}

View File

@@ -0,0 +1,27 @@
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
export function Stepper({ steps, active }: { steps: number; active: number }) {
const { t } = useTranslation()
return (
<div
className="stepper"
role="progressbar"
aria-label={t('progress_bar_percentage')}
aria-valuenow={active + 1}
aria-valuemax={steps}
tabIndex={0}
>
{Array.from({ length: steps }).map((_, i) => (
<div
key={i}
className={classNames({
step: true,
active: i === active,
completed: i < active,
})}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,26 @@
import classNames from 'classnames'
type SwitchProps = {
onChange: () => void
checked: boolean
disabled?: boolean
}
function Switch({ onChange, checked, disabled = false }: SwitchProps) {
return (
<label className={classNames('switch-input', { disabled })}>
<input
className="invisible-input"
type="checkbox"
role="switch"
autoComplete="off"
onChange={onChange}
checked={checked}
disabled={disabled}
/>
<span className="switch" />
</label>
)
}
export default Switch

View File

@@ -0,0 +1,29 @@
import Close from './close'
import usePersistedState from '../hooks/use-persisted-state'
type SystemMessageProps = {
id: string
children: React.ReactNode
}
function SystemMessage({ id, children }: SystemMessageProps) {
const [hidden, setHidden] = usePersistedState(
`systemMessage.hide.${id}`,
false
)
if (hidden) {
return null
}
return (
<li className="system-message">
{id !== 'protected' ? (
<Close onDismiss={() => setHidden(true)} variant="dark" />
) : null}
{children}
</li>
)
}
export default SystemMessage

View File

@@ -0,0 +1,50 @@
import { useEffect } from 'react'
import SystemMessage from './system-message'
import TranslationMessage from './translation-message'
import useAsync from '../hooks/use-async'
import { getJSON } from '@/infrastructure/fetch-json'
import getMeta from '../../utils/meta'
import { SystemMessage as TSystemMessage } from '../../../../types/system-message'
import { debugConsole } from '@/utils/debugging'
const MESSAGE_POLL_INTERVAL = 15 * 60 * 1000
function SystemMessages() {
const { data: messages, runAsync } = useAsync<TSystemMessage[]>()
const suggestedLanguage = getMeta('ol-suggestedLanguage')
useEffect(() => {
const pollMessages = () => {
// Ignore polling if tab is hidden or browser is offline
if (document.hidden || !navigator.onLine) {
return
}
runAsync(getJSON('/system/messages')).catch(debugConsole.error)
}
pollMessages()
const interval = setInterval(pollMessages, MESSAGE_POLL_INTERVAL)
return () => {
clearInterval(interval)
}
}, [runAsync])
if (!messages?.length && !suggestedLanguage) {
return null
}
return (
<ul className="system-messages">
{messages?.map((message, idx) => (
<SystemMessage key={idx} id={message._id}>
{message.content}
</SystemMessage>
))}
{suggestedLanguage ? <TranslationMessage /> : null}
</ul>
)
}
export default SystemMessages

View File

@@ -0,0 +1,40 @@
import { Trans, useTranslation } from 'react-i18next'
import Close from './close'
import usePersistedState from '../hooks/use-persisted-state'
import getMeta from '../../utils/meta'
function TranslationMessage() {
const { t } = useTranslation()
const [hidden, setHidden] = usePersistedState('hide-i18n-notification', false)
const config = getMeta('ol-suggestedLanguage')!
const currentUrl = getMeta('ol-currentUrl')
if (hidden) {
return null
}
return (
<li className="system-message">
<Close onDismiss={() => setHidden(true)} />
<div className="text-center">
<a href={config.url + currentUrl}>
<Trans
i18nKey="click_here_to_view_sl_in_lng"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ lngName: config.lngName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
<img
className="ms-1"
src={config.imgUrl}
alt={t('country_flag', { country: config.lngName })}
aria-hidden
/>
</a>
</div>
</li>
)
}
export default TranslationMessage

View File

@@ -0,0 +1,48 @@
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
function Check() {
return <MaterialIcon type="check" />
}
function UpgradeBenefits() {
const { t } = useTranslation()
return (
<ul className="list-unstyled upgrade-benefits">
<li>
<Check />
&nbsp;
{t('unlimited_projects')}
</li>
<li>
<Check />
&nbsp;
{t('collabs_per_proj', { collabcount: 'Multiple' })}
</li>
<li>
<Check />
&nbsp;
{t('full_doc_history')}
</li>
<li>
<Check />
&nbsp;
{t('sync_to_dropbox')}
</li>
<li>
<Check />
&nbsp;
{t('sync_to_github')}
</li>
<li>
<Check />
&nbsp;
{t('compile_larger_projects')}
</li>
</ul>
)
}
export default memo(UpgradeBenefits)

View File

@@ -0,0 +1,177 @@
import OLBadge from '@/features/ui/components/ol/ol-badge'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLCloseButton from '@/features/ui/components/ol/ol-close-button'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import MaterialIcon from '@/shared/components/material-icon'
import { PropsWithChildren } from 'react'
import { Container } from 'react-bootstrap-5'
import { Trans, useTranslation } from 'react-i18next'
type IconListItemProps = PropsWithChildren<{
icon: string
}>
function IconListItem({ icon, children }: IconListItemProps) {
return (
<li className="d-flex align-items-center">
<div className="flex-shrink-0">
<MaterialIcon type={icon} />
</div>
<div className="flex-grow-1 ms-2">{children}</div>
</li>
)
}
type PlansLinkProps = {
itmCampaign: string
onClick?: React.MouseEventHandler<HTMLAnchorElement>
}
function PlansLink({
children,
itmCampaign,
onClick,
}: PropsWithChildren<PlansLinkProps>) {
return (
<a
key="compare_plans_link"
href={`/user/subscription/choose-your-plan?itm-campaign=${itmCampaign}`}
target="_blank"
rel="noreferrer"
onClick={onClick}
>
{children}
<MaterialIcon type="open_in_new" />
</a>
)
}
type UpgradePromptProps = {
title: string
summary: string
onClose: () => void
planPricing: { student: string; standard: string }
itmCampaign: string
isStudent?: boolean
onClickInfoLink?: React.MouseEventHandler<HTMLAnchorElement>
onClickPaywall?: React.MouseEventHandler<HTMLAnchorElement>
}
export function UpgradePrompt({
title,
summary,
onClose,
planPricing,
itmCampaign,
isStudent = false,
onClickInfoLink,
onClickPaywall,
}: UpgradePromptProps) {
const { t } = useTranslation()
const planPrice = isStudent ? planPricing.student : planPricing.standard
const planCode = isStudent
? 'student_free_trial_7_days'
: 'collaborator_free_trial_7_days'
return (
<Container className="upgrade-prompt">
<OLRow className="justify-content-end">
<OLCloseButton onClick={() => onClose()} />
</OLRow>
<OLRow className="text-center">
<h2 className="my-0 upgrade-prompt-title">{title}</h2>
<p className="upgrade-prompt-summary">{summary}</p>
</OLRow>
<OLRow className="g-3">
<OLCol md={6} className="upgrade-prompt-card-container">
<div className="g-0 upgrade-prompt-card upgrade-prompt-card-premium">
<OLRow className="justify-content-between">
<OLCol>
<h3>{isStudent ? t('student') : t('standard')}</h3>
</OLCol>
<OLCol xs="auto">
<OLBadge className="badge-premium-gradient">
{t('recommended')}
</OLBadge>
</OLCol>
</OLRow>
<OLRow>
<p className="upgrade-prompt-price">
<span className="upgrade-prompt-price-number">{planPrice}</span>{' '}
{t('per_month')}
</p>
</OLRow>
<OLRow>
<ul className="upgrade-prompt-list">
<IconListItem icon="hourglass_top">
{t('12x_more_compile_time')}
</IconListItem>
<IconListItem icon="group_add">
{t('collabs_per_proj', { collabcount: isStudent ? 6 : 10 })}
</IconListItem>
<IconListItem icon="history">
{t('unlimited_document_history')}
</IconListItem>
</ul>
</OLRow>
<OLRow className="mt-auto">
<a
className="btn btn-premium"
href={`/user/subscription/new?planCode=${planCode}&itm-campaign=${itmCampaign}`}
onClick={onClickPaywall}
target="_blank"
rel="noreferrer"
>
{t('try_for_free')}
</a>
</OLRow>
</div>
</OLCol>
<OLCol md={6} className="upgrade-prompt-card-container">
<div className="g-0 upgrade-prompt-card upgrade-prompt-card-free">
<OLRow>
<h3>{t('free')}</h3>
</OLRow>
<OLRow>
<p className="upgrade-prompt-price">
{/* Invisible span here to hold the correct height to match a card with a price */}
<span className="upgrade-prompt-price-number invisible" />
{t('your_current_plan')}
</p>
</OLRow>
<OLRow>
<ul className="upgrade-prompt-list">
<IconListItem icon="hourglass_bottom">
{t('basic_compile_time')}
</IconListItem>
<IconListItem icon="person">
{t('collabs_per_proj_single', { collabcount: 1 })}
</IconListItem>
<IconListItem icon="history_off">
{t('limited_document_history')}
</IconListItem>
</ul>
</OLRow>
<OLRow className="mt-auto">
<OLButton variant="secondary" onClick={() => onClose()}>
{t('continue_with_free_plan')}
</OLButton>
</OLRow>
</div>
</OLCol>
</OLRow>
<OLRow className="text-center">
<p className="upgrade-prompt-all-plans">
{/* eslint-disable react/jsx-key */}
<Trans
i18nKey="compare_all_plans"
components={[
<PlansLink onClick={onClickInfoLink} itmCampaign={itmCampaign} />,
]}
/>
{/* eslint-disable react/jsx-key */}
</p>
</OLRow>
</Container>
)
}