parent
18cc2b31eb
commit
033ae2603d
@ -0,0 +1,72 @@ |
||||
import type { Props, State } from '../types' |
||||
import { |
||||
createCustomWidthTransformationSet, |
||||
createClones, |
||||
createDefaultTransformationSet, |
||||
getElementDimensions, |
||||
getItemsCount, |
||||
getItemsOffset, |
||||
getTransitionProperty, |
||||
getTranslate3dProperty, |
||||
getItemsInSlide, |
||||
} from './elements' |
||||
import { getActiveIndex, getStartIndex } from './math' |
||||
|
||||
export const calculateInitialState = (props: Props, el: HTMLElement | null): State => { |
||||
const { |
||||
activeIndex: propsActiveIndex = 0, |
||||
animationDuration = 0, |
||||
customWidth = false, |
||||
infinite = false, |
||||
} = props |
||||
const clones = createClones(props) |
||||
const transition = getTransitionProperty() |
||||
const itemsCount = getItemsCount(props) |
||||
const itemsOffset = getItemsOffset(props) |
||||
const itemsInSlide = getItemsInSlide(itemsCount, props) |
||||
const startIndex = getStartIndex(propsActiveIndex, itemsCount) |
||||
const activeIndex = getActiveIndex({ |
||||
infinite, |
||||
itemsCount, |
||||
startIndex, |
||||
}) |
||||
const { width: listWidth } = getElementDimensions(el) |
||||
|
||||
const transformationSet = customWidth |
||||
? createCustomWidthTransformationSet({ |
||||
el, |
||||
infinite, |
||||
listWidth, |
||||
props, |
||||
}).coords |
||||
: createDefaultTransformationSet({ |
||||
children: clones, |
||||
infinite, |
||||
itemsInSlide, |
||||
listWidth, |
||||
props, |
||||
}).coords |
||||
|
||||
const translate3d = getTranslate3dProperty(activeIndex, { |
||||
customWidth, |
||||
infinite, |
||||
itemsInSlide, |
||||
itemsOffset, |
||||
transformationSet, |
||||
}) |
||||
|
||||
return { |
||||
activeIndex, |
||||
animationDuration, |
||||
clones, |
||||
customWidth, |
||||
infinite, |
||||
itemsCount, |
||||
itemsInSlide, |
||||
itemsOffset, |
||||
listWidth, |
||||
transformationSet, |
||||
transition, |
||||
translate3d, |
||||
} |
||||
} |
||||
@ -0,0 +1,282 @@ |
||||
import type { CSSProperties, ReactNode } from 'react' |
||||
import { Children } from 'react' |
||||
|
||||
import type { |
||||
Transformations, |
||||
ItemCoords, |
||||
Props, |
||||
State, |
||||
Transition, |
||||
} from '../types' |
||||
import { mapPartialCoords, mapPositionCoords } from './mappers' |
||||
import { getShiftIndex } from './math' |
||||
|
||||
export const getSlides = ({ children }: Props) => Children.toArray(children) |
||||
|
||||
export const getItemsCount = (props: Props) => getSlides(props).length |
||||
|
||||
export const getItemsOffset = ({ infinite }: Props) => (infinite ? 1 : 0) |
||||
|
||||
export const createClones = (props: Props) => { |
||||
const slides = getSlides(props) |
||||
|
||||
if (!props.infinite) return slides |
||||
|
||||
const itemsCount = getItemsCount(props) |
||||
const itemsOffset = getItemsOffset(props) |
||||
const itemsInSlide = getItemsInSlide(itemsCount, props) |
||||
const cursor = Math.min(itemsInSlide, itemsCount) + itemsOffset |
||||
|
||||
const clonesAfter = slides.slice(0, cursor) |
||||
const clonesBefore = slides.slice(-cursor) |
||||
|
||||
if (itemsOffset && itemsInSlide === itemsCount) { |
||||
const afterOffsetClone = slides[0] |
||||
const [beforeOffsetClone] = slides.slice(-1) |
||||
|
||||
clonesBefore.unshift(beforeOffsetClone) |
||||
clonesAfter.push(afterOffsetClone) |
||||
} |
||||
|
||||
return clonesBefore.concat(slides, clonesAfter) |
||||
} |
||||
|
||||
type CreateCustomWidthTransformationSetArgs = { |
||||
el: HTMLElement | null, |
||||
infinite: boolean, |
||||
listWidth: number, |
||||
props: Props, |
||||
} |
||||
|
||||
export const createCustomWidthTransformationSet = ({ |
||||
el, |
||||
infinite, |
||||
listWidth, |
||||
props, |
||||
}: CreateCustomWidthTransformationSetArgs) => { |
||||
let content = 0 |
||||
let partial = true |
||||
let coords: Array<ItemCoords> = [] |
||||
|
||||
const { spaceBetween = 0 } = props |
||||
|
||||
const children: Array<HTMLElement | Element> = Array.from(el?.children || []) |
||||
|
||||
coords = children.reduce<Array<ItemCoords>>(( |
||||
acc, |
||||
child, |
||||
i, |
||||
) => { |
||||
let position = 0 |
||||
const previewsChildCursor = i - 1 |
||||
const previewsChild = acc[previewsChildCursor] |
||||
const { width = 0 } = getElementDimensions(child?.firstChild as HTMLElement) |
||||
|
||||
content += width + spaceBetween |
||||
partial = listWidth >= content |
||||
|
||||
if (previewsChild) { |
||||
position = previewsChildCursor === 0 |
||||
? previewsChild.width + spaceBetween |
||||
: previewsChild.width + previewsChild.position + spaceBetween |
||||
} |
||||
|
||||
acc.push({ position, width }) |
||||
return acc |
||||
}, []) |
||||
|
||||
if (!infinite) { |
||||
if (partial) { |
||||
coords = mapPartialCoords(coords) |
||||
} else { |
||||
const position = content - listWidth |
||||
coords = mapPositionCoords(coords, position) |
||||
} |
||||
} |
||||
|
||||
return { |
||||
content, |
||||
coords, |
||||
partial, |
||||
} |
||||
} |
||||
|
||||
type CreateDefaultTransformationSetArgs = { |
||||
children: Array<ReactNode>, |
||||
infinite: boolean, |
||||
itemsInSlide: number, |
||||
listWidth: number, |
||||
props: Props, |
||||
} |
||||
|
||||
export const createDefaultTransformationSet = ({ |
||||
children, |
||||
infinite, |
||||
itemsInSlide, |
||||
listWidth, |
||||
props, |
||||
}: CreateDefaultTransformationSetArgs): Transformations => { |
||||
let content = 0 |
||||
let partial = true |
||||
let coords: Array<ItemCoords> = [] |
||||
|
||||
const { spaceBetween = 0 } = props |
||||
|
||||
const width = getItemWidth({ |
||||
galleryWidth: listWidth, |
||||
itemsInSlide, |
||||
props, |
||||
}) |
||||
|
||||
coords = children.reduce<Array<ItemCoords>>(( |
||||
acc, |
||||
_, |
||||
i, |
||||
) => { |
||||
let position = 0 |
||||
const previewsChild = acc[i - 1] |
||||
|
||||
content += width + spaceBetween |
||||
partial = listWidth >= content |
||||
|
||||
if (previewsChild) { |
||||
position = width + spaceBetween + previewsChild.position || 0 |
||||
} |
||||
|
||||
acc.push({ position, width }) |
||||
return acc |
||||
}, []) |
||||
|
||||
if (!infinite) { |
||||
if (partial) { |
||||
coords = mapPartialCoords(coords) |
||||
} else { |
||||
const position = content - listWidth |
||||
coords = mapPositionCoords(coords, position) |
||||
} |
||||
} |
||||
|
||||
return { |
||||
content, |
||||
coords, |
||||
partial, |
||||
} |
||||
} |
||||
|
||||
type GetItemWidthArgs = { |
||||
galleryWidth: number, |
||||
itemsInSlide: number, |
||||
props: Props, |
||||
} |
||||
|
||||
export const getItemWidth = ({ |
||||
galleryWidth, |
||||
itemsInSlide, |
||||
props: { spaceBetween = 0 }, |
||||
}: GetItemWidthArgs) => (itemsInSlide > 0 |
||||
? (galleryWidth - spaceBetween * (itemsInSlide - 1)) / itemsInSlide |
||||
: galleryWidth) |
||||
|
||||
export const getElementDimensions = (element: HTMLElement | null) => { |
||||
const { height, width } = element?.getBoundingClientRect() || { height: 0, width: 0 } |
||||
|
||||
return { height, width } |
||||
} |
||||
|
||||
export const getTransitionProperty = (options?: Transition): string => { |
||||
const { animationDuration = 0, animationTimingFunction = 'ease' } = options || {} |
||||
return `transform ${animationDuration}ms ${animationTimingFunction} 0ms` |
||||
} |
||||
|
||||
export const getListElementStyles = ( |
||||
{ translate3d }: Partial<State>, |
||||
currentStyles: CSSProperties, |
||||
): CSSProperties => { |
||||
const transform = `translate3d(${-(translate3d || 0)}px, 0, 0)` |
||||
|
||||
return { ...currentStyles, transform } |
||||
} |
||||
|
||||
export const getItemStyles = (index: number, state: State): CSSProperties => { |
||||
const { transformationSet } = state |
||||
const { width } = transformationSet[index] || {} |
||||
|
||||
return { |
||||
width, |
||||
} |
||||
} |
||||
|
||||
export const getTranslate3dProperty = (nextIndex: number, state: Partial<State>) => { |
||||
let cursor = nextIndex |
||||
|
||||
const { |
||||
infinite, |
||||
itemsInSlide = 0, |
||||
itemsOffset = 0, |
||||
transformationSet = [], |
||||
} = state |
||||
|
||||
if (infinite) { |
||||
cursor = nextIndex + getShiftIndex(itemsInSlide, itemsOffset) |
||||
} |
||||
|
||||
return (transformationSet[cursor] || {}).position || 0 |
||||
} |
||||
|
||||
export const isDisplayedItem = (i = 0, state: State) => { |
||||
const { |
||||
activeIndex, |
||||
customWidth, |
||||
infinite, |
||||
itemsInSlide, |
||||
itemsOffset, |
||||
} = state |
||||
|
||||
const shiftIndex = getShiftIndex(itemsInSlide, itemsOffset) |
||||
|
||||
if (customWidth && infinite) { |
||||
return i - shiftIndex === activeIndex + itemsOffset |
||||
} |
||||
|
||||
const index = activeIndex + shiftIndex |
||||
|
||||
if (!infinite) { |
||||
return i >= activeIndex && i < index |
||||
} |
||||
|
||||
return i >= index && i < index + itemsInSlide |
||||
} |
||||
|
||||
export const getItemsInSlide = (itemsCount: number, props: Props) => { |
||||
let itemsInSlide = 1 |
||||
|
||||
const { |
||||
breakpoints, |
||||
customWidth, |
||||
infinite, |
||||
} = props |
||||
|
||||
if (customWidth) { |
||||
return infinite ? itemsCount : itemsInSlide |
||||
} |
||||
|
||||
if (breakpoints) { |
||||
const configKeys = Object.keys(breakpoints) |
||||
|
||||
if (configKeys.length) { |
||||
configKeys.forEach((key) => { |
||||
if (Number(key) <= window.innerWidth) { |
||||
const { items, itemsFit = 'fill' } = breakpoints[key] |
||||
|
||||
if (itemsFit === 'contain') { |
||||
itemsInSlide = items |
||||
} else { |
||||
itemsInSlide = Math.min(items, itemsCount) |
||||
} |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
return itemsInSlide || 1 |
||||
} |
||||
@ -0,0 +1,4 @@ |
||||
export * from './common' |
||||
export * from './elements' |
||||
export * from './math' |
||||
export * from './mappers' |
||||
@ -0,0 +1,12 @@ |
||||
import type { ItemCoords } from '../types' |
||||
|
||||
export const mapPartialCoords = (coords: Array<ItemCoords>) => ( |
||||
coords.map(({ width }) => ({ position: 0, width })) |
||||
) |
||||
|
||||
export const mapPositionCoords = (coords: Array<ItemCoords>, position = 0) => coords.map((item) => { |
||||
if (item.position > position) { |
||||
return { ...item, position } |
||||
} |
||||
return item |
||||
}) |
||||
@ -0,0 +1,44 @@ |
||||
import type { ItemCoords } from '../types' |
||||
|
||||
export const getShiftIndex = (itemsInSlide = 0, itemsOffset = 0) => itemsInSlide + itemsOffset |
||||
|
||||
export const getStartIndex = (index = 0, itemsCount = 0) => { |
||||
if (itemsCount) { |
||||
if (index >= itemsCount) { |
||||
return itemsCount - 1 |
||||
} |
||||
|
||||
if (index > 0) { |
||||
return index |
||||
} |
||||
} |
||||
|
||||
return 0 |
||||
} |
||||
|
||||
export const getActiveIndex = ({ |
||||
infinite = false, |
||||
itemsCount = 0, |
||||
startIndex = 0, |
||||
}) => (infinite ? startIndex : getStartIndex(startIndex, itemsCount)) |
||||
|
||||
export const getUpdateSlidePositionIndex = (activeIndex: number, itemsCount: number) => { |
||||
if (activeIndex < 0) return itemsCount - 1 |
||||
if (activeIndex >= itemsCount) return 0 |
||||
|
||||
return activeIndex |
||||
} |
||||
|
||||
export const shouldRecalculateSlideIndex = (activeIndex: number, itemsCount: number) => ( |
||||
activeIndex < 0 || activeIndex >= itemsCount |
||||
) |
||||
|
||||
export const shouldCancelSlideAnimation = (activeIndex: number, itemsCount: number) => ( |
||||
activeIndex < 0 || activeIndex >= itemsCount |
||||
) |
||||
|
||||
export const getTransformationItemIndex = ( |
||||
transformationSet: Array<ItemCoords> = [], |
||||
position = 0, |
||||
) => transformationSet.findIndex((item) => item.position >= Math.abs(position)) |
||||
|
||||
@ -0,0 +1,160 @@ |
||||
import { |
||||
useRef, |
||||
useEffect, |
||||
useLayoutEffect, |
||||
type ReactNode, |
||||
} from 'react' |
||||
|
||||
import { KEYBOARD_KEYS } from 'config' |
||||
|
||||
import { useEventListener, useObjectState } from 'hooks' |
||||
|
||||
import type { State, Props } from './types' |
||||
import * as Utils from './helpers' |
||||
import { ListItem } from './styled' |
||||
|
||||
export const useCarousel = (props: Props) => { |
||||
const [state, setState] = useObjectState<State>(Utils.calculateInitialState(props, null)) |
||||
const isAnimationDisabledRef = useRef(false) |
||||
const slideEndTimeoutIdRef = useRef<number | null>(null) |
||||
const listElementRef = useRef<HTMLUListElement>(null) |
||||
const rootElementRef = useRef<HTMLDivElement>(null) |
||||
|
||||
const { |
||||
activeIndex, |
||||
animationDuration, |
||||
clones, |
||||
itemsCount, |
||||
itemsInSlide, |
||||
transition, |
||||
translate3d, |
||||
} = state |
||||
|
||||
const { |
||||
animationTimingFunction, |
||||
infinite, |
||||
onSlideChange, |
||||
useKeyboardNavigation, |
||||
} = props |
||||
|
||||
const listElementStyles = Utils.getListElementStyles({ translate3d }, { transition }) |
||||
|
||||
const clearSlideEndTimeout = () => { |
||||
slideEndTimeoutIdRef.current && clearTimeout(slideEndTimeoutIdRef.current) |
||||
slideEndTimeoutIdRef.current = null |
||||
} |
||||
|
||||
const slidePrev = () => { |
||||
const newActiveIndex = activeIndex - 1 |
||||
|
||||
handleSlideTo(newActiveIndex) |
||||
} |
||||
|
||||
const slideNext = () => { |
||||
const newActiveIndex = activeIndex + 1 |
||||
|
||||
handleSlideTo(newActiveIndex) |
||||
} |
||||
|
||||
const handleUpdateSlidePosition = (index: number) => { |
||||
const newTranslate3d = Utils.getTranslate3dProperty(index, state) |
||||
const newTransition = Utils.getTransitionProperty({ animationDuration: 0 }) |
||||
|
||||
setState({ |
||||
activeIndex: index, |
||||
transition: newTransition, |
||||
translate3d: newTranslate3d, |
||||
}) |
||||
} |
||||
|
||||
const handleBeforeSlideEnd = (index: number) => { |
||||
if (Utils.shouldRecalculateSlideIndex(index, itemsCount)) { |
||||
const nextIndex = Utils.getUpdateSlidePositionIndex(index, itemsCount) |
||||
handleUpdateSlidePosition(nextIndex) |
||||
} |
||||
|
||||
isAnimationDisabledRef.current = false |
||||
} |
||||
|
||||
const handleSlideTo = (newActiveIndex = 0) => { |
||||
if ( |
||||
isAnimationDisabledRef.current |
||||
|| newActiveIndex === activeIndex |
||||
|| (!infinite && Utils.shouldCancelSlideAnimation(newActiveIndex, itemsCount)) |
||||
) return |
||||
|
||||
isAnimationDisabledRef.current = true |
||||
clearSlideEndTimeout() |
||||
|
||||
const newTranslate3d = Utils.getTranslate3dProperty(newActiveIndex, state) |
||||
|
||||
const newTransition = Utils.getTransitionProperty({ |
||||
animationDuration, |
||||
animationTimingFunction, |
||||
}) |
||||
|
||||
onSlideChange?.(newActiveIndex) |
||||
|
||||
setState({ |
||||
activeIndex: newActiveIndex, |
||||
transition: newTransition, |
||||
translate3d: newTranslate3d, |
||||
}) |
||||
|
||||
slideEndTimeoutIdRef.current = setTimeout( |
||||
() => handleBeforeSlideEnd(newActiveIndex), |
||||
animationDuration, |
||||
) |
||||
} |
||||
|
||||
const renderItem = (item: ReactNode, index: number) => { |
||||
const styles = Utils.getItemStyles(index, state) |
||||
|
||||
return ( |
||||
<ListItem |
||||
key={index} |
||||
style={styles} |
||||
aria-hidden={!Utils.isDisplayedItem(index, state)} |
||||
> |
||||
{item} |
||||
</ListItem> |
||||
) |
||||
} |
||||
|
||||
useEventListener({ |
||||
callback: (e) => { |
||||
if (!useKeyboardNavigation) return |
||||
if (e.key === KEYBOARD_KEYS.ArrowLeft) slidePrev() |
||||
if (e.key === KEYBOARD_KEYS.ArrowRight) slideNext() |
||||
}, |
||||
event: 'keydown', |
||||
}) |
||||
|
||||
useLayoutEffect(() => { |
||||
const setInitialState = () => { |
||||
const initialState = Utils.calculateInitialState(props, listElementRef.current) |
||||
|
||||
setState(initialState) |
||||
} |
||||
|
||||
setInitialState() |
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.activeIndex]) |
||||
|
||||
useEffect(() => () => { |
||||
slideEndTimeoutIdRef.current && clearTimeout(slideEndTimeoutIdRef.current) |
||||
}, []) |
||||
|
||||
return { |
||||
activeIndex, |
||||
clones, |
||||
itemsCount, |
||||
itemsInSlide, |
||||
listElementRef, |
||||
listElementStyles, |
||||
renderItem, |
||||
rootElementRef, |
||||
slideNext, |
||||
slidePrev, |
||||
} |
||||
} |
||||
@ -0,0 +1,86 @@ |
||||
import { ArrowButton, Arrow } from 'features/HeaderFilters/components/DateFilter/styled' |
||||
|
||||
import type { Props } from './types' |
||||
import { useCarousel } from './hooks' |
||||
import { |
||||
Wrapper, |
||||
List, |
||||
ButtonsWrapper, |
||||
} from './styled' |
||||
|
||||
export * from './types' |
||||
|
||||
type NavButtonProps = { |
||||
direction: 'left' | 'right', |
||||
disabled?: boolean, |
||||
onClick: () => void, |
||||
} |
||||
|
||||
const NavButton = ({ |
||||
direction, |
||||
disabled, |
||||
onClick, |
||||
}: NavButtonProps) => ( |
||||
<ArrowButton |
||||
aria-label={direction === 'left' ? 'Previous' : 'Next'} |
||||
disabled={disabled} |
||||
onClick={onClick} |
||||
> |
||||
<Arrow direction={direction} /> |
||||
</ArrowButton> |
||||
) |
||||
|
||||
export const Carousel = (props: Props) => { |
||||
const { |
||||
infinite, |
||||
renderNextButton, |
||||
renderPrevButton, |
||||
} = props |
||||
|
||||
const { |
||||
activeIndex, |
||||
clones, |
||||
itemsCount, |
||||
itemsInSlide, |
||||
listElementRef, |
||||
listElementStyles, |
||||
renderItem, |
||||
rootElementRef, |
||||
slideNext, |
||||
slidePrev, |
||||
} = useCarousel(props) |
||||
|
||||
return ( |
||||
<Wrapper ref={rootElementRef}> |
||||
<List ref={listElementRef} style={listElementStyles}> |
||||
{clones.map(renderItem)} |
||||
</List> |
||||
<ButtonsWrapper> |
||||
{renderPrevButton |
||||
? renderPrevButton({ |
||||
disabled: !infinite && activeIndex === 0, |
||||
onClick: slidePrev, |
||||
}) |
||||
: ( |
||||
<NavButton |
||||
disabled={!infinite && activeIndex === 0} |
||||
onClick={slidePrev} |
||||
direction='left' |
||||
/> |
||||
)} |
||||
{renderNextButton |
||||
? renderNextButton({ |
||||
disabled: !infinite && itemsInSlide + activeIndex === itemsCount, |
||||
onClick: slideNext, |
||||
}) |
||||
: ( |
||||
<NavButton |
||||
disabled={!infinite && itemsInSlide + activeIndex === itemsCount} |
||||
onClick={slideNext} |
||||
direction='right' |
||||
/> |
||||
)} |
||||
</ButtonsWrapper> |
||||
</Wrapper> |
||||
) |
||||
} |
||||
@ -0,0 +1,25 @@ |
||||
import styled from 'styled-components/macro' |
||||
|
||||
export const Wrapper = styled.div` |
||||
width: 100%; |
||||
margin: auto; |
||||
overflow: hidden; |
||||
` |
||||
|
||||
export const List = styled.ul` |
||||
display: flex; |
||||
gap: 20px; |
||||
width: 100%; |
||||
height: 100%; |
||||
` |
||||
|
||||
export const ListItem = styled.li` |
||||
flex-shrink: 0; |
||||
height: 100%; |
||||
` |
||||
|
||||
export const ButtonsWrapper = styled.div` |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: center; |
||||
` |
||||
@ -0,0 +1,73 @@ |
||||
import type { ReactNode } from 'react' |
||||
|
||||
type RenderButtonArgs = { |
||||
disabled?: boolean, |
||||
onClick: () => void, |
||||
} |
||||
|
||||
export type Transition = { |
||||
animationDuration?: number, |
||||
animationTimingFunction?: string, |
||||
} |
||||
|
||||
export type Breakpoints = { |
||||
[key: string]: { |
||||
// Число элементов в слайде
|
||||
items: number, |
||||
// Определяет, как элемент должен заполнять контейнер в соответствии с шириной слайда
|
||||
itemsFit?: 'contain' | 'fill', |
||||
}, |
||||
} |
||||
|
||||
export type Transformations = { |
||||
content: number, |
||||
coords: Array<ItemCoords>, |
||||
partial: boolean, |
||||
} |
||||
|
||||
export type ItemCoords = { |
||||
position: number, |
||||
width: number, |
||||
} |
||||
|
||||
export type Props = { |
||||
/** Текущая позиция */ |
||||
activeIndex?: number, |
||||
/** Длительность анимации */ |
||||
animationDuration?: number, |
||||
/** animation-timing-function */ |
||||
animationTimingFunction?: string, |
||||
/** Объект с брэкпойнтами */ |
||||
breakpoints?: Breakpoints, |
||||
/** Элементы карусели */ |
||||
children: ReactNode, |
||||
/** Свойство позволяет задать свою ширину элементов карусели */ |
||||
customWidth?: boolean, |
||||
/** Бесконечный режим прокрутки */ |
||||
infinite?: boolean, |
||||
/** Колбэк при прокрутке */ |
||||
onSlideChange?: (activeIndex: number) => void, |
||||
/** Рендер-функция кнопки прокрутки вперед */ |
||||
renderNextButton?: (args: RenderButtonArgs) => ReactNode, |
||||
/** Рендер-функция кнопки прокрутки назад */ |
||||
renderPrevButton?: (args: RenderButtonArgs) => ReactNode, |
||||
/** Расстояние между элементами карусели */ |
||||
spaceBetween?: number, |
||||
/** Использование клавиатуры для навигации */ |
||||
useKeyboardNavigation?: boolean, |
||||
} |
||||
|
||||
export type State = { |
||||
activeIndex: number, |
||||
animationDuration?: number, |
||||
clones: Array<ReactNode>, |
||||
customWidth: boolean, |
||||
infinite?: boolean, |
||||
itemsCount: number, |
||||
itemsInSlide: number, |
||||
itemsOffset: number, |
||||
listWidth: number, |
||||
transformationSet: Array<ItemCoords>, |
||||
transition: string, |
||||
translate3d: number, |
||||
} |
||||
@ -0,0 +1,94 @@ |
||||
import { useState, type MouseEvent } from 'react' |
||||
|
||||
import { useToggle } from 'hooks' |
||||
|
||||
import type { MatchPackage } from 'features/BuyMatchPopup/types' |
||||
import { SubscriptionType } from 'features/BuyMatchPopup/types' |
||||
import { Price } from 'features/Price' |
||||
import { T9n } from 'features/T9n' |
||||
import { useBuyMatchPopupStore } from 'features/BuyMatchPopup/store' |
||||
import { ArrowLoader } from 'features/ArrowLoader' |
||||
|
||||
import { usePackage } from '../RegularPackage/usePackage' |
||||
import { |
||||
Wrapper, |
||||
Header, |
||||
Title, |
||||
Button, |
||||
Content, |
||||
Description, |
||||
Body, |
||||
Border, |
||||
SubscriptionTypeText, |
||||
BestChoice, |
||||
} from './styled' |
||||
|
||||
type Props = { |
||||
buttonId?: string, |
||||
buttonLexic?: string, |
||||
matchPackage: MatchPackage, |
||||
onButtonClick?: (e: MouseEvent<HTMLButtonElement>, matchPackage?: MatchPackage) => void, |
||||
} |
||||
|
||||
export const PackageMobile = ({ |
||||
buttonId, |
||||
buttonLexic, |
||||
matchPackage, |
||||
onButtonClick, |
||||
}: Props) => { |
||||
const { |
||||
matchPackages, |
||||
setSelectedPackage, |
||||
} = useBuyMatchPopupStore() |
||||
|
||||
const isSinglePackage = matchPackages.length === 1 |
||||
|
||||
const { isOpen, toggle } = useToggle(isSinglePackage) |
||||
const [loader, setLoader] = useState(false) |
||||
|
||||
const { firstDescription, priceTextTopLexic } = usePackage(matchPackage) |
||||
|
||||
const handleButtonClick = (e: MouseEvent<HTMLButtonElement>) => { |
||||
setSelectedPackage(matchPackage) |
||||
onButtonClick?.(e, matchPackage) |
||||
setLoader(true) |
||||
} |
||||
|
||||
return ( |
||||
<Wrapper> |
||||
<Header |
||||
isOpen={isOpen} |
||||
onClick={isSinglePackage ? undefined : toggle} |
||||
> |
||||
<Title t={matchPackage.originalObject.sub.lexic1} /> |
||||
<Price |
||||
amount={matchPackage.originalObject.price} |
||||
currency={matchPackage.currency} |
||||
perPeriod={matchPackage.isMonthSubscription ? `per_${SubscriptionType.Month}` : undefined} |
||||
/> |
||||
</Header> |
||||
{isOpen && <Border />} |
||||
<Content isOpen={isOpen}> |
||||
<Body> |
||||
<Description> |
||||
{firstDescription} |
||||
</Description> |
||||
<Description> |
||||
<T9n t={matchPackage.originalObject.sub.lexic3} /> |
||||
</Description> |
||||
{matchPackage.isMainPackage && ( |
||||
<BestChoice> |
||||
<T9n t='best_choice' /> |
||||
</BestChoice> |
||||
)} |
||||
<SubscriptionTypeText t={priceTextTopLexic} /> |
||||
</Body> |
||||
<Button id={buttonId} onClick={handleButtonClick}> |
||||
{loader |
||||
? <ArrowLoader disabled /> |
||||
: <T9n t={buttonLexic || ''} />} |
||||
</Button> |
||||
</Content> |
||||
</Wrapper> |
||||
) |
||||
} |
||||
@ -0,0 +1,125 @@ |
||||
import styled, { css } from 'styled-components/macro' |
||||
|
||||
import { T9n } from 'features/T9n' |
||||
import { ButtonSolid } from 'features/Common' |
||||
import { PriceDetails, PriceAmount } from 'features/Price/styled' |
||||
|
||||
export const Wrapper = styled.div` |
||||
width: 100%; |
||||
border-radius: 2px 2px 0px 0px; |
||||
background-color: #414141; |
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.3); |
||||
` |
||||
|
||||
type HeaderProps = { |
||||
isOpen?: boolean, |
||||
} |
||||
|
||||
export const Header = styled.div<HeaderProps>` |
||||
position: relative; |
||||
display: flex; |
||||
justify-content: space-between; |
||||
height: 52px; |
||||
align-items: center; |
||||
padding: 0 15px 0 20px; |
||||
|
||||
${PriceAmount} { |
||||
font-size: 24px; |
||||
position: absolute; |
||||
right: 46px; |
||||
top: 50%; |
||||
translate: 0 -50%; |
||||
} |
||||
|
||||
${PriceDetails} { |
||||
position: absolute; |
||||
top: 12px; |
||||
left: calc(100% - 48px); |
||||
} |
||||
|
||||
${({ isOpen }) => (isOpen |
||||
? '' |
||||
: css` |
||||
${PriceDetails}, ${PriceAmount}, ${Title} { |
||||
color: #D9D9D9; |
||||
} |
||||
`)}
|
||||
` |
||||
|
||||
export const Border = styled.div` |
||||
height: 0.66px; |
||||
background: linear-gradient(90deg, transparent 0%, #656565 50%, transparent 100%); |
||||
` |
||||
|
||||
export const Title = styled(T9n)` |
||||
max-width: calc(100% - 112px); |
||||
font-weight: 600; |
||||
font-size: 14px; |
||||
color: ${({ theme }) => theme.colors.white}; |
||||
overflow: hidden; |
||||
` |
||||
|
||||
type ContentProps = { |
||||
isOpen?: boolean, |
||||
} |
||||
|
||||
export const Content = styled.div<ContentProps>` |
||||
height: ${({ isOpen }) => (isOpen ? 'auto' : 0)}; |
||||
` |
||||
|
||||
export const Body = styled.div` |
||||
position: relative; |
||||
padding: 8px 24px 30px; |
||||
background-color: #414141; |
||||
` |
||||
|
||||
export const Description = styled.p` |
||||
width: calc(100% - 90px); |
||||
margin-bottom: 15px; |
||||
font-weight: 500; |
||||
font-size: 12px; |
||||
line-height: 16px; |
||||
color: #C7C7C7; |
||||
|
||||
:empty, :has(span:empty) { |
||||
display: none; |
||||
} |
||||
|
||||
:last-of-type { |
||||
margin-bottom: 0; |
||||
} |
||||
` |
||||
|
||||
export const Button = styled(ButtonSolid)` |
||||
width: 100%; |
||||
height: 48px; |
||||
` |
||||
|
||||
export const SubscriptionTypeText = styled(T9n)` |
||||
position: absolute; |
||||
right: 16px; |
||||
bottom: 12px; |
||||
font-size: 12px; |
||||
font-weight: 600; |
||||
color: ${({ theme }) => theme.colors.white}; |
||||
` |
||||
|
||||
export const BestChoice = styled.div` |
||||
position: absolute; |
||||
bottom: 40px; |
||||
right: 0; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: flex-end; |
||||
width: 108px; |
||||
height: 20px; |
||||
padding-right: 16px; |
||||
border-radius: 1px 0px 0px 1px; |
||||
font-weight: 600; |
||||
font-size: 12px; |
||||
font-variant: all-small-caps; |
||||
letter-spacing: 0.03em; |
||||
color: #464646; |
||||
background: linear-gradient(270deg, rgba(253, 253, 254, 0.8) 0%, rgba(253, 253, 254, 0) 97.42%); |
||||
backdrop-filter: blur(3px); |
||||
` |
||||
@ -0,0 +1,22 @@ |
||||
import styled from 'styled-components/macro' |
||||
|
||||
import { isMobileDevice } from 'config' |
||||
|
||||
import { Footer as FooterBase } from 'features/BuyMatchPopup/styled' |
||||
|
||||
export const ChooseSub = styled.div` |
||||
font-weight: 700; |
||||
font-size: ${isMobileDevice ? 16 : 24}px; |
||||
margin: auto; |
||||
color: ${({ theme }) => theme.colors.white}; |
||||
` |
||||
|
||||
type FooterProps = { |
||||
hasCard?: boolean, |
||||
} |
||||
|
||||
export const Footer = styled(FooterBase)<FooterProps>` |
||||
flex-direction: column; |
||||
align-items: center; |
||||
margin-top: ${({ hasCard }) => (hasCard ? 20 : 40)}px; |
||||
` |
||||
@ -1,41 +1,181 @@ |
||||
import styled from 'styled-components/macro' |
||||
import type { MouseEvent } from 'react' |
||||
import { useMemo } from 'react' |
||||
|
||||
import { PaymentPeriodTabs } from 'features/PaymentPeriodTabs' |
||||
import reduce from 'lodash/reduce' |
||||
import sortBy from 'lodash/sortBy' |
||||
import without from 'lodash/without' |
||||
|
||||
import { isMobileDevice } from 'config' |
||||
|
||||
import type { MatchPackage } from 'features/BuyMatchPopup/types' |
||||
import { useLexicsConfig } from 'features/LexicsStore' |
||||
|
||||
import { Carousel, type Breakpoints } from 'components/Carousel' |
||||
|
||||
import { useBuyMatchPopupStore } from '../../store' |
||||
import { PackagesList } from '../PackagesList' |
||||
|
||||
const Wrapper = styled.div` |
||||
width: 100%; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
` |
||||
|
||||
export const Packages = () => { |
||||
const { |
||||
onPackageSelect, |
||||
onPeriodSelect, |
||||
selectedPackage, |
||||
selectedPeriod, |
||||
selectedSubscription, |
||||
subscriptions, |
||||
} = useBuyMatchPopupStore() |
||||
|
||||
if (!selectedSubscription) return null |
||||
import { RegularPackage } from '../RegularPackage' |
||||
import { PackageMobile } from '../PackageMobile' |
||||
import { |
||||
Wrapper, |
||||
List, |
||||
PrevButton, |
||||
NextButton, |
||||
Arrow, |
||||
ListItem, |
||||
} from './styled' |
||||
import { SinglePackage } from '../SinglePackage' |
||||
|
||||
const breakpoints: Breakpoints = { |
||||
1000: { items: 3 }, |
||||
1280: { items: 4 }, |
||||
1400: { items: 5 }, |
||||
} |
||||
|
||||
type ButtonProps = { |
||||
disabled?: boolean, |
||||
onClick: () => void, |
||||
} |
||||
|
||||
const renderPrevButton = ({ disabled, onClick }: ButtonProps) => ( |
||||
<PrevButton |
||||
aria-label='Previous' |
||||
disabled={disabled} |
||||
onClick={onClick} |
||||
> |
||||
<Arrow direction='left' /> |
||||
</PrevButton> |
||||
) |
||||
|
||||
const renderNextButton = ({ disabled, onClick }: ButtonProps) => ( |
||||
<NextButton |
||||
aria-label='Next' |
||||
disabled={disabled} |
||||
onClick={onClick} |
||||
> |
||||
<Arrow direction='right' /> |
||||
</NextButton> |
||||
) |
||||
|
||||
type Props = { |
||||
buttonId?: string, |
||||
buttonLexic?: string, |
||||
onButtonClick?: (e: MouseEvent<HTMLButtonElement>, matchPackage?: MatchPackage) => void, |
||||
} |
||||
|
||||
export const Packages = ({ |
||||
buttonId, |
||||
buttonLexic, |
||||
onButtonClick, |
||||
}: Props) => { |
||||
const { matchPackages } = useBuyMatchPopupStore() |
||||
|
||||
const hasOnlyOneSubscription = matchPackages.length === 1 |
||||
const hasMoreThanFiveSubscriptions = matchPackages.length > 5 |
||||
|
||||
const getSortedPackages = () => { |
||||
let temp = sortBy(matchPackages, 'order') |
||||
const mainPackage = matchPackages.find(({ isMainPackage }) => isMainPackage) |
||||
|
||||
if (mainPackage && matchPackages.length > 1) { |
||||
const index = matchPackages.length >= 2 && matchPackages.length < 4 ? 1 : 2 |
||||
|
||||
temp = without(temp, mainPackage) |
||||
temp.splice( |
||||
index, |
||||
0, |
||||
mainPackage, |
||||
) |
||||
} |
||||
|
||||
return temp |
||||
} |
||||
|
||||
const sortedPackages = getSortedPackages() |
||||
|
||||
const lexicsIds = useMemo( |
||||
() => [...reduce<MatchPackage, Set<number>>( |
||||
matchPackages, |
||||
(acc, { originalObject: { sub } }) => { |
||||
acc.add(sub.lexic1) |
||||
sub.lexic2 && acc.add(sub.lexic2) |
||||
acc.add(sub.lexic3) |
||||
|
||||
return acc |
||||
}, |
||||
new Set(), |
||||
)], |
||||
[matchPackages], |
||||
) |
||||
|
||||
useLexicsConfig(lexicsIds) |
||||
|
||||
if (isMobileDevice) { |
||||
return ( |
||||
<Wrapper> |
||||
<PaymentPeriodTabs |
||||
onPeriodSelect={onPeriodSelect} |
||||
selectedPeriod={selectedPeriod} |
||||
selectedSubscription={selectedSubscription} |
||||
/> |
||||
<PackagesList |
||||
packages={subscriptions} |
||||
selectedPackage={selectedPackage} |
||||
onSelect={onPackageSelect} |
||||
<List> |
||||
{sortedPackages.map((matchPackage) => ( |
||||
<ListItem key={matchPackage.id}> |
||||
<PackageMobile |
||||
matchPackage={matchPackage} |
||||
onButtonClick={onButtonClick} |
||||
buttonId={buttonId} |
||||
buttonLexic={buttonLexic} |
||||
/> |
||||
</ListItem> |
||||
))} |
||||
</List> |
||||
</Wrapper> |
||||
) |
||||
} |
||||
|
||||
if (hasOnlyOneSubscription) { |
||||
return ( |
||||
<SinglePackage matchPackage={matchPackages[0]} /> |
||||
) |
||||
} |
||||
|
||||
const getWrapperWidth = () => { |
||||
switch (true) { |
||||
case !hasMoreThanFiveSubscriptions: |
||||
return undefined |
||||
|
||||
case window.innerWidth < 1500: |
||||
return window.innerWidth - 160 |
||||
|
||||
default: |
||||
return 1380 |
||||
} |
||||
} |
||||
|
||||
const wrapperWidth = getWrapperWidth() |
||||
|
||||
return ( |
||||
<Wrapper width={wrapperWidth}> |
||||
{hasMoreThanFiveSubscriptions |
||||
? ( |
||||
<Carousel |
||||
animationDuration={400} |
||||
infinite |
||||
useKeyboardNavigation |
||||
breakpoints={breakpoints} |
||||
spaceBetween={20} |
||||
renderPrevButton={renderPrevButton} |
||||
renderNextButton={renderNextButton} |
||||
> |
||||
{sortedPackages.map((matchPackage) => ( |
||||
<RegularPackage matchPackage={matchPackage} /> |
||||
))} |
||||
</Carousel> |
||||
) |
||||
: ( |
||||
<List> |
||||
{sortedPackages.map((matchPackage) => ( |
||||
<ListItem key={matchPackage.id}> |
||||
<RegularPackage matchPackage={matchPackage} /> |
||||
</ListItem> |
||||
))} |
||||
</List> |
||||
)} |
||||
</Wrapper> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,65 @@ |
||||
import styled, { css } from 'styled-components/macro' |
||||
|
||||
import { isMobileDevice } from 'config' |
||||
|
||||
import { ArrowButton, Arrow as ArrowBase } from 'features/HeaderFilters/components/DateFilter/styled' |
||||
|
||||
type WrapperProps = { |
||||
width?: number, |
||||
} |
||||
|
||||
export const Wrapper = styled.div<WrapperProps>` |
||||
width: ${({ width }) => (width ? `${width}px` : 'auto')}; |
||||
margin: auto; |
||||
` |
||||
|
||||
export const List = styled.ul` |
||||
display: flex; |
||||
gap: 20px; |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
display: initial; |
||||
max-height: calc(100vh - 140px); |
||||
overflow-y: auto; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
export const ListItem = styled.li` |
||||
width: 260px; |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
width: 100%; |
||||
margin-bottom: 12px; |
||||
border-radius: 2px; |
||||
overflow: hidden; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
const NavButton = styled(ArrowButton)` |
||||
position: absolute; |
||||
top: 50%; |
||||
translate: 0 -50%; |
||||
|
||||
${({ disabled }) => (disabled |
||||
? css` |
||||
opacity: 0.5; |
||||
` |
||||
: '')} |
||||
` |
||||
|
||||
export const PrevButton = styled(NavButton)` |
||||
left: 30px; |
||||
` |
||||
|
||||
export const NextButton = styled(NavButton)` |
||||
right: 30px; |
||||
` |
||||
|
||||
export const Arrow = styled(ArrowBase)` |
||||
width: 20px; |
||||
height: 20px; |
||||
` |
||||
@ -1,84 +0,0 @@ |
||||
import isNumber from 'lodash/isNumber' |
||||
import map from 'lodash/map' |
||||
|
||||
import { T9n } from 'features/T9n' |
||||
import { MatchPackage, SubscriptionType } from 'features/BuyMatchPopup/types' |
||||
|
||||
import { |
||||
Pass, |
||||
Name, |
||||
Description, |
||||
InfoWrapper, |
||||
Item, |
||||
List, |
||||
Price, |
||||
ScAutoRenewal, |
||||
ScPriceContainer, |
||||
} from './styled' |
||||
|
||||
type Props = { |
||||
onSelect: (subscription: MatchPackage) => void, |
||||
packages: Array<MatchPackage>, |
||||
selectedPackage: MatchPackage | null, |
||||
} |
||||
|
||||
export const PackagesList = ({ |
||||
onSelect, |
||||
packages, |
||||
selectedPackage, |
||||
}: Props) => ( |
||||
<List> |
||||
{ |
||||
map( |
||||
packages, |
||||
(subPackage) => ( |
||||
<Item |
||||
key={subPackage.id} |
||||
onClick={() => onSelect(subPackage)} |
||||
active={subPackage === selectedPackage} |
||||
> |
||||
<InfoWrapper> |
||||
<Pass> |
||||
<T9n t={subPackage.pass} /> |
||||
</Pass> |
||||
<Name> |
||||
{ |
||||
isNumber(subPackage.nameLexic) |
||||
? <T9n t={subPackage.nameLexic} /> |
||||
: subPackage.name |
||||
} |
||||
</Name> |
||||
<Description> |
||||
<T9n |
||||
t={subPackage.description.lexic} |
||||
values={subPackage.description.values} |
||||
/> |
||||
</Description> |
||||
</InfoWrapper> |
||||
<ScPriceContainer> |
||||
<Price |
||||
amount={subPackage.price} |
||||
currency={subPackage.currency} |
||||
perPeriod={ |
||||
subPackage.type !== SubscriptionType.Month |
||||
? null |
||||
: `per_${subPackage.type}` |
||||
} |
||||
/> |
||||
{ |
||||
subPackage.type === SubscriptionType.Month |
||||
&& ( |
||||
<ScAutoRenewal> |
||||
<T9n |
||||
t='auto_renewal' |
||||
/> |
||||
</ScAutoRenewal> |
||||
) |
||||
} |
||||
</ScPriceContainer> |
||||
</Item> |
||||
), |
||||
) |
||||
} |
||||
</List> |
||||
) |
||||
@ -1,230 +0,0 @@ |
||||
import styled, { css } from 'styled-components/macro' |
||||
import { isMobileDevice } from 'config/userAgent' |
||||
|
||||
import { popupScrollbarStyles } from 'features/PopupComponents' |
||||
import { Price as BasePrice } from 'features/Price' |
||||
import { |
||||
PriceAmount, |
||||
PriceDetails, |
||||
Period, |
||||
} from 'features/Price/styled' |
||||
|
||||
export const List = styled.ul` |
||||
width: 100%; |
||||
height: 460px; |
||||
overflow-y: auto; |
||||
margin-top: 25px; |
||||
padding: 0 40px; |
||||
|
||||
${popupScrollbarStyles} |
||||
|
||||
@media (max-width: 1370px) { |
||||
max-height: 415px; |
||||
height: auto; |
||||
} |
||||
|
||||
@media (max-height: 768px) { |
||||
max-height: 280px; |
||||
} |
||||
|
||||
@media (max-height: 500px) { |
||||
max-height: 140px; |
||||
} |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
padding: 0; |
||||
margin-top: 19px; |
||||
@media screen and (orientation: landscape){ |
||||
margin-top: 10px; |
||||
} |
||||
` |
||||
: ''}; |
||||
` |
||||
|
||||
type ItemProps = { |
||||
active?: boolean, |
||||
} |
||||
|
||||
export const Item = styled.li.attrs(() => ({ |
||||
tabIndex: 0, |
||||
}))<ItemProps>` |
||||
width: 100%; |
||||
min-height: 140px; |
||||
padding: 20px 30px 20px 20px; |
||||
background: ${({ theme }) => theme.colors.packageBackground}; |
||||
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3); |
||||
border-radius: 2px; |
||||
|
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
cursor: pointer; |
||||
transition: background-color 0.3s; |
||||
|
||||
:not(:last-child) { |
||||
margin-bottom: 20px; |
||||
} |
||||
|
||||
${({ active, theme: { colors } }) => ( |
||||
active ? `background-color: ${colors.button}` : '' |
||||
)}; |
||||
|
||||
@media (max-width: 1370px) { |
||||
min-height: 125px; |
||||
} |
||||
|
||||
@media (max-width: 750px) { |
||||
height: 100%; |
||||
} |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
padding: 5px 10px; |
||||
|
||||
@media (max-width: 750px) { |
||||
min-height: 52.29px; |
||||
height: auto; |
||||
} |
||||
|
||||
:not(:last-child) { |
||||
margin-bottom: 10px; |
||||
|
||||
@media screen and (orientation: landscape) { |
||||
margin-bottom: 5.24px; |
||||
} |
||||
} |
||||
` : ''};
|
||||
|
||||
@media (max-width: 850px) and (orientation: landscape){ |
||||
min-height: 52.29px; |
||||
height: auto; |
||||
} |
||||
` |
||||
|
||||
export const InfoWrapper = styled.div` |
||||
width: 75%; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-self: flex-start; |
||||
${isMobileDevice |
||||
? css` |
||||
align-self: center; |
||||
@media (max-width: 850px) and (orientation: landscape){ |
||||
height: 100%; |
||||
} |
||||
` |
||||
: ''}; |
||||
` |
||||
|
||||
export const Name = styled.span` |
||||
font-weight: 500; |
||||
font-size: 20px; |
||||
line-height: 23px; |
||||
letter-spacing: 0.03em; |
||||
|
||||
@media (max-width: 1370px) { |
||||
line-height: 20px; |
||||
} |
||||
${isMobileDevice |
||||
? css` |
||||
@media (max-width: 750px){ |
||||
font-size: 12px; |
||||
line-height: 10.04px; |
||||
} |
||||
@media screen and (orientation: landscape){ |
||||
font-size: 14px; |
||||
} |
||||
` |
||||
: ''}; |
||||
` |
||||
|
||||
export const Pass = styled(Name)` |
||||
font-weight: 600; |
||||
text-transform: uppercase; |
||||
${isMobileDevice |
||||
? css` |
||||
line-height: 12px; |
||||
@media screen and (orientation: landscape){ |
||||
line-height: 14px; |
||||
} |
||||
` |
||||
: ''}; |
||||
` |
||||
|
||||
export const Description = styled.span` |
||||
width: 68%; |
||||
margin-top: 13px; |
||||
font-weight: 500; |
||||
font-size: 15px; |
||||
line-height: 20px; |
||||
letter-spacing: 0.03em; |
||||
|
||||
@media (max-width: 1370px) { |
||||
line-height: 18px; |
||||
} |
||||
${isMobileDevice |
||||
? css` |
||||
@media (max-width: 750px){ |
||||
font-size: 8px; |
||||
line-height: 8px; |
||||
margin-top: 5px; |
||||
width: 100%; |
||||
} |
||||
@media (max-width: 850px) and (orientation: landscape){ |
||||
margin-top: 0; |
||||
line-height: 8px; |
||||
font-size: 10px; |
||||
} |
||||
` |
||||
: ''}; |
||||
` |
||||
|
||||
export const Price = styled(BasePrice)` |
||||
${PriceAmount} { |
||||
font-size: 24px; |
||||
line-height: 24px; |
||||
font-weight: normal; |
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 14px; |
||||
` |
||||
: ''}; |
||||
} |
||||
|
||||
${PriceDetails} { |
||||
font-weight: 500; |
||||
font-size: 12px; |
||||
line-height: 18px; |
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 8px; |
||||
` |
||||
: ''}; |
||||
} |
||||
|
||||
${Period} { |
||||
text-transform: capitalize; |
||||
} |
||||
` |
||||
|
||||
export const ScAutoRenewal = styled(PriceDetails)` |
||||
line-height: 21px; |
||||
font-size: 12px; |
||||
text-transform: none; |
||||
margin: 0; |
||||
color: ${({ theme: { colors } }) => colors.white70}; |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
line-height: normal; |
||||
font-size: 10px; |
||||
text-align: center; |
||||
` |
||||
: ''}; |
||||
` |
||||
|
||||
export const ScPriceContainer = styled.div` |
||||
display: flex; |
||||
flex-direction: column; |
||||
` |
||||
@ -0,0 +1,74 @@ |
||||
import type { MatchPackage } from 'features/BuyMatchPopup/types' |
||||
import { SubscriptionType } from 'features/BuyMatchPopup/types' |
||||
import { T9n } from 'features/T9n' |
||||
import { Price } from 'features/Price' |
||||
|
||||
import { usePackage } from './usePackage' |
||||
|
||||
import { |
||||
Wrapper, |
||||
InfoWrapper, |
||||
Description, |
||||
Header, |
||||
HeaderTitle, |
||||
BestChoice, |
||||
PriceBlock, |
||||
PriceTextWrapper, |
||||
PriceTextTop, |
||||
PriceTextBottom, |
||||
} from './styled' |
||||
|
||||
type Props = { |
||||
matchPackage: MatchPackage, |
||||
} |
||||
|
||||
export const RegularPackage = ({ matchPackage }: Props) => { |
||||
const { |
||||
firstDescription, |
||||
handleClick, |
||||
handleKeyPress, |
||||
isActive, |
||||
priceTextBottomLexic, |
||||
priceTextTopLexic, |
||||
} = usePackage(matchPackage) |
||||
|
||||
return ( |
||||
<Wrapper |
||||
onClick={handleClick} |
||||
onKeyDown={handleKeyPress} |
||||
active={isActive} |
||||
isMainPackage={matchPackage.isMainPackage} |
||||
> |
||||
<Header> |
||||
<HeaderTitle |
||||
width={matchPackage.id.startsWith('team_') ? 190 : undefined} |
||||
t={matchPackage.originalObject.sub.lexic1} |
||||
/> |
||||
{matchPackage.isMainPackage && ( |
||||
<BestChoice> |
||||
<T9n t='best_choice' /> |
||||
</BestChoice> |
||||
)} |
||||
</Header> |
||||
<InfoWrapper> |
||||
<Description> |
||||
{firstDescription} |
||||
</Description> |
||||
<Description> |
||||
<T9n t={matchPackage.originalObject.sub.lexic3} /> |
||||
</Description> |
||||
<PriceBlock isMonthSubscription={matchPackage.isMonthSubscription}> |
||||
<PriceTextWrapper> |
||||
<PriceTextTop t={priceTextTopLexic} /> |
||||
<PriceTextBottom t={priceTextBottomLexic} /> |
||||
</PriceTextWrapper> |
||||
<Price |
||||
amount={matchPackage.originalObject.price} |
||||
currency={matchPackage.currency} |
||||
perPeriod={matchPackage.isMonthSubscription ? `per_${SubscriptionType.Month}` : undefined} |
||||
/> |
||||
</PriceBlock> |
||||
</InfoWrapper> |
||||
</Wrapper> |
||||
) |
||||
} |
||||
@ -0,0 +1,140 @@ |
||||
import styled, { css } from 'styled-components/macro' |
||||
|
||||
import { PriceDetails } from 'features/Price/styled' |
||||
import { T9n } from 'features/T9n' |
||||
|
||||
export const Description = styled.p` |
||||
margin-bottom: 20px; |
||||
font-weight: 400; |
||||
font-size: 12px; |
||||
line-height: 20px; |
||||
white-space: initial; |
||||
|
||||
:empty, :has(span:empty) { |
||||
display: none; |
||||
} |
||||
|
||||
:last-of-type { |
||||
margin-bottom: 0; |
||||
} |
||||
` |
||||
|
||||
type PriceBlockProps = { |
||||
isMonthSubscription?: boolean, |
||||
} |
||||
|
||||
export const PriceBlock = styled.div<PriceBlockProps>` |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
position: absolute; |
||||
top: 218px; |
||||
left: 0; |
||||
right: 0; |
||||
padding-left: 30px; |
||||
padding-right: ${({ isMonthSubscription }) => (isMonthSubscription ? 10 : 20)}px; |
||||
` |
||||
|
||||
export const PriceTextWrapper = styled.div` |
||||
display: flex; |
||||
flex-direction: column; |
||||
white-space: initial; |
||||
` |
||||
|
||||
export const PriceTextTop = styled(T9n)` |
||||
margin-bottom: 2px; |
||||
font-weight: 600; |
||||
font-size: 10px; |
||||
font-style: italic; |
||||
letter-spacing: 0.03em; |
||||
` |
||||
|
||||
export const PriceTextBottom = styled(T9n)` |
||||
font-weight: 400; |
||||
font-size: 8px; |
||||
font-style: italic; |
||||
letter-spacing: 0.03em; |
||||
` |
||||
|
||||
type WrapperProps = { |
||||
active?: boolean, |
||||
isMainPackage?: boolean, |
||||
} |
||||
|
||||
export const Wrapper = styled.div.attrs({ |
||||
tabIndex: 0, |
||||
})<WrapperProps>` |
||||
width: 100%; |
||||
height: 380px; |
||||
border-radius: 2px; |
||||
background: ${({ theme }) => theme.colors.packageBackground}; |
||||
box-shadow: 0px 0px 10px 5px rgba(0, 0, 0, 0.1); |
||||
transition: background-color 0.3s; |
||||
cursor: pointer; |
||||
|
||||
${({ active, theme: { colors } }) => (active |
||||
? css` |
||||
background-color: ${colors.button}; |
||||
` |
||||
: css` |
||||
:hover { |
||||
background-color: #2F3E6D; |
||||
}` |
||||
)} |
||||
|
||||
${({ isMainPackage, theme: { colors } }) => (isMainPackage |
||||
? css` |
||||
border: 2px solid ${colors.white}; |
||||
` |
||||
: '' |
||||
)} |
||||
` |
||||
|
||||
export const Header = styled.header` |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: center; |
||||
height: 100px; |
||||
background: rgba(255, 255, 255, 0.2); |
||||
` |
||||
|
||||
type HeaderTitleProps = { |
||||
width?: number, |
||||
} |
||||
|
||||
export const HeaderTitle = styled(T9n)<HeaderTitleProps>` |
||||
flex: 1; |
||||
display: flex; |
||||
align-items: center; |
||||
width: ${({ width }) => (width ? `${width}px` : 'auto')}; |
||||
padding-left: 30px; |
||||
white-space: initial; |
||||
font-size: 16px; |
||||
font-weight: 600; |
||||
` |
||||
|
||||
export const BestChoice = styled.div` |
||||
display: flex; |
||||
align-items: center; |
||||
height: 22px; |
||||
margin-top: auto; |
||||
padding-left: 32px; |
||||
font-size: 12px; |
||||
font-weight: 600; |
||||
letter-spacing: 0.03em; |
||||
font-variant: all-small-caps; |
||||
color: #464646; |
||||
background-color: #FDFDFE; |
||||
` |
||||
|
||||
export const InfoWrapper = styled.div` |
||||
position: relative; |
||||
display: flex; |
||||
flex-direction: column; |
||||
height: calc(100% - 100px); |
||||
padding: 20px 30px 30px; |
||||
|
||||
${PriceDetails} { |
||||
margin-top: 5px; |
||||
} |
||||
` |
||||
@ -0,0 +1,70 @@ |
||||
import type { KeyboardEvent } from 'react' |
||||
|
||||
import { KEYBOARD_KEYS } from 'config' |
||||
|
||||
import type { MatchPackage } from 'features/BuyMatchPopup/types' |
||||
import { useBuyMatchPopupStore } from 'features/BuyMatchPopup' |
||||
import { useLexicsStore } from 'features/LexicsStore' |
||||
|
||||
export const usePackage = (matchPackage: MatchPackage) => { |
||||
const { |
||||
onPackageSelect, |
||||
selectedPackage, |
||||
} = useBuyMatchPopupStore() |
||||
|
||||
const { suffix, translate } = useLexicsStore() |
||||
|
||||
const isActive = matchPackage.id === selectedPackage?.id |
||||
|
||||
const priceTextTopLexic = matchPackage.isMonthSubscription ? 'subscription' : 'purchase' |
||||
const priceTextBottomLexic = matchPackage.isMonthSubscription ? 'cancel_anytime' : 'one_off_payment' |
||||
|
||||
const getFirstDescription = () => { |
||||
const tournament = matchPackage.match.tournament[`name_${suffix}`] |
||||
const season = typeof matchPackage.description.values.season === 'string' |
||||
? `${matchPackage.description.values.season.slice(0, 4)}/${matchPackage.description.values.season.slice(-2)}` |
||||
: '' |
||||
|
||||
switch (true) { |
||||
case matchPackage.originalObject.sub.lexic2 !== null: |
||||
return translate(matchPackage.originalObject.sub.lexic2!) |
||||
|
||||
case matchPackage.id === '0': |
||||
return matchPackage.name |
||||
|
||||
case matchPackage.id.startsWith('team1'): |
||||
case matchPackage.id.startsWith('team2'): |
||||
return `${translate('all_games_of')} ${matchPackage.name} ${translate('in')} ${tournament} ${translate('in_season')} ${season}` |
||||
|
||||
case matchPackage.id.startsWith('team_home'): |
||||
return `${translate('all_home_games_of')} ${matchPackage.name} ${translate('in')} ${tournament} ${translate('in_season')} ${season}` |
||||
|
||||
case matchPackage.id.startsWith('team_away'): |
||||
return `${translate('all_away_games_of')} ${matchPackage.name} ${translate('in')} ${tournament} ${translate('in_season')} ${season}` |
||||
|
||||
case matchPackage.id.startsWith('all'): |
||||
return `${tournament} ${translate('in_season')} ${season}` |
||||
|
||||
default: return '' |
||||
} |
||||
} |
||||
|
||||
const handleClick = () => { |
||||
onPackageSelect(matchPackage) |
||||
} |
||||
|
||||
const handleKeyPress = (e: KeyboardEvent) => { |
||||
if (e.key === KEYBOARD_KEYS.Enter) { |
||||
onPackageSelect(matchPackage) |
||||
} |
||||
} |
||||
|
||||
return { |
||||
firstDescription: getFirstDescription(), |
||||
handleClick, |
||||
handleKeyPress, |
||||
isActive, |
||||
priceTextBottomLexic, |
||||
priceTextTopLexic, |
||||
} |
||||
} |
||||
@ -1,115 +0,0 @@ |
||||
import { useCallback } from 'react' |
||||
|
||||
import map from 'lodash/map' |
||||
|
||||
import { MDASH } from 'config' |
||||
|
||||
import { isSubscribePopup } from 'helpers' |
||||
|
||||
import { Name as Names } from 'features/Name' |
||||
import { T9n } from 'features/T9n' |
||||
import { useBuyMatchPopupStore } from 'features/BuyMatchPopup/store' |
||||
import { CloseButton, HeaderActions } from 'features/PopupComponents' |
||||
import { |
||||
Body, |
||||
Button, |
||||
Footer, |
||||
Header, |
||||
HeaderTitle, |
||||
Wrapper, |
||||
} from 'features/BuyMatchPopup/styled' |
||||
|
||||
import { MatchPackage, SubscriptionType } from '../../types' |
||||
|
||||
import { |
||||
Description, |
||||
InfoWrapper, |
||||
Name, |
||||
Pass, |
||||
} from '../PackagesList/styled' |
||||
|
||||
import { |
||||
Price, |
||||
ChooseSub, |
||||
ChooseSubItem, |
||||
ChooseSubList, |
||||
} from './styled' |
||||
|
||||
export const SelectSubscriptionStep = () => { |
||||
const { |
||||
close, |
||||
match, |
||||
matchSubscriptions, |
||||
onNext, |
||||
onSubscriptionSelect, |
||||
selectedSubscription, |
||||
} = useBuyMatchPopupStore() |
||||
|
||||
const getPackagesCurrency = useCallback(( |
||||
packages: Record<SubscriptionType, Array<MatchPackage>>, |
||||
) => { |
||||
const packageWithValue = Object.entries(packages).find(([key, value]) => value.length)?.[1][0] |
||||
return packageWithValue ? packageWithValue.currency : 'RUB' |
||||
}, []) |
||||
|
||||
if (!match || !matchSubscriptions) return null |
||||
|
||||
return ( |
||||
<Wrapper> |
||||
<Header> |
||||
{!isSubscribePopup() |
||||
&& ( |
||||
<HeaderTitle> |
||||
<Names nameObj={match.team1} /> |
||||
{` ${MDASH} `} |
||||
<Names nameObj={match.team2} /> |
||||
<ChooseSub> |
||||
<T9n t='choose_subscription' /> |
||||
</ChooseSub> |
||||
</HeaderTitle> |
||||
)} |
||||
<HeaderActions position='right'> |
||||
<CloseButton onClick={close} /> |
||||
</HeaderActions> |
||||
</Header> |
||||
<Body marginTop={15}> |
||||
<ChooseSubList> |
||||
{map(matchSubscriptions, (subscription) => ( |
||||
<ChooseSubItem |
||||
key={subscription.id} |
||||
onClick={() => onSubscriptionSelect(subscription)} |
||||
active={subscription === selectedSubscription} |
||||
> |
||||
<InfoWrapper> |
||||
<Pass> |
||||
<T9n t={subscription.lexic} /> |
||||
</Pass> |
||||
<Name> |
||||
<T9n t={subscription.lexic2} /> |
||||
</Name> |
||||
<Description> |
||||
<T9n t={subscription.lexic3} /> |
||||
</Description> |
||||
</InfoWrapper> |
||||
|
||||
<Price |
||||
amount={subscription.min_price || 0} |
||||
currency={getPackagesCurrency(subscription.packages)} |
||||
isFrom={Boolean(subscription.min_price)} |
||||
/> |
||||
</ChooseSubItem> |
||||
))} |
||||
</ChooseSubList> |
||||
</Body> |
||||
<Footer> |
||||
<Button |
||||
disabled={!selectedSubscription} |
||||
onClick={onNext} |
||||
id='purchase_next' |
||||
> |
||||
<T9n t='next_choose' /> |
||||
</Button> |
||||
</Footer> |
||||
</Wrapper> |
||||
) |
||||
} |
||||
@ -1,33 +0,0 @@ |
||||
import styled from 'styled-components/macro' |
||||
import { Price as BasePrice } from 'features/Price' |
||||
import { PriceAmount, PriceDetails } from 'features/Price/styled' |
||||
|
||||
import { |
||||
Item, |
||||
List, |
||||
} from '../PackagesList/styled' |
||||
|
||||
export const Price = styled(BasePrice)` |
||||
${PriceAmount} { |
||||
font-size: 24px; |
||||
font-weight: normal; |
||||
} |
||||
${PriceDetails} { |
||||
padding-top: 5px; |
||||
font-size: 12px; |
||||
} |
||||
` |
||||
|
||||
export const ChooseSub = styled.div` |
||||
font-weight: 600; |
||||
font-size: 16px; |
||||
margin: 35px 0 17px; |
||||
` |
||||
|
||||
export const ChooseSubItem = styled(Item)` |
||||
min-height: auto; |
||||
` |
||||
|
||||
export const ChooseSubList = styled(List)` |
||||
height: auto; |
||||
` |
||||
@ -0,0 +1,52 @@ |
||||
import type { MatchPackage } from 'features/BuyMatchPopup/types' |
||||
import { SubscriptionType } from 'features/BuyMatchPopup/types' |
||||
|
||||
import { T9n } from 'features/T9n' |
||||
import { Price } from 'features/Price' |
||||
|
||||
import { usePackage } from '../RegularPackage/usePackage' |
||||
import { |
||||
Wrapper, |
||||
Description, |
||||
PriceBlock, |
||||
PriceTextWrapper, |
||||
PriceTextTop, |
||||
PriceTextBottom, |
||||
} from './styled' |
||||
|
||||
type Props = { |
||||
matchPackage: MatchPackage, |
||||
} |
||||
|
||||
export const SinglePackage = ({ matchPackage }: Props) => { |
||||
const { |
||||
firstDescription, |
||||
priceTextBottomLexic, |
||||
priceTextTopLexic, |
||||
} = usePackage(matchPackage) |
||||
|
||||
return ( |
||||
<Wrapper> |
||||
<Description> |
||||
<T9n t={matchPackage.originalObject.sub.lexic1} /> |
||||
</Description> |
||||
<Description> |
||||
{firstDescription} |
||||
</Description> |
||||
<Description> |
||||
<T9n t={matchPackage.originalObject.sub.lexic3} /> |
||||
</Description> |
||||
<PriceBlock> |
||||
<PriceTextWrapper> |
||||
<PriceTextTop t={priceTextTopLexic} /> |
||||
<PriceTextBottom t={priceTextBottomLexic} /> |
||||
</PriceTextWrapper> |
||||
<Price |
||||
amount={matchPackage.originalObject.price} |
||||
currency={matchPackage.currency} |
||||
perPeriod={`per_${SubscriptionType.Month}`} |
||||
/> |
||||
</PriceBlock> |
||||
</Wrapper> |
||||
) |
||||
} |
||||
@ -0,0 +1,100 @@ |
||||
import styled from 'styled-components/macro' |
||||
|
||||
import { |
||||
PriceAmount, |
||||
PriceDetails, |
||||
Period, |
||||
Divider, |
||||
Currency, |
||||
PerPeriod, |
||||
} from 'features/Price/styled' |
||||
import { T9n } from 'features/T9n' |
||||
|
||||
export const Description = styled.p` |
||||
margin-bottom: 20px; |
||||
line-height: 20px; |
||||
font-size: 18px; |
||||
font-weight: 400; |
||||
|
||||
:empty, :has(span:empty) { |
||||
display: none; |
||||
} |
||||
|
||||
:last-of-type { |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
:first-child { |
||||
font-size: 20px; |
||||
font-weight: 700; |
||||
} |
||||
` |
||||
|
||||
export const PriceBlock = styled.div` |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
position: relative; |
||||
margin-top: 60px; |
||||
` |
||||
|
||||
export const PriceTextWrapper = styled.div` |
||||
display: flex; |
||||
flex-direction: column; |
||||
white-space: initial; |
||||
` |
||||
|
||||
export const PriceTextTop = styled(T9n)` |
||||
margin-bottom: 2px; |
||||
font-weight: 600; |
||||
font-style: italic; |
||||
font-size: 16px; |
||||
letter-spacing: 0.03em; |
||||
` |
||||
|
||||
export const PriceTextBottom = styled(T9n)` |
||||
font-weight: 400; |
||||
font-size: 16px; |
||||
font-style: italic; |
||||
letter-spacing: 0.03em; |
||||
` |
||||
|
||||
export const Wrapper = styled.div` |
||||
position: relative; |
||||
display: flex; |
||||
flex-direction: column; |
||||
height: calc(100% - 100px); |
||||
|
||||
${PriceDetails} { |
||||
font-size: 50px; |
||||
} |
||||
|
||||
${PriceAmount} { |
||||
font-size: 50px; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
${Divider} { |
||||
width: 5px; |
||||
height: 45px; |
||||
margin-right: 3px; |
||||
translate: 0 7px; |
||||
} |
||||
|
||||
${Currency} { |
||||
margin-right: 10px; |
||||
translate: none; |
||||
font-size: 50px; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
${Period} { |
||||
font-size: 16px; |
||||
} |
||||
|
||||
${PerPeriod} { |
||||
display: inline-block; |
||||
translate: 0 4px; |
||||
} |
||||
` |
||||
|
||||
@ -1,31 +0,0 @@ |
||||
import omitBy from 'lodash/omitBy' |
||||
import isEmpty from 'lodash/isEmpty' |
||||
import map from 'lodash/map' |
||||
|
||||
import type { MatchSubscription, SubscriptionType } from 'features/BuyMatchPopup/types' |
||||
|
||||
type PaymentTab = { |
||||
tabLexic: string, |
||||
type: SubscriptionType, |
||||
} |
||||
|
||||
const getTabLexic = (type: string) => { |
||||
switch (type) { |
||||
case 'month': |
||||
return 'for_month' |
||||
case 'year': |
||||
return 'for_year' |
||||
case 'pay_per_view': |
||||
return 'for_view' |
||||
default: |
||||
return '' |
||||
} |
||||
} |
||||
|
||||
export const getCorrectPaymentTabs = (matchSubscriptions: MatchSubscription) => { |
||||
const matchSubscriptionsWithValues = omitBy(matchSubscriptions.packages, isEmpty) |
||||
return map(matchSubscriptionsWithValues, (matchSubscription, key) => ({ |
||||
tabLexic: getTabLexic(key), |
||||
type: key, |
||||
})) as Array<PaymentTab> |
||||
} |
||||
@ -1,115 +0,0 @@ |
||||
import { useMemo } from 'react' |
||||
|
||||
import map from 'lodash/map' |
||||
import size from 'lodash/size' |
||||
import styled, { css } from 'styled-components/macro' |
||||
|
||||
import { isMobileDevice } from 'config/userAgent' |
||||
|
||||
import type { MatchSubscription, SubscriptionType } from 'features/BuyMatchPopup/types' |
||||
import { T9n } from 'features/T9n' |
||||
|
||||
import { getCorrectPaymentTabs } from './helpers' |
||||
|
||||
type ListProps = { |
||||
countSubscriptions: number, |
||||
} |
||||
const List = styled.ul<ListProps>` |
||||
display: flex; |
||||
min-width: 395px; |
||||
justify-content: ${({ countSubscriptions }) => (countSubscriptions === 1 |
||||
? 'center' |
||||
: 'space-between')}; |
||||
${isMobileDevice |
||||
? css` |
||||
min-width: 90%; |
||||
width: 90%; |
||||
position: relative; |
||||
justify-content: center; |
||||
@media screen and (orientation: landscape){ |
||||
margin-top: -5px; |
||||
width: 50%; |
||||
min-width: 50%; |
||||
} |
||||
` |
||||
: ''}; |
||||
` |
||||
|
||||
type ItemProps = { |
||||
active?: boolean, |
||||
} |
||||
|
||||
const Item = styled.li<ItemProps>` |
||||
position: relative; |
||||
font-weight: 600; |
||||
font-size: 16px; |
||||
line-height: 47px; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
color: rgba(255, 255, 255, 0.5); |
||||
cursor: pointer; |
||||
transition: color 0.3s; |
||||
|
||||
::after { |
||||
transition: background-color 0.3s; |
||||
position: absolute; |
||||
content: ''; |
||||
bottom: 0px; |
||||
width: 130px; |
||||
height: 3px; |
||||
} |
||||
|
||||
${({ active }) => ( |
||||
active |
||||
? css` |
||||
color: #fff; |
||||
::after { |
||||
background-color: #fff; |
||||
} |
||||
` |
||||
: '' |
||||
)} |
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 10px; |
||||
width: 33%; |
||||
white-space: nowrap; |
||||
::after { |
||||
height: 2px; |
||||
} |
||||
` |
||||
: ''}; |
||||
` |
||||
|
||||
type Props = { |
||||
className?: string, |
||||
onPeriodSelect: (period: SubscriptionType) => void, |
||||
selectedPeriod: SubscriptionType, |
||||
selectedSubscription: MatchSubscription, |
||||
} |
||||
|
||||
export const PaymentPeriodTabs = ({ |
||||
className, |
||||
onPeriodSelect, |
||||
selectedPeriod, |
||||
selectedSubscription, |
||||
}: Props) => { |
||||
const matchSubscriptionsWithValues = useMemo(() => ( |
||||
getCorrectPaymentTabs(selectedSubscription) |
||||
), [selectedSubscription]) |
||||
|
||||
return ( |
||||
<List className={className} countSubscriptions={size(matchSubscriptionsWithValues)}> |
||||
{map(matchSubscriptionsWithValues, ({ tabLexic, type }) => ( |
||||
<Item |
||||
key={type} |
||||
active={selectedPeriod === type} |
||||
onClick={() => onPeriodSelect(type)} |
||||
> |
||||
<T9n t={tabLexic} /> |
||||
</Item> |
||||
))} |
||||
</List> |
||||
) |
||||
} |
||||
@ -1,51 +0,0 @@ |
||||
import { currencySymbols } from 'config' |
||||
|
||||
import { T9n } from 'features/T9n' |
||||
|
||||
import { |
||||
Prefix, |
||||
PriceAmount, |
||||
PriceDetails, |
||||
PriceWrapper, |
||||
Currency, |
||||
Period, |
||||
} from '../../styled' |
||||
|
||||
type Props = { |
||||
amount: number, |
||||
className?: string, |
||||
currency?: string, |
||||
isFrom?: boolean, |
||||
perPeriod?: string | null, |
||||
} |
||||
|
||||
export const BasePrice = ({ |
||||
amount, |
||||
className, |
||||
currency = currencySymbols.RUB, |
||||
isFrom, |
||||
perPeriod, |
||||
}: Props) => ( |
||||
<PriceWrapper className={className}> |
||||
{ |
||||
isFrom |
||||
? ( |
||||
<Prefix> |
||||
<T9n t='from_price' /> |
||||
</Prefix> |
||||
) |
||||
: '' |
||||
} |
||||
<PriceAmount>{amount}</PriceAmount> |
||||
<PriceDetails> |
||||
<Currency>{currency}</Currency> |
||||
{ |
||||
perPeriod && ( |
||||
<Period> |
||||
/ <T9n t={perPeriod} /> |
||||
</Period> |
||||
) |
||||
} |
||||
</PriceDetails> |
||||
</PriceWrapper> |
||||
) |
||||
@ -1,51 +0,0 @@ |
||||
import { currencySymbols } from 'config' |
||||
|
||||
import { T9n } from 'features/T9n' |
||||
|
||||
import { |
||||
Prefix, |
||||
PriceDetails, |
||||
PriceWrapper, |
||||
Currency, |
||||
Period, |
||||
} from '../../styled' |
||||
import { PriceAmount } from './styled' |
||||
|
||||
type Props = { |
||||
amount: number, |
||||
className?: string, |
||||
currency?: string, |
||||
isFrom?: boolean, |
||||
perPeriod?: string | null, |
||||
} |
||||
|
||||
export const BrazilianPrice = ({ |
||||
amount, |
||||
className, |
||||
currency = currencySymbols.BRL, |
||||
isFrom, |
||||
perPeriod, |
||||
}: Props) => ( |
||||
<PriceWrapper className={className}> |
||||
{ |
||||
isFrom |
||||
? ( |
||||
<Prefix> |
||||
<T9n t='from_price' /> |
||||
</Prefix> |
||||
) |
||||
: '' |
||||
} |
||||
<PriceDetails> |
||||
<Currency>{currency}</Currency> |
||||
<PriceAmount>{amount.toFixed(2).replace('.', ',')}</PriceAmount> |
||||
{ |
||||
perPeriod && ( |
||||
<Period> |
||||
/ <T9n t={perPeriod} /> |
||||
</Period> |
||||
) |
||||
} |
||||
</PriceDetails> |
||||
</PriceWrapper> |
||||
) |
||||
@ -1,6 +0,0 @@ |
||||
import styled from 'styled-components' |
||||
import { PriceAmount as BasePriceAmount } from '../../styled' |
||||
|
||||
export const PriceAmount = styled(BasePriceAmount)` |
||||
margin-right: 4px; |
||||
` |
||||
@ -1,48 +1,39 @@ |
||||
import { currencySymbols } from 'config' |
||||
|
||||
import { useMemo } from 'react' |
||||
import { BasePrice } from './components/BasePrice' |
||||
import { BrazilianPrice } from './components/BrazilianPrice' |
||||
import { |
||||
PriceAmount, |
||||
PriceDetails, |
||||
PriceWrapper, |
||||
Currency, |
||||
Period, |
||||
Divider, |
||||
PerPeriod, |
||||
} from './styled' |
||||
|
||||
export type PriceProps = { |
||||
type Props = { |
||||
amount: number, |
||||
className?: string, |
||||
currency?: string, |
||||
isFrom?: boolean, |
||||
perPeriod?: string | null, |
||||
} |
||||
|
||||
export const Price: React.FC<PriceProps> = ({ |
||||
export const Price = ({ |
||||
amount, |
||||
className, |
||||
currency, |
||||
isFrom, |
||||
currency = currencySymbols.RUB, |
||||
perPeriod, |
||||
}) => { |
||||
const priceContent = useMemo(() => { |
||||
switch (currency) { |
||||
case currencySymbols.BRL: |
||||
return ( |
||||
<BrazilianPrice |
||||
amount={amount} |
||||
className={className} |
||||
currency={currency} |
||||
isFrom={isFrom} |
||||
perPeriod={perPeriod} |
||||
/> |
||||
) |
||||
default: |
||||
return ( |
||||
<BasePrice |
||||
amount={amount} |
||||
className={className} |
||||
currency={currency} |
||||
isFrom={isFrom} |
||||
perPeriod={perPeriod} |
||||
/> |
||||
}: Props) => ( |
||||
<PriceWrapper className={className}> |
||||
<PriceAmount>{amount}</PriceAmount> |
||||
<PriceDetails> |
||||
<Currency>{currency}</Currency> |
||||
{ |
||||
perPeriod && ( |
||||
<Period> |
||||
<Divider /><PerPeriod t={perPeriod} /> |
||||
</Period> |
||||
) |
||||
} |
||||
}, [amount, className, currency, isFrom, perPeriod]) |
||||
|
||||
return priceContent |
||||
} |
||||
</PriceDetails> |
||||
</PriceWrapper> |
||||
) |
||||
|
||||
@ -1,62 +1,48 @@ |
||||
import styled, { css } from 'styled-components/macro' |
||||
import styled from 'styled-components/macro' |
||||
|
||||
import { isMobileDevice } from 'config/userAgent' |
||||
import { devices } from 'config/devices' |
||||
import { isMobileDevice } from 'config' |
||||
|
||||
import { T9n } from 'features/T9n' |
||||
|
||||
export const PriceWrapper = styled.div` |
||||
display: flex; |
||||
align-items: flex-start; |
||||
|
||||
@media ${devices.tablet} { |
||||
justify-content: center; |
||||
} |
||||
${isMobileDevice |
||||
? css` |
||||
min-width: 80px; |
||||
` |
||||
: ''}; |
||||
` |
||||
|
||||
export const PriceAmount = styled.span` |
||||
font-style: normal; |
||||
font-weight: 600; |
||||
font-size: 48px; |
||||
line-height: 40px; |
||||
font-weight: 400; |
||||
font-size: 32px; |
||||
color: ${({ theme: { colors } }) => colors.white}; |
||||
|
||||
@media ${devices.tablet} { |
||||
font-size: 36px; |
||||
} |
||||
` |
||||
|
||||
export const PriceDetails = styled.span` |
||||
display: flex; |
||||
margin-left: 5px; |
||||
font-style: normal; |
||||
font-weight: normal; |
||||
font-size: 18px; |
||||
line-height: 21px; |
||||
font-size: 30px; |
||||
color: ${({ theme: { colors } }) => colors.white}; |
||||
text-transform: uppercase; |
||||
|
||||
@media ${devices.tablet} { |
||||
font-size: 11px; |
||||
} |
||||
` |
||||
|
||||
export const Currency = styled.span` |
||||
text-transform: uppercase; |
||||
margin-right: 4px; |
||||
font-size: ${isMobileDevice ? 8 : 10}px; |
||||
` |
||||
|
||||
export const Period = styled.span` |
||||
text-transform: uppercase; |
||||
font-style: italic; |
||||
font-weight: 400; |
||||
font-size: ${isMobileDevice ? 8 : 10}px; |
||||
` |
||||
|
||||
export const Prefix = styled.span` |
||||
padding-top: 5px; |
||||
text-transform: lowercase; |
||||
line-height: 40px; |
||||
font-size: 12px; |
||||
font-weight: normal; |
||||
margin-right: 5px; |
||||
export const Divider = styled.div` |
||||
display: inline-block; |
||||
width: 0.5px; |
||||
height: 8px; |
||||
margin: 0 2px -1px 4px; |
||||
transform: skew(-20deg, 0); |
||||
background-color: ${({ theme: { colors } }) => colors.white}; |
||||
` |
||||
|
||||
export const PerPeriod = styled(T9n)`` |
||||
|
||||
@ -1,62 +0,0 @@ |
||||
import isEmpty from 'lodash/isEmpty' |
||||
import map from 'lodash/map' |
||||
|
||||
import type { MatchSubscriptions } from '../PageSubscriptions' |
||||
import { InlineButton } from '../../styled' |
||||
import { |
||||
Wrapper, |
||||
SportName, |
||||
List, |
||||
Item, |
||||
Subscription, |
||||
InfoWrapper, |
||||
Header, |
||||
Description, |
||||
Price, |
||||
SubscriptionEnd, |
||||
} from './styled' |
||||
|
||||
type Props = { |
||||
list: MatchSubscriptions, |
||||
sport: number, |
||||
} |
||||
|
||||
export const UserSubscriptionsList = ({ list, sport }: Props) => { |
||||
if (isEmpty(list)) return null |
||||
return ( |
||||
<Wrapper> |
||||
<SportName sport={sport} /> |
||||
<List> |
||||
{ |
||||
map(list, ({ |
||||
description, |
||||
header, |
||||
isActive, |
||||
price, |
||||
subscription_id, |
||||
type, |
||||
}) => ( |
||||
<Item key={subscription_id}> |
||||
<Subscription> |
||||
<InfoWrapper> |
||||
<Header> |
||||
{header} |
||||
</Header> |
||||
<Description> |
||||
{description} |
||||
</Description> |
||||
</InfoWrapper> |
||||
|
||||
<Price amount={price} perPeriod={`per_${type}`} /> |
||||
<InlineButton color={isActive ? '#eb5757' : '#294FC3'}> |
||||
{isActive ? 'Удалить' : 'Восстановить'} |
||||
</InlineButton> |
||||
</Subscription> |
||||
<SubscriptionEnd>Следующее списание 31.02.2020</SubscriptionEnd> |
||||
</Item> |
||||
)) |
||||
} |
||||
</List> |
||||
</Wrapper> |
||||
) |
||||
} |
||||
@ -1,159 +0,0 @@ |
||||
import styled, { css } from 'styled-components/macro' |
||||
|
||||
import { isMobileDevice } from 'config/userAgent' |
||||
|
||||
import { SportName as SportNameBase } from 'features/Common/SportName' |
||||
import { Price as BasePrice } from 'features/Price' |
||||
import { PriceAmount, PriceDetails } from 'features/Price/styled' |
||||
|
||||
import { InlineButton } from '../../styled' |
||||
|
||||
export const Wrapper = styled.div` |
||||
:not(:first-child) { |
||||
margin-top: 24px; |
||||
} |
||||
` |
||||
|
||||
export const SportName = styled(SportNameBase)` |
||||
font-style: normal; |
||||
font-weight: 500; |
||||
font-size: 18px; |
||||
line-height: 22px; |
||||
color: rgba(255, 255, 255, 0.6); |
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 14px; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
export const List = styled.ul` |
||||
margin-top: 6px; |
||||
` |
||||
|
||||
export const Item = styled.li` |
||||
display: flex; |
||||
align-items: center; |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
display: block; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
export const Price = styled(BasePrice)` |
||||
margin-right: 20px; |
||||
|
||||
${PriceAmount} { |
||||
font-size: 24px; |
||||
line-height: 24px; |
||||
font-weight: normal; |
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 14px; |
||||
` |
||||
: ''} |
||||
} |
||||
|
||||
${PriceDetails} { |
||||
font-weight: 500; |
||||
font-size: 12px; |
||||
line-height: 18px; |
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 7px; |
||||
` |
||||
: ''} |
||||
} |
||||
` |
||||
|
||||
export const Subscription = styled.div.attrs( |
||||
() => ({ |
||||
tabIndex: 0, |
||||
}), |
||||
)` |
||||
position: relative; |
||||
width: 800px; |
||||
height: 70px; |
||||
display: flex; |
||||
margin: 5px 0; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
background-color: #3F3F3F; |
||||
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3); |
||||
border-radius: 2px; |
||||
overflow: hidden; |
||||
|
||||
${InlineButton} { |
||||
width: 133px; |
||||
} |
||||
|
||||
:focus-within, :hover { |
||||
${InlineButton} { |
||||
transform: translateX(0); |
||||
} |
||||
} |
||||
${isMobileDevice |
||||
? css` |
||||
height: 86px; |
||||
width: 100%; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
export const InfoWrapper = styled.div` |
||||
width: 68%; |
||||
display: flex; |
||||
padding: 14px 0 14px 20px; |
||||
flex-direction: column; |
||||
color: #fff; |
||||
${isMobileDevice |
||||
? css` |
||||
padding: 10px 0 10px 15px; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
export const Header = styled.span` |
||||
font-style: normal; |
||||
font-weight: 600; |
||||
font-size: 16px; |
||||
line-height: 23px; |
||||
text-transform: uppercase; |
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 14px; |
||||
line-height: 20px; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
export const Description = styled.p` |
||||
font-style: normal; |
||||
font-weight: 500; |
||||
font-size: 16px; |
||||
line-height: 20px; |
||||
text-transform: capitalize; |
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 10px; |
||||
line-height: 12px; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
export const SubscriptionEnd = styled.span` |
||||
margin-left: 24px; |
||||
font-style: normal; |
||||
font-weight: 500; |
||||
font-size: 16px; |
||||
line-height: 24px; |
||||
color: rgba(255, 255, 255, 0.3); |
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 10px; |
||||
margin-left: 0; |
||||
` |
||||
: ''} |
||||
` |
||||
@ -0,0 +1,16 @@ |
||||
import { ProfileHeader } from 'features/ProfileHeader' |
||||
import { Main } from 'features/PageLayout' |
||||
import { BuyMatchPopup } from 'features/BuyMatchPopup' |
||||
|
||||
import { Wrapper } from './styled' |
||||
|
||||
const SubscriptionsPage = () => ( |
||||
<Wrapper> |
||||
<ProfileHeader /> |
||||
<Main> |
||||
<BuyMatchPopup /> |
||||
</Main> |
||||
</Wrapper> |
||||
) |
||||
|
||||
export default SubscriptionsPage |
||||
@ -0,0 +1,31 @@ |
||||
import styled from 'styled-components/macro' |
||||
|
||||
import { PageWrapper } from 'features/PageLayout' |
||||
import { Form } from 'features/Search/styled' |
||||
import { MenuList } from 'features/Menu/styled' |
||||
import { HeaderStyled } from 'features/ProfileHeader/styled' |
||||
import { Logo } from 'features/Logo' |
||||
import { Body, Wrapper as ContentWrapper } from 'features/BuyMatchPopup/styled' |
||||
|
||||
export const Wrapper = styled(PageWrapper)` |
||||
${Form}, ${MenuList} { |
||||
display: none; |
||||
} |
||||
|
||||
${HeaderStyled} { |
||||
height: 60px; |
||||
} |
||||
|
||||
${Logo} { |
||||
top: 7.5px; |
||||
} |
||||
|
||||
${Body} { |
||||
margin-top: 18px; |
||||
padding: 0; |
||||
} |
||||
|
||||
${ContentWrapper} { |
||||
padding: 20px 16px; |
||||
} |
||||
` |
||||
Loading…
Reference in new issue