parent
e48b890fd7
commit
c4403ea089
@ -0,0 +1,9 @@ |
|||||||
|
import styled from 'styled-components/macro' |
||||||
|
|
||||||
|
export const Overlay = styled.div` |
||||||
|
position: fixed; |
||||||
|
inset: 0; |
||||||
|
opacity: 0.6; |
||||||
|
background-color: ${({ theme }) => theme.colors.black}; |
||||||
|
z-index: 9999; |
||||||
|
` |
||||||
@ -1,3 +1,6 @@ |
|||||||
export enum KEYBOARD_KEYS { |
export enum KEYBOARD_KEYS { |
||||||
|
ArrowLeft = 'ArrowLeft', |
||||||
|
ArrowRight = 'ArrowRight', |
||||||
Enter = 'Enter', |
Enter = 'Enter', |
||||||
|
Esc = 'Escape', |
||||||
} |
} |
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,198 @@ |
|||||||
|
import type { |
||||||
|
PropsWithChildren, |
||||||
|
ComponentProps, |
||||||
|
CSSProperties, |
||||||
|
} from 'react' |
||||||
|
import { Fragment } from 'react' |
||||||
|
|
||||||
|
import compact from 'lodash/compact' |
||||||
|
|
||||||
|
import type { StepType } from '@reactour/tour' |
||||||
|
import { TourProvider as TourProviderLib } from '@reactour/tour' |
||||||
|
|
||||||
|
import { isMobileDevice } from 'config' |
||||||
|
|
||||||
|
import { disableBodyScroll, enableBodyScroll } from 'helpers' |
||||||
|
|
||||||
|
import { useMatchPageStore } from 'features/MatchPage/store' |
||||||
|
|
||||||
|
import { Steps } from './config' |
||||||
|
import { |
||||||
|
ContentComponent, |
||||||
|
Body, |
||||||
|
BodyText, |
||||||
|
Title, |
||||||
|
} from './components/ContentComponent' |
||||||
|
|
||||||
|
const getPopoverStyle = (base: CSSProperties): CSSProperties => ({ |
||||||
|
...base, |
||||||
|
borderRadius: 6, |
||||||
|
lineHeight: 1, |
||||||
|
maxWidth: 'auto', |
||||||
|
padding: 20, |
||||||
|
width: 340, |
||||||
|
...isMobileDevice && { |
||||||
|
padding: '20px 25px', |
||||||
|
textAlign: 'center', |
||||||
|
width: '95vw', |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const getPopoverPosition = (baseCoords: [number, number]): [number, number] => ( |
||||||
|
isMobileDevice ? [0.001, 0.001] : baseCoords |
||||||
|
) |
||||||
|
|
||||||
|
const getSelector = (step: Steps) => ( |
||||||
|
isMobileDevice ? `[data-step="${Steps.Start}"]` : `[data-step="${step}"]` |
||||||
|
) |
||||||
|
|
||||||
|
const steps: Array<StepType> = compact([ |
||||||
|
{ |
||||||
|
content: ( |
||||||
|
<Fragment> |
||||||
|
<Title |
||||||
|
alignLeft |
||||||
|
t='check_out_the_stats' |
||||||
|
/> |
||||||
|
<Body> |
||||||
|
<BodyText t='here_you_will_discover_tons' /> |
||||||
|
</Body> |
||||||
|
</Fragment> |
||||||
|
), |
||||||
|
padding: { |
||||||
|
popover: getPopoverPosition([15, 10]), |
||||||
|
}, |
||||||
|
selector: getSelector(Steps.Start), |
||||||
|
styles: { |
||||||
|
popover: (base) => ({ |
||||||
|
...getPopoverStyle(base), |
||||||
|
textAlign: 'left', |
||||||
|
}), |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
content: ( |
||||||
|
<Fragment> |
||||||
|
<Title t='welcom_to_stats_tab' /> |
||||||
|
<Body> |
||||||
|
<BodyText t='see_interactive_game_stats' /> |
||||||
|
</Body> |
||||||
|
</Fragment> |
||||||
|
), |
||||||
|
padding: { |
||||||
|
popover: getPopoverPosition([5, 0.001]), |
||||||
|
}, |
||||||
|
selector: getSelector(Steps.Welcome), |
||||||
|
styles: { |
||||||
|
popover: (base) => ({ |
||||||
|
...getPopoverStyle(base), |
||||||
|
...isMobileDevice && { |
||||||
|
padding: '20px 0', |
||||||
|
}, |
||||||
|
}), |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
content: ( |
||||||
|
<Fragment> |
||||||
|
<Title t='compare_teams_across_multiple_metrics' /> |
||||||
|
<Body> |
||||||
|
<BodyText t='blue_stats_are_clickable' /> |
||||||
|
</Body> |
||||||
|
</Fragment> |
||||||
|
), |
||||||
|
padding: { |
||||||
|
popover: getPopoverPosition([8, 20]), |
||||||
|
}, |
||||||
|
selector: getSelector(Steps.TeamsTab), |
||||||
|
}, |
||||||
|
{ |
||||||
|
content: ( |
||||||
|
<Title t='click_to_watch_playlist' /> |
||||||
|
), |
||||||
|
selector: getSelector(Steps.ClickToWatchPlaylist), |
||||||
|
}, |
||||||
|
{ |
||||||
|
content: ( |
||||||
|
<Title t='team_players_stats' /> |
||||||
|
), |
||||||
|
padding: { |
||||||
|
popover: getPopoverPosition([10, 17]), |
||||||
|
}, |
||||||
|
selector: getSelector(Steps.PlayersTab), |
||||||
|
}, |
||||||
|
{ |
||||||
|
content: ( |
||||||
|
<Title t='show_more_stats' /> |
||||||
|
), |
||||||
|
selector: getSelector(Steps.ShowMoreStats), |
||||||
|
}, |
||||||
|
!isMobileDevice && { |
||||||
|
content: ( |
||||||
|
<Title t='show_less_stats' /> |
||||||
|
), |
||||||
|
padding: { |
||||||
|
popover: [20, 10], |
||||||
|
}, |
||||||
|
selector: getSelector(Steps.ShowLessStats), |
||||||
|
}, |
||||||
|
{ |
||||||
|
content: ( |
||||||
|
<Title t='click_to_see_full_time_stats' /> |
||||||
|
), |
||||||
|
padding: { |
||||||
|
popover: getPopoverPosition([10, 0.001]), |
||||||
|
}, |
||||||
|
selector: getSelector(Steps.FinalStats), |
||||||
|
}, |
||||||
|
{ |
||||||
|
content: ( |
||||||
|
<Title t='click_to_see_stats_in_real_time' /> |
||||||
|
), |
||||||
|
padding: { |
||||||
|
popover: getPopoverPosition([10, 0.001]), |
||||||
|
}, |
||||||
|
selector: getSelector(Steps.FinalStats), |
||||||
|
}, |
||||||
|
]) |
||||||
|
|
||||||
|
const styles: ComponentProps<typeof TourProviderLib>['styles'] = { |
||||||
|
maskWrapper: () => ({ |
||||||
|
display: 'none', |
||||||
|
}), |
||||||
|
popover: getPopoverStyle, |
||||||
|
} |
||||||
|
|
||||||
|
const padding: ComponentProps<typeof TourProviderLib>['padding'] = { |
||||||
|
popover: isMobileDevice ? [0.001, 0.001] : [15, 25], |
||||||
|
} |
||||||
|
|
||||||
|
export const TourProvider = ({ children }: PropsWithChildren<{}>) => { |
||||||
|
const { beforeCloseTourCallback } = useMatchPageStore() |
||||||
|
|
||||||
|
const afterOpen = (target: Element | null) => { |
||||||
|
target && disableBodyScroll(target) |
||||||
|
} |
||||||
|
|
||||||
|
const beforeClose = (target: Element | null) => { |
||||||
|
target && enableBodyScroll(target) |
||||||
|
beforeCloseTourCallback() |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<TourProviderLib |
||||||
|
steps={steps} |
||||||
|
ContentComponent={ContentComponent} |
||||||
|
showDots={false} |
||||||
|
afterOpen={afterOpen} |
||||||
|
beforeClose={beforeClose} |
||||||
|
position={isMobileDevice ? 'top' : 'left'} |
||||||
|
padding={padding} |
||||||
|
disableInteraction |
||||||
|
disableKeyboardNavigation |
||||||
|
styles={styles} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</TourProviderLib> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,161 @@ |
|||||||
|
import { |
||||||
|
useEffect, |
||||||
|
useRef, |
||||||
|
useMemo, |
||||||
|
useCallback, |
||||||
|
} from 'react' |
||||||
|
|
||||||
|
import throttle from 'lodash/throttle' |
||||||
|
|
||||||
|
import type { PopoverContentProps } from '@reactour/tour' |
||||||
|
|
||||||
|
import { isMobileDevice, KEYBOARD_KEYS } from 'config' |
||||||
|
|
||||||
|
import { useEventListener } from 'hooks' |
||||||
|
|
||||||
|
import { useMatchPageStore } from 'features/MatchPage/store' |
||||||
|
import { Tabs } from 'features/MatchSidePlaylists/config' |
||||||
|
import { StatsType, Tabs as StatTabs } from 'features/MatchSidePlaylists/components/TabStats/config' |
||||||
|
|
||||||
|
import { Steps } from '../../config' |
||||||
|
|
||||||
|
const KEY_PRESS_DELAY = 1500 |
||||||
|
|
||||||
|
export const useContentComponent = ({ |
||||||
|
currentStep, |
||||||
|
setCurrentStep, |
||||||
|
setIsOpen, |
||||||
|
steps, |
||||||
|
}: PopoverContentProps) => { |
||||||
|
const { |
||||||
|
setSelectedStatsTable, |
||||||
|
setSelectedTab, |
||||||
|
setStatsType, |
||||||
|
toggleIsExpanded, |
||||||
|
} = useMatchPageStore() |
||||||
|
|
||||||
|
const timerRef = useRef<NodeJS.Timeout>() |
||||||
|
|
||||||
|
const back = useCallback(() => { |
||||||
|
switch (currentStep) { |
||||||
|
case Steps.Start: |
||||||
|
case Steps.Welcome: |
||||||
|
return |
||||||
|
case Steps.PlayersTab: |
||||||
|
setSelectedStatsTable(StatTabs.TEAMS) |
||||||
|
break |
||||||
|
case Steps.ShowLessStats: |
||||||
|
if (!isMobileDevice) { |
||||||
|
toggleIsExpanded() |
||||||
|
} |
||||||
|
break |
||||||
|
case Steps.FinalStats: |
||||||
|
if (isMobileDevice) { |
||||||
|
setStatsType(StatsType.FINAL_STATS) |
||||||
|
} else { |
||||||
|
toggleIsExpanded() |
||||||
|
} |
||||||
|
break |
||||||
|
|
||||||
|
case Steps.CurrentStats: |
||||||
|
setStatsType(StatsType.FINAL_STATS) |
||||||
|
break |
||||||
|
|
||||||
|
default: |
||||||
|
} |
||||||
|
|
||||||
|
timerRef.current = setTimeout(() => setCurrentStep((step) => step - 1), 0) |
||||||
|
}, [ |
||||||
|
currentStep, |
||||||
|
setCurrentStep, |
||||||
|
setSelectedStatsTable, |
||||||
|
setStatsType, |
||||||
|
toggleIsExpanded, |
||||||
|
]) |
||||||
|
|
||||||
|
const next = useCallback(() => { |
||||||
|
switch (currentStep) { |
||||||
|
case steps.length - 1: |
||||||
|
return |
||||||
|
case Steps.Start: |
||||||
|
setSelectedTab(Tabs.STATS) |
||||||
|
break |
||||||
|
|
||||||
|
case Steps.ClickToWatchPlaylist: |
||||||
|
setSelectedStatsTable(StatTabs.TEAM1) |
||||||
|
break |
||||||
|
|
||||||
|
case Steps.ShowMoreStats: |
||||||
|
if (isMobileDevice) { |
||||||
|
setStatsType(StatsType.FINAL_STATS) |
||||||
|
} else { |
||||||
|
toggleIsExpanded() |
||||||
|
} |
||||||
|
break |
||||||
|
|
||||||
|
case Steps.ShowLessStats: |
||||||
|
if (isMobileDevice) { |
||||||
|
setStatsType(StatsType.CURRENT_STATS) |
||||||
|
} else { |
||||||
|
setStatsType(StatsType.FINAL_STATS) |
||||||
|
toggleIsExpanded() |
||||||
|
} |
||||||
|
break |
||||||
|
|
||||||
|
case Steps.FinalStats: |
||||||
|
if (!isMobileDevice) { |
||||||
|
setStatsType(StatsType.CURRENT_STATS) |
||||||
|
} |
||||||
|
break |
||||||
|
|
||||||
|
default: |
||||||
|
} |
||||||
|
|
||||||
|
timerRef.current = setTimeout(() => setCurrentStep((step) => step + 1), 0) |
||||||
|
}, [ |
||||||
|
currentStep, |
||||||
|
setCurrentStep, |
||||||
|
setSelectedStatsTable, |
||||||
|
setSelectedTab, |
||||||
|
setStatsType, |
||||||
|
toggleIsExpanded, |
||||||
|
steps.length, |
||||||
|
]) |
||||||
|
|
||||||
|
const skipTour = useCallback(() => { |
||||||
|
setIsOpen(false) |
||||||
|
}, [setIsOpen]) |
||||||
|
|
||||||
|
useEventListener({ |
||||||
|
callback: useMemo(() => throttle((e: KeyboardEvent) => { |
||||||
|
e.stopPropagation() |
||||||
|
|
||||||
|
switch (e.code) { |
||||||
|
case KEYBOARD_KEYS.ArrowLeft: |
||||||
|
back() |
||||||
|
break |
||||||
|
|
||||||
|
case KEYBOARD_KEYS.ArrowRight: |
||||||
|
next() |
||||||
|
break |
||||||
|
|
||||||
|
case KEYBOARD_KEYS.Esc: |
||||||
|
skipTour() |
||||||
|
break |
||||||
|
default: |
||||||
|
} |
||||||
|
}, KEY_PRESS_DELAY), [back, next, skipTour]), |
||||||
|
event: 'keydown', |
||||||
|
options: true, |
||||||
|
}) |
||||||
|
|
||||||
|
useEffect(() => () => { |
||||||
|
timerRef.current && clearTimeout(timerRef.current) |
||||||
|
}, []) |
||||||
|
|
||||||
|
return { |
||||||
|
back, |
||||||
|
next, |
||||||
|
skipTour, |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,123 @@ |
|||||||
|
import type { ComponentType } from 'react' |
||||||
|
import { Fragment } from 'react' |
||||||
|
|
||||||
|
import type { PopoverContentProps } from '@reactour/tour' |
||||||
|
|
||||||
|
import { isMobileDevice } from 'config' |
||||||
|
|
||||||
|
import { T9n } from 'features/T9n' |
||||||
|
|
||||||
|
import { useContentComponent } from './hooks' |
||||||
|
import { |
||||||
|
PrevButton, |
||||||
|
NextButton, |
||||||
|
ActionButtonsContainer, |
||||||
|
Counter, |
||||||
|
SkipTour, |
||||||
|
ArrowWrapper, |
||||||
|
} from './styled' |
||||||
|
|
||||||
|
import { Steps } from '../../config' |
||||||
|
|
||||||
|
export * from './styled' |
||||||
|
|
||||||
|
const Arrow = () => ( |
||||||
|
<ArrowWrapper |
||||||
|
width='7' |
||||||
|
height='7' |
||||||
|
viewBox='0 0 7 7' |
||||||
|
fill='none' |
||||||
|
> |
||||||
|
<path |
||||||
|
d='M5.25 3.06699C5.58333 3.25944 5.58333 3.74056 5.25 3.93301L1.5 6.09808C1.16667 6.29053 0.75 6.04996 0.75 5.66506L0.75 1.33494C0.75 0.950036 1.16667 0.709474 1.5 0.901924L5.25 3.06699Z' |
||||||
|
fill='black' |
||||||
|
fillOpacity='0.7' |
||||||
|
/> |
||||||
|
</ArrowWrapper> |
||||||
|
|
||||||
|
) |
||||||
|
|
||||||
|
export const ContentComponent: ComponentType<PopoverContentProps> = (props) => { |
||||||
|
const { |
||||||
|
back, |
||||||
|
next, |
||||||
|
skipTour, |
||||||
|
} = useContentComponent(props) |
||||||
|
|
||||||
|
const { currentStep, steps } = props |
||||||
|
|
||||||
|
const renderActionButtons = () => { |
||||||
|
switch (currentStep) { |
||||||
|
case Steps.Start: |
||||||
|
return ( |
||||||
|
<Fragment> |
||||||
|
<NextButton onClick={next}> |
||||||
|
<T9n t='start_tour' /> |
||||||
|
</NextButton> |
||||||
|
<SkipTour onClick={skipTour}> |
||||||
|
<T9n t='skip_tour' /> |
||||||
|
</SkipTour> |
||||||
|
</Fragment> |
||||||
|
) |
||||||
|
|
||||||
|
case Steps.Welcome: |
||||||
|
return ( |
||||||
|
<Fragment> |
||||||
|
<Counter> |
||||||
|
{currentStep}/{steps.length - 1} |
||||||
|
</Counter> |
||||||
|
<NextButton onClick={next}> |
||||||
|
<T9n t='next_step' /> |
||||||
|
</NextButton> |
||||||
|
<SkipTour onClick={skipTour}> |
||||||
|
<T9n t='skip_tour' /> |
||||||
|
</SkipTour> |
||||||
|
</Fragment> |
||||||
|
) |
||||||
|
|
||||||
|
case steps.length - 1: |
||||||
|
return ( |
||||||
|
<Fragment> |
||||||
|
<Counter> |
||||||
|
{currentStep}/{steps.length - 1} |
||||||
|
</Counter> |
||||||
|
<PrevButton isLastStep onClick={back}> |
||||||
|
<T9n t='back' /> |
||||||
|
</PrevButton> |
||||||
|
<NextButton onClick={skipTour}> |
||||||
|
<T9n t='end_tour' /> |
||||||
|
</NextButton> |
||||||
|
</Fragment> |
||||||
|
) |
||||||
|
|
||||||
|
default: |
||||||
|
return ( |
||||||
|
<Fragment> |
||||||
|
<Counter> |
||||||
|
{currentStep}/{steps.length - 1} |
||||||
|
</Counter> |
||||||
|
<PrevButton onClick={back}> |
||||||
|
<T9n t='back' /> |
||||||
|
</PrevButton> |
||||||
|
<NextButton onClick={next}> |
||||||
|
<T9n t='next_step' /> |
||||||
|
</NextButton> |
||||||
|
<SkipTour onClick={skipTour}> |
||||||
|
<T9n t='skip_tour' /> |
||||||
|
</SkipTour> |
||||||
|
</Fragment> |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Fragment> |
||||||
|
{steps[currentStep].content} |
||||||
|
<ActionButtonsContainer step={currentStep}> |
||||||
|
{renderActionButtons()} |
||||||
|
</ActionButtonsContainer> |
||||||
|
{!isMobileDevice && <Arrow />} |
||||||
|
</Fragment> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,158 @@ |
|||||||
|
import styled, { css } from 'styled-components/macro' |
||||||
|
|
||||||
|
import includes from 'lodash/includes' |
||||||
|
|
||||||
|
import { isMobileDevice } from 'config' |
||||||
|
|
||||||
|
import { T9n } from 'features/T9n' |
||||||
|
|
||||||
|
import { Steps } from '../../config' |
||||||
|
|
||||||
|
const NavButton = styled.button` |
||||||
|
padding: 0; |
||||||
|
border: none; |
||||||
|
font-size: 12px; |
||||||
|
font-weight: 700; |
||||||
|
white-space: nowrap; |
||||||
|
text-transform: uppercase; |
||||||
|
text-decoration: none; |
||||||
|
background: none; |
||||||
|
cursor: pointer; |
||||||
|
|
||||||
|
${isMobileDevice |
||||||
|
? css` |
||||||
|
font-size: 15px; |
||||||
|
` |
||||||
|
: ''} |
||||||
|
` |
||||||
|
|
||||||
|
type PrevButtonProps = { |
||||||
|
isLastStep?: boolean, |
||||||
|
} |
||||||
|
|
||||||
|
export const PrevButton = styled(NavButton)<PrevButtonProps>` |
||||||
|
color: rgba(0, 0, 0, 0.5); |
||||||
|
` |
||||||
|
|
||||||
|
export const NextButton = styled(NavButton)` |
||||||
|
color: #294FC3; |
||||||
|
` |
||||||
|
|
||||||
|
export const SkipTour = styled.button` |
||||||
|
margin-top: -2px; |
||||||
|
padding: 0; |
||||||
|
border: none; |
||||||
|
font-weight: 400; |
||||||
|
font-size: 12px; |
||||||
|
color: rgba(0, 0, 0, 0.5); |
||||||
|
text-decoration: underline; |
||||||
|
text-transform: uppercase; |
||||||
|
cursor: pointer; |
||||||
|
background: none; |
||||||
|
|
||||||
|
${isMobileDevice |
||||||
|
? css` |
||||||
|
font-size: 15px; |
||||||
|
` |
||||||
|
: ''} |
||||||
|
` |
||||||
|
|
||||||
|
export const Counter = styled.div` |
||||||
|
color: rgba(0, 0, 0, 0.5); |
||||||
|
font-size: 12px; |
||||||
|
font-weight: 700; |
||||||
|
white-space: nowrap; |
||||||
|
|
||||||
|
${isMobileDevice |
||||||
|
? css` |
||||||
|
font-size: 15px; |
||||||
|
` |
||||||
|
: ''} |
||||||
|
` |
||||||
|
|
||||||
|
type TitleProps = { |
||||||
|
alignLeft?: boolean, |
||||||
|
} |
||||||
|
|
||||||
|
export const Title = styled(T9n)<TitleProps>` |
||||||
|
display: block; |
||||||
|
margin-bottom: 10px; |
||||||
|
font-size: 14px; |
||||||
|
font-weight: 700; |
||||||
|
line-height: 17px; |
||||||
|
|
||||||
|
${isMobileDevice |
||||||
|
? css` |
||||||
|
flex: 1; |
||||||
|
display: flex; |
||||||
|
justify-content: center; |
||||||
|
flex-direction: column; |
||||||
|
padding: 0 3%; |
||||||
|
line-height: 20px; |
||||||
|
font-size: 16px; |
||||||
|
` |
||||||
|
: ''} |
||||||
|
|
||||||
|
${({ alignLeft }) => (alignLeft |
||||||
|
? css` |
||||||
|
padding: 0; |
||||||
|
` |
||||||
|
: '')} |
||||||
|
` |
||||||
|
|
||||||
|
export const Body = styled.div`
|
||||||
|
margin-bottom: 15px; |
||||||
|
` |
||||||
|
|
||||||
|
export const BodyText = styled(T9n)` |
||||||
|
font-size: 14px; |
||||||
|
line-height: 17px; |
||||||
|
|
||||||
|
${isMobileDevice |
||||||
|
? css` |
||||||
|
line-height: 20px; |
||||||
|
font-size: 16px; |
||||||
|
` |
||||||
|
: ''} |
||||||
|
` |
||||||
|
|
||||||
|
type ActionButtonsContainerProps = { |
||||||
|
step: Steps, |
||||||
|
} |
||||||
|
|
||||||
|
export const ActionButtonsContainer = styled.div<ActionButtonsContainerProps>` |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
gap: 24px; |
||||||
|
padding: 0; |
||||||
|
justify-content: space-between; |
||||||
|
|
||||||
|
${isMobileDevice |
||||||
|
? css` |
||||||
|
padding: 0 5%; |
||||||
|
margin: auto; |
||||||
|
gap: 20px; |
||||||
|
justify-content: center; |
||||||
|
` |
||||||
|
: ''} |
||||||
|
|
||||||
|
${({ step }) => (isMobileDevice && step === Steps.Start |
||||||
|
? css` |
||||||
|
justify-content: space-between; |
||||||
|
padding: 0; |
||||||
|
` |
||||||
|
: '')} |
||||||
|
|
||||||
|
${({ step }) => (isMobileDevice && (includes([Steps.FinalStats, Steps.Welcome], step)) |
||||||
|
? css` |
||||||
|
padding: 0 20%; |
||||||
|
` |
||||||
|
: '')} |
||||||
|
` |
||||||
|
|
||||||
|
export const ArrowWrapper = styled.svg` |
||||||
|
position: absolute; |
||||||
|
top: 24px; |
||||||
|
right: 15px; |
||||||
|
` |
||||||
@ -0,0 +1,100 @@ |
|||||||
|
import { memo, useRef } from 'react' |
||||||
|
|
||||||
|
import { useTour } from '@reactour/tour' |
||||||
|
|
||||||
|
import styled, { css, keyframes } from 'styled-components/macro' |
||||||
|
|
||||||
|
import { isMobileDevice } from 'config' |
||||||
|
|
||||||
|
import { Steps } from '../../config' |
||||||
|
|
||||||
|
type WrapperProps = { |
||||||
|
step: number, |
||||||
|
} |
||||||
|
|
||||||
|
const getBaseSize = ({ step }: WrapperProps) => { |
||||||
|
let baseSize = isMobileDevice ? 57 : 55 |
||||||
|
|
||||||
|
switch (step) { |
||||||
|
case Steps.ClickToWatchPlaylist: |
||||||
|
if (isMobileDevice) baseSize = 39 |
||||||
|
break |
||||||
|
case Steps.TeamsTab: |
||||||
|
case Steps.PlayersTab: |
||||||
|
case Steps.ShowMoreStats: |
||||||
|
if (isMobileDevice) baseSize = 75 |
||||||
|
break |
||||||
|
case isMobileDevice ? Steps.ShowLessStats : Steps.FinalStats: |
||||||
|
case isMobileDevice ? Steps.FinalStats : Steps.CurrentStats: |
||||||
|
baseSize = 118 |
||||||
|
break |
||||||
|
|
||||||
|
default: |
||||||
|
} |
||||||
|
|
||||||
|
return baseSize |
||||||
|
} |
||||||
|
|
||||||
|
const getAnimation = ({ step }: WrapperProps) => { |
||||||
|
const baseSize = getBaseSize({ step }) |
||||||
|
|
||||||
|
return keyframes` |
||||||
|
to { |
||||||
|
scale: ${(baseSize + 5) / baseSize}; |
||||||
|
} |
||||||
|
` |
||||||
|
} |
||||||
|
|
||||||
|
const Wrapper = styled.div<WrapperProps>` |
||||||
|
position: absolute; |
||||||
|
top: 50%; |
||||||
|
left: 50%; |
||||||
|
display: block; |
||||||
|
width: ${getBaseSize}px; |
||||||
|
height: ${getBaseSize}px; |
||||||
|
border-radius: 100%; |
||||||
|
border: 1px solid #0057FF; |
||||||
|
translate: -50% -50%; |
||||||
|
background: radial-gradient(50% 50% at 50% 50%, rgba(0, 87, 255, 0) 70.25%, rgba(0, 87, 255, 0.4) 100%); |
||||||
|
animation: ${getAnimation} 0.8s ease-in-out infinite alternate; |
||||||
|
z-index: 9999; |
||||||
|
|
||||||
|
${({ step }) => { |
||||||
|
switch (step) { |
||||||
|
case Steps.ShowMoreStats: |
||||||
|
return css`left: 3px;` |
||||||
|
|
||||||
|
case Steps.ShowLessStats: |
||||||
|
return isMobileDevice |
||||||
|
? '' |
||||||
|
: css`left: 0;` |
||||||
|
|
||||||
|
case isMobileDevice ? Steps.ShowLessStats : Steps.FinalStats: |
||||||
|
case isMobileDevice ? Steps.FinalStats : Steps.CurrentStats: |
||||||
|
return css` |
||||||
|
right: -14px; |
||||||
|
left: auto; |
||||||
|
translate: 0 -50%; |
||||||
|
` |
||||||
|
|
||||||
|
default: |
||||||
|
return '' |
||||||
|
} |
||||||
|
}} |
||||||
|
` |
||||||
|
|
||||||
|
const SpotlightFC = () => { |
||||||
|
const ref = useRef<HTMLDivElement>(null) |
||||||
|
|
||||||
|
const { currentStep } = useTour() |
||||||
|
|
||||||
|
return ( |
||||||
|
<Wrapper |
||||||
|
ref={ref} |
||||||
|
step={currentStep} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export const Spotlight = memo(SpotlightFC) |
||||||
|
|
||||||
@ -0,0 +1,2 @@ |
|||||||
|
export * from './ContentComponent' |
||||||
|
export * from './Spotlight' |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
export enum Steps { |
||||||
|
Start, |
||||||
|
Welcome, |
||||||
|
TeamsTab, |
||||||
|
ClickToWatchPlaylist, |
||||||
|
PlayersTab, |
||||||
|
ShowMoreStats, |
||||||
|
ShowLessStats, |
||||||
|
FinalStats, |
||||||
|
CurrentStats, |
||||||
|
} |
||||||
|
|
||||||
|
export const TOUR_COMPLETED_STORAGE_KEY = 'tour_completed' |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
export * from './config' |
||||||
|
export * from './TourProvider' |
||||||
|
export * from './components' |
||||||
@ -0,0 +1,267 @@ |
|||||||
|
/* eslint-disable no-param-reassign */ |
||||||
|
import { isIOS } from 'config' |
||||||
|
|
||||||
|
type BodyScrollOptions = { |
||||||
|
allowTouchMove?: ((el: EventTarget) => boolean) | undefined, |
||||||
|
reserveScrollBarGap?: boolean | undefined, |
||||||
|
} |
||||||
|
|
||||||
|
let previousBodyPosition: { |
||||||
|
left: string, |
||||||
|
position: string, |
||||||
|
right: string, |
||||||
|
top: string, |
||||||
|
} | undefined |
||||||
|
|
||||||
|
let locks: Array<{ |
||||||
|
options: BodyScrollOptions, |
||||||
|
targetElement: HTMLElement | Element, |
||||||
|
}> = [] |
||||||
|
|
||||||
|
let initialClientY = -1 |
||||||
|
let documentListenerAdded = false |
||||||
|
let previousBodyOverflowSetting: string | undefined |
||||||
|
let previousBodyPaddingRight: string | undefined |
||||||
|
|
||||||
|
// returns true if `el` should be allowed to receive touchmove events.
|
||||||
|
const allowTouchMove = (el: EventTarget | null) => locks.some((lock) => { |
||||||
|
if (el && lock.options.allowTouchMove && lock.options.allowTouchMove(el)) { |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
return false |
||||||
|
}) |
||||||
|
|
||||||
|
const preventDefault = (rawEvent?: Event) => { |
||||||
|
const e = rawEvent || window.event |
||||||
|
|
||||||
|
// For the case whereby consumers adds a touchmove event listener to document.
|
||||||
|
// Recall that we do document.addEventListener('touchmove', preventDefault, { passive: false })
|
||||||
|
// in disableBodyScroll - so if we provide this opportunity to allowTouchMove, then
|
||||||
|
// the touchmove event on document will break.
|
||||||
|
// @ts-expect-error
|
||||||
|
if (allowTouchMove(e.target)) { |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
/** Do not prevent if the event has more than one touch |
||||||
|
* (usually meaning this is a multi touch gesture like pinch to zoom). |
||||||
|
* */ |
||||||
|
// @ts-expect-error
|
||||||
|
if (e.touches.length > 1) return true |
||||||
|
// @ts-expect-error
|
||||||
|
if (e.preventDefault) e.preventDefault() |
||||||
|
|
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
const setPositionFixed = () => window.requestAnimationFrame(() => { |
||||||
|
// If previousBodyPosition is already set, don't set it again.
|
||||||
|
if (previousBodyPosition === undefined) { |
||||||
|
previousBodyPosition = { |
||||||
|
left: document.body.style.left, |
||||||
|
position: document.body.style.position, |
||||||
|
right: document.body.style.right, |
||||||
|
top: document.body.style.top, |
||||||
|
} |
||||||
|
|
||||||
|
// Update the dom inside an animation frame
|
||||||
|
const { |
||||||
|
innerHeight, |
||||||
|
scrollX, |
||||||
|
scrollY, |
||||||
|
} = window |
||||||
|
document.body.style.position = 'fixed' |
||||||
|
// @ts-expect-error
|
||||||
|
document.body.style.top = -scrollY |
||||||
|
// @ts-expect-error
|
||||||
|
document.body.style.left = -scrollX |
||||||
|
// @ts-expect-error
|
||||||
|
document.body.style.right = 0 |
||||||
|
|
||||||
|
setTimeout(() => window.requestAnimationFrame(() => { |
||||||
|
// Attempt to check if the bottom bar appeared due to the position change
|
||||||
|
const bottomBarHeight = innerHeight - window.innerHeight |
||||||
|
if (bottomBarHeight && scrollY >= innerHeight) { |
||||||
|
// Move the content further up so that the bottom bar doesn't hide it
|
||||||
|
// @ts-expect-error
|
||||||
|
document.body.style.top = -(scrollY + bottomBarHeight) |
||||||
|
} |
||||||
|
}), 300) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const setOverflowHidden = (options?: BodyScrollOptions) => { |
||||||
|
// If previousBodyPaddingRight is already set, don't set it again.
|
||||||
|
if (previousBodyPaddingRight === undefined) { |
||||||
|
const reserveScrollBarGap = !!options && options.reserveScrollBarGap === true |
||||||
|
const scrollBarGap = window.innerWidth - document.documentElement.clientWidth |
||||||
|
|
||||||
|
if (reserveScrollBarGap && scrollBarGap > 0) { |
||||||
|
const computedBodyPaddingRight = parseInt(window.getComputedStyle(document.body).getPropertyValue('padding-right'), 10) |
||||||
|
previousBodyPaddingRight = document.body.style.paddingRight |
||||||
|
document.body.style.paddingRight = `${computedBodyPaddingRight + scrollBarGap}px` |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// If previousBodyOverflowSetting is already set, don't set it again.
|
||||||
|
if (previousBodyOverflowSetting === undefined) { |
||||||
|
previousBodyOverflowSetting = document.body.style.overflow |
||||||
|
document.body.style.overflow = 'hidden' |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#Problems_and_solutions
|
||||||
|
const isTargetElementTotallyScrolled = (targetElement: HTMLElement | Element | null) => ( |
||||||
|
targetElement |
||||||
|
? targetElement.scrollHeight - targetElement.scrollTop <= targetElement.clientHeight |
||||||
|
: false |
||||||
|
) |
||||||
|
|
||||||
|
const handleScroll = (event: TouchEvent, targetElement: HTMLElement | Element | null) => { |
||||||
|
const clientY = event.targetTouches[0].clientY - initialClientY |
||||||
|
|
||||||
|
if (allowTouchMove(event.target)) { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
if (targetElement && targetElement.scrollTop === 0 && clientY > 0) { |
||||||
|
// element is at the top of its scroll.
|
||||||
|
return preventDefault(event) |
||||||
|
} |
||||||
|
|
||||||
|
if (isTargetElementTotallyScrolled(targetElement) && clientY < 0) { |
||||||
|
// element is at the bottom of its scroll.
|
||||||
|
return preventDefault(event) |
||||||
|
} |
||||||
|
|
||||||
|
event.stopPropagation() |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
const restorePositionSetting = () => { |
||||||
|
if (previousBodyPosition !== undefined) { |
||||||
|
// Convert the position from "px" to Int
|
||||||
|
const y = -parseInt(document.body.style.top, 10) |
||||||
|
const x = -parseInt(document.body.style.left, 10) |
||||||
|
|
||||||
|
// Restore styles
|
||||||
|
document.body.style.position = previousBodyPosition.position |
||||||
|
document.body.style.top = previousBodyPosition.top |
||||||
|
document.body.style.left = previousBodyPosition.left |
||||||
|
document.body.style.right = previousBodyPosition.right |
||||||
|
|
||||||
|
// Restore scroll
|
||||||
|
window.scrollTo(x, y) |
||||||
|
|
||||||
|
previousBodyPosition = undefined |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const restoreOverflowSetting = () => { |
||||||
|
if (previousBodyPaddingRight !== undefined) { |
||||||
|
document.body.style.paddingRight = previousBodyPaddingRight |
||||||
|
|
||||||
|
// Restore previousBodyPaddingRight to undefined so setOverflowHidden knows it
|
||||||
|
// can be set again.
|
||||||
|
previousBodyPaddingRight = undefined |
||||||
|
} |
||||||
|
|
||||||
|
if (previousBodyOverflowSetting !== undefined) { |
||||||
|
document.body.style.overflow = previousBodyOverflowSetting |
||||||
|
|
||||||
|
// Restore previousBodyOverflowSetting to undefined
|
||||||
|
// so setOverflowHidden knows it can be set again.
|
||||||
|
previousBodyOverflowSetting = undefined |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Enables body scroll locking without breaking scrolling of a target element
|
||||||
|
export const enableBodyScroll = (targetElement: HTMLElement | Element) => { |
||||||
|
if (!targetElement) { |
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('enableBodyScroll unsuccessful - targetElement must be provided when calling enableBodyScroll on IOS devices.') |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
locks = locks.filter((lock) => lock.targetElement !== targetElement) |
||||||
|
|
||||||
|
if (isIOS) { |
||||||
|
// @ts-expect-error
|
||||||
|
targetElement.ontouchstart = null |
||||||
|
// @ts-expect-error
|
||||||
|
targetElement.ontouchmove = null |
||||||
|
|
||||||
|
if (documentListenerAdded && locks.length === 0) { |
||||||
|
document.removeEventListener( |
||||||
|
'touchmove', |
||||||
|
preventDefault, |
||||||
|
// @ts-expect-error
|
||||||
|
{ passive: false }, |
||||||
|
) |
||||||
|
documentListenerAdded = false |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (isIOS) { |
||||||
|
restorePositionSetting() |
||||||
|
} else { |
||||||
|
restoreOverflowSetting() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Disable body scroll locking
|
||||||
|
export const disableBodyScroll = ( |
||||||
|
targetElement: HTMLElement | Element, options?: BodyScrollOptions, |
||||||
|
) => { |
||||||
|
// targetElement must be provided
|
||||||
|
if (!targetElement) { |
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('disableBodyScroll unsuccessful - targetElement must be provided when calling disableBodyScroll on IOS devices.') |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// disableBodyScroll must not have been called on this targetElement before
|
||||||
|
if (locks.some((lock) => lock.targetElement === targetElement)) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const lock = { |
||||||
|
options: options || {}, |
||||||
|
targetElement, |
||||||
|
} |
||||||
|
|
||||||
|
locks = [...locks, lock] |
||||||
|
|
||||||
|
if (isIOS) { |
||||||
|
setPositionFixed() |
||||||
|
} else { |
||||||
|
setOverflowHidden(options) |
||||||
|
} |
||||||
|
|
||||||
|
if (isIOS) { |
||||||
|
// @ts-expect-error
|
||||||
|
targetElement.ontouchstart = (event) => { |
||||||
|
if (event.targetTouches.length === 1) { |
||||||
|
// detect single touch.
|
||||||
|
initialClientY = event.targetTouches[0].clientY |
||||||
|
} |
||||||
|
} |
||||||
|
// @ts-expect-error
|
||||||
|
targetElement.ontouchmove = (event) => { |
||||||
|
if (event.targetTouches.length === 1) { |
||||||
|
// detect single touch.
|
||||||
|
handleScroll(event, targetElement) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!documentListenerAdded) { |
||||||
|
document.addEventListener( |
||||||
|
'touchmove', |
||||||
|
preventDefault, |
||||||
|
{ passive: false }, |
||||||
|
) |
||||||
|
documentListenerAdded = true |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue