first commit
This commit is contained in:
@@ -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
|
@@ -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
|
64
services/web/frontend/js/shared/components/beta-badge.tsx
Normal file
64
services/web/frontend/js/shared/components/beta-badge.tsx
Normal 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
|
@@ -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
|
28
services/web/frontend/js/shared/components/close.tsx
Normal file
28
services/web/frontend/js/shared/components/close.tsx
Normal 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
|
@@ -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>
|
||||
)
|
@@ -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'
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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" />,
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
||||
</>
|
||||
)
|
@@ -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)}</>
|
||||
}
|
||||
)
|
@@ -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>
|
||||
)
|
||||
}
|
7
services/web/frontend/js/shared/components/history.ts
Normal file
7
services/web/frontend/js/shared/components/history.ts
Normal 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)
|
||||
},
|
||||
}
|
10
services/web/frontend/js/shared/components/icon-checked.jsx
Normal file
10
services/web/frontend/js/shared/components/icon-checked.jsx
Normal 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
|
44
services/web/frontend/js/shared/components/icon.tsx
Normal file
44
services/web/frontend/js/shared/components/icon.tsx
Normal 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
|
26
services/web/frontend/js/shared/components/interstitial.tsx
Normal file
26
services/web/frontend/js/shared/components/interstitial.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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} />
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
27
services/web/frontend/js/shared/components/location.js
Normal file
27
services/web/frontend/js/shared/components/location.js
Normal 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()
|
||||
},
|
||||
}
|
51
services/web/frontend/js/shared/components/material-icon.tsx
Normal file
51
services/web/frontend/js/shared/components/material-icon.tsx
Normal 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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
126
services/web/frontend/js/shared/components/notification.tsx
Normal file
126
services/web/frontend/js/shared/components/notification.tsx
Normal 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
|
170
services/web/frontend/js/shared/components/pagination.jsx
Normal file
170
services/web/frontend/js/shared/components/pagination.jsx
Normal 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
|
38
services/web/frontend/js/shared/components/panel-heading.tsx
Normal file
38
services/web/frontend/js/shared/components/panel-heading.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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
|
23
services/web/frontend/js/shared/components/processing.jsx
Normal file
23
services/web/frontend/js/shared/components/processing.jsx
Normal 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')}…
|
||||
<Icon type="refresh" fw spin />
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
|
||||
Processing.propTypes = {
|
||||
isProcessing: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
export default Processing
|
42
services/web/frontend/js/shared/components/radio-chip.tsx
Normal file
42
services/web/frontend/js/shared/components/radio-chip.tsx
Normal 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
|
36
services/web/frontend/js/shared/components/recaptcha-2.tsx
Normal file
36
services/web/frontend/js/shared/components/recaptcha-2.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
220
services/web/frontend/js/shared/components/select.tsx
Normal file
220
services/web/frontend/js/shared/components/select.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
27
services/web/frontend/js/shared/components/stepper.tsx
Normal file
27
services/web/frontend/js/shared/components/stepper.tsx
Normal 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>
|
||||
)
|
||||
}
|
26
services/web/frontend/js/shared/components/switch.tsx
Normal file
26
services/web/frontend/js/shared/components/switch.tsx
Normal 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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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 />
|
||||
|
||||
{t('unlimited_projects')}
|
||||
</li>
|
||||
<li>
|
||||
<Check />
|
||||
|
||||
{t('collabs_per_proj', { collabcount: 'Multiple' })}
|
||||
</li>
|
||||
<li>
|
||||
<Check />
|
||||
|
||||
{t('full_doc_history')}
|
||||
</li>
|
||||
<li>
|
||||
<Check />
|
||||
|
||||
{t('sync_to_dropbox')}
|
||||
</li>
|
||||
<li>
|
||||
<Check />
|
||||
|
||||
{t('sync_to_github')}
|
||||
</li>
|
||||
<li>
|
||||
<Check />
|
||||
|
||||
{t('compile_larger_projects')}
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(UpgradeBenefits)
|
177
services/web/frontend/js/shared/components/upgrade-prompt.tsx
Normal file
177
services/web/frontend/js/shared/components/upgrade-prompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user