Ott 1049 stripe payments (#390)

* Ott 1028 get token (#353)

* feat(1028): get card token

* fix(1028): fixed lang type

* Ott 1028 card crud (#354)

* feat(1028): added card requests

* feat(1028): crud on card

* feat(1028): buy subs. (#355)

* feat(1072): production configuration (#356)

* feat(1051): display payment and card link errors (#358)

* Ott 1073 one time subs (#359)

* refactor(1073): migrate to new proc response type

* feat(1073): one time payments

* Ott 1066 display default card (#360)

* refactor(1066): display default card in subs list popup

* fix(1066): test fix

* feat(1080): 3ds (#361)

* feat(1080): wip, 3ds

* fix(1080): pr comments

* refactor: url change (#382)
keep-around/af30b88d367751c9e05a735e4a0467a96238ef47
Mirlan 5 years ago committed by GitHub
parent 93b791612a
commit a0219f8212
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      Makefile
  2. 2
      package.json
  3. 1
      public/index.html
  4. 2
      src/config/env.tsx
  5. 5
      src/config/lexics/indexLexics.tsx
  6. 6
      src/config/lexics/payment.tsx
  7. 5
      src/config/lexics/userAccount.tsx
  8. 1
      src/config/procedures.tsx
  9. 63
      src/features/AddCardForm/components/ElementContainer/index.tsx
  10. 55
      src/features/AddCardForm/components/Form/hooks/index.tsx
  11. 96
      src/features/AddCardForm/components/Form/index.tsx
  12. 80
      src/features/AddCardForm/index.tsx
  13. 41
      src/features/AddCardForm/styled.tsx
  14. 7
      src/features/App/AuthenticatedApp.tsx
  15. 56
      src/features/BuyMatchPopup/components/CardStep/index.tsx
  16. 44
      src/features/BuyMatchPopup/components/CardsList/index.tsx
  17. 6
      src/features/BuyMatchPopup/components/ErrorStep/index.tsx
  18. 34
      src/features/BuyMatchPopup/components/SelectedCard/index.tsx
  19. 24
      src/features/BuyMatchPopup/components/SubscriptionSelectionStep/index.tsx
  20. 9
      src/features/BuyMatchPopup/components/SubscriptionsList/index.tsx
  21. 4
      src/features/BuyMatchPopup/components/SuccessStep/index.tsx
  22. 5
      src/features/BuyMatchPopup/index.tsx
  23. 28
      src/features/BuyMatchPopup/store/helpers.tsx
  24. 84
      src/features/BuyMatchPopup/store/hooks/index.tsx
  25. 6
      src/features/BuyMatchPopup/store/hooks/useLexicsFetcher.tsx
  26. 20
      src/features/BuyMatchPopup/store/hooks/useStripe3DSecure.tsx
  27. 21
      src/features/BuyMatchPopup/store/hooks/useSubscriptions.tsx
  28. 5
      src/features/BuyMatchPopup/styled.tsx
  29. 9
      src/features/BuyMatchPopup/types.tsx
  30. 51
      src/features/CardsStore/hooks/index.tsx
  31. 20
      src/features/CardsStore/index.tsx
  32. 4
      src/features/LanguageSelect/config.tsx
  33. 3
      src/features/LanguageSelect/index.tsx
  34. 6
      src/features/LexicsStore/hooks/useLang.tsx
  35. 2
      src/features/Matches/helpers/getMatchClickAction/__tests__/index.tsx
  36. 15
      src/features/Price/index.tsx
  37. 24
      src/features/StripeElements/index.tsx
  38. 22
      src/features/UserAccount/components/BankCard/index.tsx
  39. 26
      src/features/UserAccount/components/BankCard/styled.tsx
  40. 46
      src/features/UserAccount/components/PageBankCards/index.tsx
  41. 6
      src/helpers/callApi/logoutIfUnauthorized.tsx
  42. 15
      src/requests/addCard.tsx
  43. 24
      src/requests/buyMatchSubscriptions.tsx
  44. 24
      src/requests/buySubscription/buyMatchPayOnceSubscription.tsx
  45. 25
      src/requests/buySubscription/buyMatchSubscriptions.tsx
  46. 39
      src/requests/buySubscription/handleUnsuccessfulSubscription.tsx
  47. 4
      src/requests/buySubscription/index.tsx
  48. 23
      src/requests/buySubscription/notifySuccessfulSubscription.tsx
  49. 15
      src/requests/deleteCard.tsx
  50. 22
      src/requests/getCardsList.tsx
  51. 15
      src/requests/getSubscriptions.tsx
  52. 2
      src/requests/index.tsx
  53. 15
      src/requests/setDefaultCard.tsx

@ -10,7 +10,7 @@ build:
production-build: production-build:
rm -rf build rm -rf build
REACT_APP_PRODUCTION=true npm run build REACT_APP_PRODUCTION=true REACT_APP_STRIPE_PK=pk_live_ANI76cBhSo69DZUxPmyRVIZW npm run build
.PHONY: build .PHONY: build

@ -13,6 +13,8 @@
"build-storybook": "build-storybook -s public" "build-storybook": "build-storybook -s public"
}, },
"dependencies": { "dependencies": {
"@stripe/react-stripe-js": "^1.4.0",
"@stripe/stripe-js": "^1.13.2",
"date-fns": "^2.14.0", "date-fns": "^2.14.0",
"history": "^4.10.1", "history": "^4.10.1",
"hls.js": "^0.14.15", "hls.js": "^0.14.15",

@ -7,6 +7,7 @@
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;700&display=swap" rel="stylesheet">
<script src="https://js.stripe.com/v3" async></script>
<title>Instat TV</title> <title>Instat TV</title>
</head> </head>
<body> <body>

@ -1 +1,3 @@
export const isProduction = process.env.REACT_APP_PRODUCTION === 'true' export const isProduction = process.env.REACT_APP_PRODUCTION === 'true'
export const STRIPE_PUBLIC_KEY = process.env.REACT_APP_STRIPE_PK || 'pk_test_fkEjSoWfJXuCwMgwHRpbOGPt'

@ -1,4 +1,6 @@
import { paymentLexics } from './payment'
import { proceduresLexics } from './procedures' import { proceduresLexics } from './procedures'
import { publicLexics } from './public'
const matchPopupLexics = { const matchPopupLexics = {
apply: 13491, apply: 13491,
@ -75,7 +77,6 @@ export const indexLexics = {
round_highilights: 13050, round_highilights: 13050,
save: 828, save: 828,
search_results: 9014, search_results: 9014,
select_language: 1005,
sport: 12993, sport: 12993,
team: 658, team: 658,
to_home: 13376, to_home: 13376,
@ -88,4 +89,6 @@ export const indexLexics = {
...proceduresLexics, ...proceduresLexics,
...matchPopupLexics, ...matchPopupLexics,
...buyMatchPopupLexics, ...buyMatchPopupLexics,
...publicLexics,
...paymentLexics,
} }

@ -0,0 +1,6 @@
export const paymentLexics = {
add_card: 8313,
card_holder_name: 2021,
error_can_not_add_card: 14447,
error_payment_unsuccessful: 14446,
}

@ -1,4 +1,5 @@
import { publicLexics } from 'config/lexics/public' import { publicLexics } from './public'
import { paymentLexics } from './payment'
const navigations = { const navigations = {
bank_card: 14205, bank_card: 14205,
@ -8,7 +9,6 @@ const navigations = {
} }
export const userAccountLexics = { export const userAccountLexics = {
add_card: 8313,
change: 12614, change: 12614,
country: 835, country: 835,
delete: 848, delete: 848,
@ -27,4 +27,5 @@ export const userAccountLexics = {
user_account: 12928, user_account: 12928,
...navigations, ...navigations,
...publicLexics, ...publicLexics,
...paymentLexics,
} }

@ -1,5 +1,6 @@
export const PROCEDURES = { export const PROCEDURES = {
auth_user: 'auth_user', auth_user: 'auth_user',
bind_ott_subscription: 'bind_ott_subscription',
create_user: 'create_user', create_user: 'create_user',
get_cities: 'get_cities', get_cities: 'get_cities',
get_match_info: 'get_match_info', get_match_info: 'get_match_info',

@ -0,0 +1,63 @@
import { ReactNode } from 'react'
import styled from 'styled-components/macro'
import { T9n } from 'features/T9n'
type LabelProps = {
backgroundColor?: string,
width?: string,
}
const Label = styled.label<LabelProps>`
display: flex;
height: 50px;
width: ${({ width }) => width || '100%'};
padding: 0 25px;
background-color: ${({ backgroundColor = '#3F3F3F' }) => backgroundColor};
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3);
border-radius: 2px;
border: 1px solid transparent;
font-family: Montserrat;
font-style: normal;
font-weight: normal;
font-size: 16px;
line-height: 48px;
letter-spacing: -0.01em;
color: rgba(255, 255, 255, 0.5);
:not(:first-child) {
margin-top: 10px;
}
`
const Text = styled(T9n)`
margin-right: 20px;
`
const ElementWrapper = styled.div`
height: 100%;
flex-grow: 1;
`
type Props = {
backgroundColor?: string,
children: ReactNode,
label?: string,
width?: string,
}
export const ElementContainer = ({
backgroundColor,
children,
label,
width,
}: Props) => (
<Label width={width} backgroundColor={backgroundColor}>
{label && <Text t={label} />}
<ElementWrapper>
{children}
</ElementWrapper>
</Label>
)

@ -0,0 +1,55 @@
import { FormEvent, useState } from 'react'
import {
CardNumberElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js'
import { useCardsStore } from 'features/CardsStore'
export type Props = {
initialformOpen?: boolean,
inputsBackground?: string,
onAddSuccess?: () => void,
submitButton?: 'outline' |'solid',
}
export const useFormSubmit = ({ onAddSuccess }: Props) => {
const stripe = useStripe()
const elements = useElements()
const { onAddCard } = useCardsStore()
const [error, setError] = useState('')
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!stripe || !elements) {
// Stripe.js has not loaded yet.
return
}
const name: string = e.currentTarget.cardHolderName.value
if (!name) {
setError('Name can not be empty')
return
}
const cardNumberElement = elements.getElement(CardNumberElement)
if (!cardNumberElement) return
const { error: tokenError, token } = await stripe.createToken(
cardNumberElement,
{ name },
)
if (tokenError) {
setError(tokenError.message || '')
} else if (token) {
onAddCard(token.id).then(onAddSuccess)
}
}
return { error, handleSubmit }
}

@ -0,0 +1,96 @@
import {
CardNumberElement,
CardExpiryElement,
CardCvcElement,
} from '@stripe/react-stripe-js'
import { T9n } from 'features/T9n'
import { useCardsStore } from 'features/CardsStore'
import { OutlineButton, SolidButton } from 'features/UserAccount/styled'
import { ElementContainer } from '../ElementContainer'
import type { Props } from './hooks'
import { useFormSubmit } from './hooks'
import {
Form,
Column,
ButtonsBlock,
Input,
Errors,
} from '../../styled'
const baseStyles = {
color: '#fff',
fontFamily: 'Montserrat, Tahoma, sans-serif',
fontSize: '20px',
fontWeight: 'bold',
lineHeight: '50px',
}
const options = { placeholder: '', style: { base: baseStyles } }
const buttons = {
outline: OutlineButton,
solid: SolidButton,
}
export const AddCardFormInner = (props: Props) => {
const {
inputsBackground,
submitButton = 'solid',
} = props
const { error: cardError } = useCardsStore()
const { error: formError, handleSubmit } = useFormSubmit(props)
const SubmitButton = buttons[submitButton]
return (
<Form onSubmit={handleSubmit}>
<Column>
<ElementContainer
label='form_card_number'
backgroundColor={inputsBackground}
>
<CardNumberElement options={options} />
</ElementContainer>
<ElementContainer
label='card_holder_name'
backgroundColor={inputsBackground}
>
<Input name='cardHolderName' />
</ElementContainer>
<ElementContainer
label='form_card_expiration'
width='275px'
backgroundColor={inputsBackground}
>
<CardExpiryElement options={options} />
</ElementContainer>
<ElementContainer
label='form_card_code'
width='275px'
backgroundColor={inputsBackground}
>
<CardCvcElement options={options} />
</ElementContainer>
</Column>
<ButtonsBlock>
{(formError || cardError) && (
<Errors>
{formError}
<T9n t={cardError} />
</Errors>
) }
<SubmitButton type='submit'>
Сохранить
</SubmitButton>
</ButtonsBlock>
</Form>
)
}

@ -1,44 +1,42 @@
import { SolidButton } from 'features/UserAccount/styled' import type { MouseEvent } from 'react'
import { import { useToggle } from 'hooks'
Form,
Column,
ButtonsBlock,
Input,
} from './styled'
export const AddCardForm = () => ( import { T9n } from 'features/T9n'
<Form> import { OutlineButton, Icon } from 'features/UserAccount/styled'
<Column>
<Input import type { Props } from './components/Form/hooks'
labelWidth={70} import { AddCardFormInner } from './components/Form'
wrapperWidth={560}
label='Номер' export const AddCardForm = ({
labelLexic='form_card_number' initialformOpen,
/> inputsBackground,
<Input submitButton,
labelWidth={70} }: Props) => {
wrapperWidth={560} const { isOpen, toggle } = useToggle(initialformOpen)
label='Имя'
/> const onAddClick = (e: MouseEvent) => {
<Input e.stopPropagation()
maxLength={5} toggle()
labelWidth={120} }
wrapperWidth={275}
labelLexic='form_card_expiration' return (
/> isOpen
<Input ? (
maxLength={3} <AddCardFormInner
labelWidth={140} inputsBackground={inputsBackground}
wrapperWidth={275} onAddSuccess={toggle}
label='CVC / CVV' submitButton={submitButton}
labelLexic='form_card_code'
/> />
</Column> )
<ButtonsBlock> : (
<SolidButton type='submit'> <OutlineButton
Сохранить type='button'
</SolidButton> onClick={onAddClick}
</ButtonsBlock> >
</Form> <Icon src='plusIcon' />
) <T9n t='add_card' />
</OutlineButton>
)
)
}

@ -1,8 +1,5 @@
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { Input as InputBase } from 'features/Common'
import { InputWrapper } from 'features/Common/Input/styled'
export const Form = styled.form`` export const Form = styled.form``
export const Column = styled.div` export const Column = styled.div`
@ -14,21 +11,37 @@ export const Column = styled.div`
export const ButtonsBlock = styled.div` export const ButtonsBlock = styled.div`
display: flex; display: flex;
flex-direction: column;
align-items: start;
margin-top: 40px; margin-top: 40px;
` `
export const Input = styled(InputBase).attrs(() => ({ export const Input = styled.input`
withError: false, color: #fff;
}))` font-family: Montserrat, Tahoma, sans-serif;
width: auto; font-size: 20px;
font-weight: bold;
line-height: 48px;
background-color: transparent;
border: none;
outline: none;
width: 100%;
padding: 0;
margin: 0;
${InputWrapper} { :-webkit-autofill,
height: 50px; :-webkit-autofill:hover,
padding: 0 24px; :-webkit-autofill:focus,
margin-top: 10px; :-webkit-autofill:active {
box-shadow: 0 0 0 30px #535353 inset;
caret-color: ${({ theme: { colors } }) => colors.text};
-webkit-text-fill-color: ${({ theme: { colors } }) => colors.text};
} }
`
:first-child ${InputWrapper} { export const Errors = styled.span`
margin-top: 0; margin-bottom: 40px;
} font-size: 16px;
line-height: 16px;
color: red;
` `

@ -9,6 +9,8 @@ import {
import { indexLexics } from 'config/lexics/indexLexics' import { indexLexics } from 'config/lexics/indexLexics'
import { PAGES } from 'config' import { PAGES } from 'config'
import { StripeElements } from 'features/StripeElements'
import { useLexicsConfig } from 'features/LexicsStore' import { useLexicsConfig } from 'features/LexicsStore'
import { ExtendedSearchStore, ExtendedSearchPage } from 'features/ExtendedSearchPage' import { ExtendedSearchStore, ExtendedSearchPage } from 'features/ExtendedSearchPage'
@ -16,6 +18,7 @@ import { MatchSwitchesStore } from 'features/MatchSwitches'
import { UserFavoritesStore } from 'features/UserFavorites/store' import { UserFavoritesStore } from 'features/UserFavorites/store'
import { MatchPopup, MatchPopupStore } from 'features/MatchPopup' import { MatchPopup, MatchPopupStore } from 'features/MatchPopup'
import { BuyMatchPopup, BuyMatchPopupStore } from 'features/BuyMatchPopup' import { BuyMatchPopup, BuyMatchPopupStore } from 'features/BuyMatchPopup'
import { CardsStore } from 'features/CardsStore'
const HomePage = lazy(() => import('features/HomePage')) const HomePage = lazy(() => import('features/HomePage'))
const TeamPage = lazy(() => import('features/TeamPage')) const TeamPage = lazy(() => import('features/TeamPage'))
@ -28,6 +31,8 @@ export const AuthenticatedApp = () => {
useLexicsConfig(indexLexics) useLexicsConfig(indexLexics)
return ( return (
<StripeElements>
<CardsStore>
<MatchSwitchesStore> <MatchSwitchesStore>
<UserFavoritesStore> <UserFavoritesStore>
<ExtendedSearchStore> <ExtendedSearchStore>
@ -66,5 +71,7 @@ export const AuthenticatedApp = () => {
</ExtendedSearchStore> </ExtendedSearchStore>
</UserFavoritesStore> </UserFavoritesStore>
</MatchSwitchesStore> </MatchSwitchesStore>
</CardsStore>
</StripeElements>
) )
} }

@ -0,0 +1,56 @@
import isEmpty from 'lodash/isEmpty'
import { AddCardForm } from 'features/AddCardForm'
import { useCardsStore } from 'features/CardsStore'
import { useBuyMatchPopupStore } from 'features/BuyMatchPopup/store'
import {
CloseButton,
Header,
HeaderActions,
HeaderTitle,
} from 'features/PopupComponents'
import { T9n } from 'features/T9n'
import { CardsList } from '../CardsList'
import {
Wrapper,
Body,
Footer,
Button,
} from '../../styled'
export const CardStep = () => {
const { cards } = useCardsStore()
const { close, subscribeToMatch } = useBuyMatchPopupStore()
const emptyCards = isEmpty(cards)
return (
<Wrapper width={630}>
<Header height={50}>
<HeaderTitle>
{emptyCards ? 'Добавление карты' : 'Выберите карту для оплаты'}
</HeaderTitle>
<HeaderActions position='right'>
<CloseButton onClick={close} />
</HeaderActions>
</Header>
<Body padding='0 35px' marginBottom={40}>
<CardsList />
<AddCardForm
initialformOpen={emptyCards}
submitButton='outline'
inputsBackground='rgba(255, 255, 255, 0.1)'
/>
</Body>
<Footer>
{!emptyCards && (
<Button onClick={subscribeToMatch}>
<T9n t='buy_subscription' />
</Button>
)}
</Footer>
</Wrapper>
)
}

@ -0,0 +1,44 @@
import map from 'lodash/map'
import styled from 'styled-components/macro'
import { Radio } from 'features/Common'
import { useCardsStore } from 'features/CardsStore'
const List = styled.ul`
width: 100%;
display: flex;
flex-wrap: wrap;
margin-top: 20px;
margin-bottom: 25px;
`
const Item = styled.li`
width: 50%;
height: 50px;
display: flex;
align-items: center;
`
export const CardsList = () => {
const {
cards,
defaultCard,
onSetDefaultCard,
} = useCardsStore()
return (
<List>
{
map(cards, (card) => (
<Item key={card.id}>
<Radio
label={`${card.brand} •••• ${card.last4}`}
checked={card.id === defaultCard?.id}
onChange={() => onSetDefaultCard(card.id)}
/>
</Item>
))
}
</List>
)
}

@ -14,7 +14,7 @@ import {
} from '../../styled' } from '../../styled'
export const ErrorStep = () => { export const ErrorStep = () => {
const { close } = useBuyMatchPopupStore() const { close, error: paymentError } = useBuyMatchPopupStore()
return ( return (
<Wrapper width={517}> <Wrapper width={517}>
@ -27,7 +27,9 @@ export const ErrorStep = () => {
</HeaderActions> </HeaderActions>
</Header> </Header>
<Body marginTop={30} marginBottom={35}> <Body marginTop={30} marginBottom={35}>
<ResultText t='error_not_enough_balance' /> <ResultText>
{paymentError || <T9n t='error_payment_unsuccessful' />}
</ResultText>
</Body> </Body>
</Wrapper> </Wrapper>
) )

@ -1,7 +1,8 @@
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { T9n } from 'features/T9n' import capitalize from 'lodash/capitalize'
import { ButtonOutline } from 'features/Common'
import { useCardsStore } from 'features/CardsStore'
const Wrapper = styled.div` const Wrapper = styled.div`
display: flex; display: flex;
@ -17,29 +18,14 @@ const CardInfo = styled.span`
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
` `
const ChangeCardButton = styled(ButtonOutline)` export const SelectedCard = () => {
border: none; const { defaultCard } = useCardsStore()
padding: 0;
width: auto;
height: auto;
padding: 0 10px;
margin-left: 10px;
line-height: 20px;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
:hover { if (!defaultCard) return null
color: rgba(255, 255, 255);
}
`
export const SelectedCard = () => ( return (
<Wrapper> <Wrapper>
<CardInfo>Mastercard 4432</CardInfo> <CardInfo>{capitalize(defaultCard?.brand)} {defaultCard?.last4}</CardInfo>
<ChangeCardButton>
<T9n t='change_card' />
</ChangeCardButton>
</Wrapper> </Wrapper>
) )
}

@ -1,6 +1,9 @@
import { useEffect } from 'react'
import isNull from 'lodash/isNull'
import { MDASH } from 'config' import { MDASH } from 'config'
import { T9n } from 'features/T9n'
import { import {
CloseButton, CloseButton,
Header, Header,
@ -8,10 +11,12 @@ import {
HeaderTitle, HeaderTitle,
} from 'features/PopupComponents' } from 'features/PopupComponents'
import { Name } from 'features/Name' import { Name } from 'features/Name'
import { useCardsStore } from 'features/CardsStore'
import { useBuyMatchPopupStore } from '../../store' import { useBuyMatchPopupStore } from '../../store'
import { SelectedCard } from '../SelectedCard' import { SelectedCard } from '../SelectedCard'
import { Subscriptions } from '../Subscriptions' import { Subscriptions } from '../Subscriptions'
import { Steps } from '../../types'
import { import {
Wrapper, Wrapper,
Body, Body,
@ -20,13 +25,24 @@ import {
} from '../../styled' } from '../../styled'
export const SubscriptionSelectionStep = () => { export const SubscriptionSelectionStep = () => {
const {
cards,
fetchCards,
} = useCardsStore()
const { const {
close, close,
goTo,
match, match,
selectedSubscription, selectedSubscription,
subscribeToMatch,
} = useBuyMatchPopupStore() } = useBuyMatchPopupStore()
useEffect(() => {
if (isNull(cards)) {
fetchCards()
}
}, [cards, fetchCards])
if (!match) return null if (!match) return null
return ( return (
@ -48,9 +64,9 @@ export const SubscriptionSelectionStep = () => {
<Footer> <Footer>
<Button <Button
disabled={!selectedSubscription} disabled={!selectedSubscription}
onClick={subscribeToMatch} onClick={(e) => goTo(Steps.CardSelection, e)}
> >
<T9n t='buy_subscription' /> Далее
</Button> </Button>
</Footer> </Footer>
</Wrapper> </Wrapper>

@ -1,10 +1,7 @@
import map from 'lodash/map' import map from 'lodash/map'
import { T9n } from 'features/T9n' import { T9n } from 'features/T9n'
import type { import { MatchSubscription, SubscriptionType } from 'features/BuyMatchPopup/types'
MatchSubscriptions,
MatchSubscription,
} from 'features/BuyMatchPopup/types'
import { import {
Header, Header,
@ -17,7 +14,7 @@ import {
type Props = { type Props = {
onSelect: (subscription: MatchSubscription) => void, onSelect: (subscription: MatchSubscription) => void,
selectedSubscription: MatchSubscription | null, selectedSubscription: MatchSubscription | null,
subscriptions: MatchSubscriptions, subscriptions: Array<MatchSubscription>,
} }
export const SubscriptionsList = ({ export const SubscriptionsList = ({
@ -52,7 +49,7 @@ export const SubscriptionsList = ({
<Price <Price
amount={price} amount={price}
currency={currency} currency={currency}
perPeriod={`per_${type}`} perPeriod={type === SubscriptionType.Month ? null : `per_${type}`}
/> />
</Item> </Item>
) )

@ -24,7 +24,9 @@ export const SuccessStep = () => {
</HeaderTitle> </HeaderTitle>
</Header> </Header>
<Body marginTop={30} marginBottom={40}> <Body marginTop={30} marginBottom={40}>
<ResultText t='success_subscription' /> <ResultText>
<T9n t='success_subscription' />
</ResultText>
</Body> </Body>
<Footer> <Footer>
<Button onClick={close}> <Button onClick={close}>

@ -1,5 +1,6 @@
import { useBuyMatchPopupStore } from './store' import { useBuyMatchPopupStore } from './store'
import { SubscriptionSelectionStep } from './components/SubscriptionSelectionStep' import { SubscriptionSelectionStep } from './components/SubscriptionSelectionStep'
import { CardStep } from './components/CardStep'
import { SuccessStep } from './components/SuccessStep' import { SuccessStep } from './components/SuccessStep'
import { ErrorStep } from './components/ErrorStep' import { ErrorStep } from './components/ErrorStep'
import { Modal } from './styled' import { Modal } from './styled'
@ -8,11 +9,9 @@ import { Steps } from './types'
export * from './store' export * from './store'
export * from './store/helpers' export * from './store/helpers'
const Empty = () => null
const components = { const components = {
[Steps.Subscriptions]: SubscriptionSelectionStep, [Steps.Subscriptions]: SubscriptionSelectionStep,
[Steps.CardSelection]: Empty, [Steps.CardSelection]: CardStep,
[Steps.Success]: SuccessStep, [Steps.Success]: SuccessStep,
[Steps.Error]: ErrorStep, [Steps.Error]: ErrorStep,
} }

@ -1,18 +1,30 @@
import map from 'lodash/map' import map from 'lodash/map'
import isNumber from 'lodash/isNumber' import reduce from 'lodash/reduce'
import { currencySymbols } from 'config' import { currencySymbols } from 'config'
import type { MatchSubscriptionsResponse } from 'requests' import type { MatchSubscriptionsResponse } from 'requests'
import { SubscriptionType } from '../types' import { SubscriptionType, MatchSubscriptions } from '../types'
export const transformSubsciptions = (subscriptions: MatchSubscriptionsResponse) => ( export const transformSubsciptions = (
map(subscriptions, (subscription) => ({ subscriptions: MatchSubscriptionsResponse,
) => (
reduce(
subscriptions,
(
acc,
periodSubscriptions,
type,
) => {
const period = type as SubscriptionType
acc[period] = map(periodSubscriptions, (subscription) => ({
currency: currencySymbols[subscription.currency_iso || 'RUB'], currency: currencySymbols[subscription.currency_iso || 'RUB'],
id: subscription.id, type: period,
lexic: subscription.lexic, ...subscription,
price: subscription.price_month || subscription.price_year || 0,
type: isNumber(subscription.price_month) ? SubscriptionType.Month : SubscriptionType.Year,
})) }))
return acc
},
{} as MatchSubscriptions,
)
) )

@ -6,24 +6,53 @@ import {
} from 'react' } from 'react'
import { useHistory } from 'react-router-dom' import { useHistory } from 'react-router-dom'
import type { PaymentIntent, StripeError } from '@stripe/stripe-js'
import last from 'lodash/last' import last from 'lodash/last'
import { ProfileTypes } from 'config' import { ProfileTypes } from 'config'
import { buyMatchSubscription } from 'requests'
import {
buyMatchSubscription,
buyMatchPayOnceSubscription,
notifySuccessfulSubscription,
PaymentActions,
} from 'requests/buySubscription'
import type { OnFailedPaymentActionData } from 'requests/buySubscription'
import type { Match } from 'features/Matches/hooks' import type { Match } from 'features/Matches/hooks'
import type { MatchSubscription } from 'features/BuyMatchPopup/types'
import { useCardsStore } from 'features/CardsStore'
import { useMatchPopupStore } from 'features/MatchPopup' import { useMatchPopupStore } from 'features/MatchPopup'
import { Steps } from 'features/BuyMatchPopup/types' import { Steps, SubscriptionType } from 'features/BuyMatchPopup/types'
import { getProfileUrl } from 'features/ProfileLink/helpers' import { getProfileUrl } from 'features/ProfileLink/helpers'
import { useSubscriptions } from './useSubscriptions' import { useSubscriptions } from './useSubscriptions'
import { useStripe3DSecure } from './useStripe3DSecure'
const requests = {
[SubscriptionType.Month]: buyMatchSubscription,
[SubscriptionType.Year]: buyMatchPayOnceSubscription,
}
const getSubscriptionItem = (subscription: MatchSubscription) => ({
currency_id: subscription.currency_id,
currency_iso: subscription.currency_iso,
id: subscription.id,
lexic: subscription.lexic,
price: subscription.price,
sub: subscription.sub,
})
export const useBuyMatchPopup = () => { export const useBuyMatchPopup = () => {
const history = useHistory() const history = useHistory()
const { defaultCard } = useCardsStore()
const { openMatchPopup } = useMatchPopupStore() const { openMatchPopup } = useMatchPopupStore()
const { handle3DSecure } = useStripe3DSecure()
const [steps, setSteps] = useState<Array<Steps>>([]) const [steps, setSteps] = useState<Array<Steps>>([])
const [match, setMatch] = useState<Match | null>(null) const [match, setMatch] = useState<Match | null>(null)
const [error, setError] = useState('')
const goTo = useCallback( const goTo = useCallback(
(step: Steps, e?: MouseEvent<HTMLButtonElement>) => setSteps((state) => { (step: Steps, e?: MouseEvent<HTMLButtonElement>) => setSteps((state) => {
@ -32,7 +61,9 @@ export const useBuyMatchPopup = () => {
}), }),
[], [],
) )
const goBack = useCallback(() => setSteps((state) => {
const goBack = useCallback((e?: MouseEvent<HTMLButtonElement>) => setSteps((state) => {
e?.stopPropagation()
const newState = [...state] const newState = [...state]
newState.pop() newState.pop()
return newState return newState
@ -56,6 +87,7 @@ export const useBuyMatchPopup = () => {
const closePopup = () => { const closePopup = () => {
setMatch(null) setMatch(null)
setSteps([]) setSteps([])
setError('')
resetSubscriptions() resetSubscriptions()
} }
@ -68,7 +100,7 @@ export const useBuyMatchPopup = () => {
history.push(matchLink) history.push(matchLink)
} }
const handleSuccessfulSubscription = () => { const onSuccessfulSubscription = () => {
closePopup() closePopup()
if (!match) return if (!match) return
if (match.calc) { if (match.calc) {
@ -80,13 +112,44 @@ export const useBuyMatchPopup = () => {
} }
} }
const subscribeToMatch = (e: MouseEvent) => { const goToError = () => goTo(Steps.Error)
e.stopPropagation()
if (selectedSubscription) { const onConfirmationError = (stripeError?: StripeError) => {
buyMatchSubscription(selectedSubscription) setError(stripeError?.message || '')
.then(handleSuccessfulSubscription) goToError()
.catch(() => goTo(Steps.Error)) }
const onConfirmationSuccess = ({ id }: PaymentIntent) => {
if (!selectedSubscription) return
const item = getSubscriptionItem(selectedSubscription)
notifySuccessfulSubscription({ item, paymentIntentId: id })
.then(onSuccessfulSubscription, goToError)
}
const onUnsuccessfulSubscription = (data?: OnFailedPaymentActionData) => {
switch (data?.action) {
case PaymentActions.ThreeDSecure:
handle3DSecure(data.value)?.then(onConfirmationSuccess, onConfirmationError)
break
case PaymentActions.Redirect:
window.location.href = data.value
break
case undefined:
onConfirmationError()
break
}
} }
const subscribeToMatch = () => {
if (!selectedSubscription || !defaultCard) return
const item = getSubscriptionItem(selectedSubscription)
const buy = requests[selectedSubscription.type]
buy({ cardId: defaultCard.id, item }).then(
onSuccessfulSubscription,
onUnsuccessfulSubscription,
)
} }
useEffect(() => { useEffect(() => {
@ -98,6 +161,7 @@ export const useBuyMatchPopup = () => {
return { return {
close: closePopup, close: closePopup,
currentStep: last(steps), currentStep: last(steps),
error,
goBack, goBack,
goTo, goTo,
match, match,

@ -1,6 +1,7 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import isEmpty from 'lodash/isEmpty' import isEmpty from 'lodash/isEmpty'
import flatMap from 'lodash/flatMap'
import map from 'lodash/map' import map from 'lodash/map'
import { useLexicsStore } from 'features/LexicsStore' import { useLexicsStore } from 'features/LexicsStore'
@ -10,7 +11,10 @@ import type { MatchSubscriptions } from '../../types'
export const useLexicsFetcher = () => { export const useLexicsFetcher = () => {
const { addLexicsConfig } = useLexicsStore() const { addLexicsConfig } = useLexicsStore()
const fetchLexics = useCallback((subscriptions: MatchSubscriptions) => { const fetchLexics = useCallback((subscriptions: MatchSubscriptions) => {
const lexics = map(subscriptions, ({ lexic }) => lexic) const lexics = flatMap(
subscriptions,
(periodSubscriptions) => map(periodSubscriptions, ({ lexic }) => lexic),
)
if (!isEmpty(lexics)) { if (!isEmpty(lexics)) {
addLexicsConfig(lexics) addLexicsConfig(lexics)
} }

@ -0,0 +1,20 @@
import type { PaymentIntentResult } from '@stripe/stripe-js'
import { useStripe } from '@stripe/react-stripe-js'
export const useStripe3DSecure = () => {
const stripe = useStripe()
const handleConfirmationResult = (result: PaymentIntentResult) => {
if (result.error) {
return Promise.reject(result.error)
}
return Promise.resolve(result.paymentIntent)
}
const handle3DSecure = (clientSecret: string) => (
stripe?.confirmCardPayment(clientSecret).then(handleConfirmationResult)
)
return { handle3DSecure }
}

@ -1,11 +1,8 @@
import { import {
useMemo,
useState, useState,
useCallback, useCallback,
} from 'react' } from 'react'
import filter from 'lodash/filter'
import { getSubscriptions } from 'requests' import { getSubscriptions } from 'requests'
import { SportTypes } from 'config' import { SportTypes } from 'config'
@ -15,28 +12,28 @@ import { SubscriptionType } from '../../types'
import { transformSubsciptions } from '../helpers' import { transformSubsciptions } from '../helpers'
import { useLexicsFetcher } from './useLexicsFetcher' import { useLexicsFetcher } from './useLexicsFetcher'
const defaultSubscriptions: MatchSubscriptions = {
[SubscriptionType.Month]: [],
[SubscriptionType.Year]: [],
}
export const useSubscriptions = () => { export const useSubscriptions = () => {
const { fetchLexics } = useLexicsFetcher() const { fetchLexics } = useLexicsFetcher()
const [selectedPeriod, setSelectedPeriod] = useState(SubscriptionType.Month) const [selectedPeriod, setSelectedPeriod] = useState(SubscriptionType.Month)
const [subscriptionsList, setSubscriptionsList] = useState<MatchSubscriptions>([]) const [matchSubscriptions, setMatchSubscriptionsList] = useState(defaultSubscriptions)
const [selectedSubscription, setSelectedSubscription] = useState<MatchSubscription | null>(null) const [selectedSubscription, setSelectedSubscription] = useState<MatchSubscription | null>(null)
const fetchSubscriptions = useCallback((sport: SportTypes, id: number) => { const fetchSubscriptions = useCallback((sport: SportTypes, id: number) => {
getSubscriptions(sport, id) getSubscriptions(sport, id)
.then(transformSubsciptions) .then(transformSubsciptions)
.then(fetchLexics) .then(fetchLexics)
.then(setSubscriptionsList) .then(setMatchSubscriptionsList)
}, [fetchLexics]) }, [fetchLexics])
const subscriptions = useMemo(
() => filter(subscriptionsList, { type: selectedPeriod }),
[selectedPeriod, subscriptionsList],
)
const resetSubscriptions = useCallback(() => { const resetSubscriptions = useCallback(() => {
setSelectedPeriod(SubscriptionType.Month) setSelectedPeriod(SubscriptionType.Month)
setSelectedSubscription(null) setSelectedSubscription(null)
setSubscriptionsList([]) setMatchSubscriptionsList(defaultSubscriptions)
}, []) }, [])
return { return {
@ -46,6 +43,6 @@ export const useSubscriptions = () => {
resetSubscriptions, resetSubscriptions,
selectedPeriod, selectedPeriod,
selectedSubscription, selectedSubscription,
subscriptions, subscriptions: matchSubscriptions[selectedPeriod],
} }
} }

@ -5,7 +5,6 @@ import { devices } from 'config'
import { Modal as BaseModal } from 'features/Modal' import { Modal as BaseModal } from 'features/Modal'
import { ModalWindow } from 'features/Modal/styled' import { ModalWindow } from 'features/Modal/styled'
import { ButtonSolid } from 'features/Common' import { ButtonSolid } from 'features/Common'
import { T9n } from 'features/T9n'
export const Modal = styled(BaseModal)` export const Modal = styled(BaseModal)`
background-color: rgba(0, 0, 0, 0.7); background-color: rgba(0, 0, 0, 0.7);
@ -50,11 +49,13 @@ export const Wrapper = styled.div<WrapperProps>`
type BodyProps = { type BodyProps = {
marginBottom?: number, marginBottom?: number,
marginTop?: number, marginTop?: number,
padding?: string,
} }
export const Body = styled.div<BodyProps>` export const Body = styled.div<BodyProps>`
margin-top: ${({ marginTop }) => (marginTop ? `${marginTop}px` : '')}; margin-top: ${({ marginTop }) => (marginTop ? `${marginTop}px` : '')};
margin-bottom: ${({ marginBottom }) => (marginBottom ? `${marginBottom}px` : '')}; margin-bottom: ${({ marginBottom }) => (marginBottom ? `${marginBottom}px` : '')};
padding: ${({ padding }) => (padding || '')};
` `
type FooterProps = { type FooterProps = {
@ -70,7 +71,7 @@ export const Footer = styled.div<FooterProps>`
margin-bottom: 15px; margin-bottom: 15px;
` `
export const ResultText = styled(T9n)` export const ResultText = styled.span`
width: 100%; width: 100%;
display: inline-block; display: inline-block;
text-align: center; text-align: center;

@ -1,3 +1,5 @@
import type { SubscriptionResponse } from 'requests'
export enum Steps { export enum Steps {
CardSelection = 'CardSelection', CardSelection = 'CardSelection',
Error = 'Error', Error = 'Error',
@ -10,12 +12,9 @@ export enum SubscriptionType {
Year = 'year', Year = 'year',
} }
export type MatchSubscription = { export type MatchSubscription = SubscriptionResponse & {
currency: string, currency: string,
id: number,
lexic: number,
price: number,
type: SubscriptionType, type: SubscriptionType,
} }
export type MatchSubscriptions = Array<MatchSubscription> export type MatchSubscriptions = Record<SubscriptionType, Array<MatchSubscription>>

@ -0,0 +1,51 @@
import {
useCallback,
useState,
useMemo,
} from 'react'
import find from 'lodash/find'
import type { Cards } from 'requests/getCardsList'
import { getCardsList } from 'requests/getCardsList'
import { addCard } from 'requests/addCard'
import { deleteCard } from 'requests/deleteCard'
import { setDefaultCard as setDefaultCardRequest } from 'requests/setDefaultCard'
export const useBankCards = () => {
const [error, setError] = useState('')
const [cards, setCards] = useState<Cards | null>(null)
const defaultCard = useMemo(
() => find(cards, { default: true }),
[cards],
)
const fetchCards = useCallback(() => getCardsList().then(setCards), [])
const onAddCard = async (token: string) => (
addCard(token)
.catch(() => {
setError('error_can_not_add_card')
return Promise.reject()
})
.then(fetchCards)
)
const onDeleteCard = (cardId: string) => {
deleteCard(cardId).then(fetchCards)
}
const onSetDefaultCard = (cardId: string) => {
setDefaultCardRequest(cardId).then(fetchCards)
}
return {
cards,
defaultCard,
error,
fetchCards,
onAddCard,
onDeleteCard,
onSetDefaultCard,
}
}

@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
import { createContext, useContext } from 'react'
import { useBankCards } from './hooks'
type Context = ReturnType<typeof useBankCards>
type Props = { children: ReactNode }
const CardsContext = createContext({} as Context)
export const CardsStore = ({ children }: Props) => {
const value = useBankCards()
return (
<CardsContext.Provider value={value}>
{children}
</CardsContext.Provider>
)
}
export const useCardsStore = () => useContext(CardsContext)

@ -9,4 +9,6 @@ export const langsList = [
locale: 'en', locale: 'en',
title: 'English', title: 'English',
}, },
] ] as const
export type Languages = typeof langsList[number]['locale']

@ -5,6 +5,7 @@ import { OutsideClick } from 'features/OutsideClick'
import { useToggle } from 'hooks' import { useToggle } from 'hooks'
import type { Languages } from './config'
import { langsList } from './config' import { langsList } from './config'
import { import {
Wrapper, Wrapper,
@ -22,7 +23,7 @@ export const LanguageSelect = () => {
open, open,
} = useToggle() } = useToggle()
const handleLangChange = (locale: string) => () => { const handleLangChange = (locale: Languages) => () => {
changeLang(locale) changeLang(locale)
close() close()
} }

@ -2,20 +2,22 @@ import { useCallback } from 'react'
import { useLocalStore } from 'hooks' import { useLocalStore } from 'hooks'
import { Languages } from 'features/LanguageSelect/config'
import { isSupportedLang } from '../helpers/isSupportedLang' import { isSupportedLang } from '../helpers/isSupportedLang'
const LANG_KEY = 'lang' const LANG_KEY = 'lang'
const DEFAULT_LANG = 'en' const DEFAULT_LANG = 'en'
export const useLang = () => { export const useLang = () => {
const [lang, setLang] = useLocalStore({ const [lang, setLang] = useLocalStore<Languages>({
defaultValue: DEFAULT_LANG, defaultValue: DEFAULT_LANG,
key: LANG_KEY, key: LANG_KEY,
validator: isSupportedLang, validator: isSupportedLang,
}) })
const changeLang = useCallback( const changeLang = useCallback(
(newLang: string) => { (newLang: Languages) => {
if (newLang === lang) return if (newLang === lang) return
setLang(newLang) setLang(newLang)
}, },

@ -20,7 +20,7 @@ it('equals to no country access type', () => {
access: false, access: false,
sub: false, sub: false,
}) })
expect(getMatchAccess(match)).toBe(MatchAccess.NoCountryAccess) expect(getMatchAccess(match)).toBe(MatchAccess.CanBuyMatch)
}) })
it('equals to redirect type', () => { it('equals to redirect type', () => {

@ -1,3 +1,5 @@
import { Fragment } from 'react'
import { currencySymbols } from 'config' import { currencySymbols } from 'config'
import { T9n } from 'features/T9n' import { T9n } from 'features/T9n'
@ -12,19 +14,26 @@ type Props = {
amount: number, amount: number,
className?: string, className?: string,
currency?: string, currency?: string,
perPeriod?: string, perPeriod?: string | null,
} }
export const Price = ({ export const Price = ({
amount, amount,
className, className,
currency = currencySymbols.RUB, currency = currencySymbols.RUB,
perPeriod = 'month', perPeriod,
}: Props) => ( }: Props) => (
<PriceWrapper className={className}> <PriceWrapper className={className}>
<PriceAmount>{amount}</PriceAmount> <PriceAmount>{amount}</PriceAmount>
<PriceDetails> <PriceDetails>
{currency} / <T9n t={perPeriod} /> {currency}
{
perPeriod && (
<Fragment>
/ <T9n t={perPeriod} />
</Fragment>
)
}
</PriceDetails> </PriceDetails>
</PriceWrapper> </PriceWrapper>
) )

@ -0,0 +1,24 @@
import type { ReactNode } from 'react'
import { Elements } from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
import { STRIPE_PUBLIC_KEY } from 'config/env'
import { useLexicsStore } from 'features/LexicsStore'
const stripe = loadStripe(STRIPE_PUBLIC_KEY)
type Props = {
children: ReactNode,
}
export const StripeElements = ({ children }: Props) => {
const { lang } = useLexicsStore()
return (
<Elements stripe={stripe} options={{ locale: lang }}>
{children}
</Elements>
)
}

@ -1,26 +1,28 @@
import { Radio } from 'features/Common/Radio' import type { Card } from 'requests/getCardsList'
import { InlineButton } from '../../styled' import { InlineButton } from '../../styled'
import { CardNumberWrapper } from './styled' import { CardNumberWrapper, CustomRadio } from './styled'
type Props = { type Props = {
card: Card,
checked?: boolean, checked?: boolean,
number: string, onChange: (cardId: string) => void,
type: 'visa' | 'mastercard', onDelete: (cardId: string) => void,
} }
export const BankCard = ({ export const BankCard = ({
card,
checked, checked,
number, onChange,
type, onDelete,
}: Props) => ( }: Props) => (
<CardNumberWrapper> <CardNumberWrapper>
<Radio <CustomRadio
label={`${type} ${number}`} label={`${card.brand} •••• ${card.last4}`}
checked={checked} checked={checked}
onClick={() => {}} onChange={() => onChange(card.id)}
/> />
<InlineButton> <InlineButton onClick={() => onDelete(card.id)}>
Удалить Удалить
</InlineButton> </InlineButton>
</CardNumberWrapper> </CardNumberWrapper>

@ -4,6 +4,7 @@ import { devices } from 'config/devices'
import { Label } from 'features/Common/Radio/styled' import { Label } from 'features/Common/Radio/styled'
import { RadioSvg } from 'features/Common/Radio/Icon' import { RadioSvg } from 'features/Common/Radio/Icon'
import { Radio } from 'features/Common'
import { InlineButton } from '../../styled' import { InlineButton } from '../../styled'
@ -18,17 +19,30 @@ export const CardNumberWrapper = styled.div`
margin-bottom: 10px; margin-bottom: 10px;
overflow: hidden; overflow: hidden;
:hover ${InlineButton} {
transform: translateX(0);
}
`
export const CustomRadio = styled(Radio)`
height: 100%;
width: 100%;
display: flex;
align-items: center;
${RadioSvg} {
margin-right: 24px;
}
${Label} { ${Label} {
height: 100%;
width: 100%;
font-size: 20px; font-size: 20px;
line-height: 24px; line-height: 24px;
margin-left: 25px; margin-left: 25px;
text-transform: capitalize; text-transform: capitalize;
} }
${RadioSvg} {
margin-right: 24px;
}
@media ${devices.tablet} { @media ${devices.tablet} {
max-width: 415px; max-width: 415px;
@ -45,8 +59,4 @@ export const CardNumberWrapper = styled.div`
line-height: 24px; line-height: 24px;
} }
} }
:hover ${InlineButton} {
transform: translateX(0);
}
` `

@ -1,32 +1,42 @@
import { useToggle } from 'hooks' import { useEffect } from 'react'
import map from 'lodash/map'
import isNull from 'lodash/isNull'
import { T9n } from 'features/T9n'
import { AddCardForm } from 'features/AddCardForm' import { AddCardForm } from 'features/AddCardForm'
import { useCardsStore } from 'features/CardsStore'
import { BankCard } from '../BankCard' import { BankCard } from '../BankCard'
import { OutlineButton, Icon } from '../../styled'
import { FormWrapper, Wrapper } from './styled' import { FormWrapper, Wrapper } from './styled'
export const PageBankCards = () => { export const PageBankCards = () => {
const { isOpen, toggle } = useToggle() const {
cards,
defaultCard,
fetchCards,
onDeleteCard,
onSetDefaultCard,
} = useCardsStore()
useEffect(() => {
if (isNull(cards)) {
fetchCards()
}
}, [fetchCards, cards])
return ( return (
<Wrapper> <Wrapper>
<BankCard type='mastercard' number='•••• 4432' checked /> {map(cards, (card) => (
<BankCard type='mastercard' number='•••• 4432' /> <BankCard
<BankCard type='mastercard' number='•••• 4432' /> key={card.id}
card={card}
checked={card.id === defaultCard?.id}
onChange={onSetDefaultCard}
onDelete={onDeleteCard}
/>
))}
<FormWrapper> <FormWrapper>
{isOpen <AddCardForm />
? <AddCardForm />
: (
<OutlineButton
type='button'
onClick={toggle}
>
<Icon src='plusIcon' />
<T9n t='add_card' />
</OutlineButton>
)}
</FormWrapper> </FormWrapper>
</Wrapper> </Wrapper>
) )

@ -1,7 +1,7 @@
import { PAGES } from 'config' import { PAGES } from 'config'
import { removeToken } from 'helpers' import { removeToken } from 'helpers'
export const logoutIfUnauthorized = (response: Response) => { export const logoutIfUnauthorized = async (response: Response) => {
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
removeToken() removeToken()
window.location.pathname = PAGES.login window.location.pathname = PAGES.login
@ -10,5 +10,7 @@ export const logoutIfUnauthorized = (response: Response) => {
const error = new Error(response.statusText) const error = new Error(response.statusText)
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(error) console.error(error)
return Promise.reject(error)
const body = await response.json()
return Promise.reject(body)
} }

@ -0,0 +1,15 @@
import { API_ROOT } from 'config'
import { callApi } from 'helpers'
export const addCard = async (token: string) => {
const config = {
body: {
token,
},
}
return callApi({
config,
url: `${API_ROOT}/account/attach-card`,
})
}

@ -1,24 +0,0 @@
import { API_ROOT } from 'config'
import { callApi } from 'helpers'
import type { SubscriptionType } from 'features/BuyMatchPopup/types'
type Subscription = {
id: number,
type: SubscriptionType,
}
export const buyMatchSubscription = ({ id, type }: Subscription) => {
const config = {
body: {
interval: type,
is_scheduled: 0,
subscription_plan: id,
},
}
return callApi({
config,
url: `${API_ROOT}/account/purchase`,
})
}

@ -0,0 +1,24 @@
import { API_ROOT } from 'config'
import { callApi } from 'helpers'
import type { SubscriptionResponse } from '../getSubscriptions'
import { handleUnsuccessfulSubscription } from './handleUnsuccessfulSubscription'
type Args = {
cardId: string,
item: SubscriptionResponse,
}
export const buyMatchPayOnceSubscription = ({ cardId, item }: Args) => {
const config = {
body: {
card_id: cardId,
item,
},
}
return callApi({
config,
url: `${API_ROOT}/account/pay`,
}).catch(handleUnsuccessfulSubscription)
}

@ -0,0 +1,25 @@
import { API_ROOT } from 'config'
import { callApi } from 'helpers'
import type { SubscriptionResponse } from 'requests/getSubscriptions'
import { handleUnsuccessfulSubscription } from './handleUnsuccessfulSubscription'
type Subscription = {
cardId: string,
item: SubscriptionResponse,
}
export const buyMatchSubscription = ({ cardId, item }: Subscription) => {
const config = {
body: {
card_id: cardId,
item,
},
}
return callApi({
config,
url: `${API_ROOT}/account/subscription`,
}).catch(handleUnsuccessfulSubscription)
}

@ -0,0 +1,39 @@
type UnsuccessfulResponse = {
data?: {
client_secret?: string,
redirect_to_url?: string,
use_stripe_sdk?: string,
},
reason: string,
}
export enum PaymentActions {
ThreeDSecure,
Redirect,
}
export type OnFailedPaymentActionData = {
action: PaymentActions,
value: string,
}
export const handleUnsuccessfulSubscription = ({ data, reason }: UnsuccessfulResponse) => {
const clientSecret = data?.use_stripe_sdk || data?.client_secret
if (reason === 'requires_action' && clientSecret) {
const actionData: OnFailedPaymentActionData = {
action: PaymentActions.ThreeDSecure,
value: clientSecret,
}
return Promise.reject(actionData)
}
if (reason === 'requires_action' && data?.redirect_to_url) {
const actionData: OnFailedPaymentActionData = {
action: PaymentActions.Redirect,
value: data.redirect_to_url,
}
return Promise.reject(actionData)
}
return Promise.reject()
}

@ -0,0 +1,4 @@
export * from './buyMatchPayOnceSubscription'
export * from './buyMatchSubscriptions'
export * from './handleUnsuccessfulSubscription'
export * from './notifySuccessfulSubscription'

@ -0,0 +1,23 @@
import { API_ROOT } from 'config'
import { callApi } from 'helpers'
import type { SubscriptionResponse } from '../getSubscriptions'
type Args = {
item: SubscriptionResponse,
paymentIntentId: string,
}
export const notifySuccessfulSubscription = ({ item, paymentIntentId }: Args) => {
const config = {
body: {
item,
pi_id: paymentIntentId,
},
}
return callApi({
config,
url: `${API_ROOT}/account/pay-finish`,
})
}

@ -0,0 +1,15 @@
import { API_ROOT } from 'config'
import { callApi } from 'helpers'
export const deleteCard = async (cardId: string) => {
const config = {
body: {
card_id: cardId,
},
}
return callApi({
config,
url: `${API_ROOT}/account/delete-card`,
})
}

@ -0,0 +1,22 @@
import { API_ROOT } from 'config'
import { callApi } from 'helpers'
export type Card = {
brand: string,
default: boolean,
id: string,
last4: string,
}
export type Cards = Array<Card>
export const getCardsList = async (): Promise<Cards> => {
const config = {
method: 'GET',
}
return callApi({
config,
url: `${API_ROOT}/account/get-cards`,
})
}

@ -4,20 +4,27 @@ import {
PROCEDURES, PROCEDURES,
SportTypes, SportTypes,
} from 'config' } from 'config'
import { SubscriptionType } from 'features/BuyMatchPopup/types'
import { callApi } from 'helpers' import { callApi } from 'helpers'
const proc = PROCEDURES.get_match_subscriptions const proc = PROCEDURES.get_match_subscriptions
type Subscription = { export type SubscriptionResponse = {
currency_id: number, currency_id: number,
currency_iso: keyof typeof currencySymbols, currency_iso: keyof typeof currencySymbols,
id: number, id: number,
lexic: number, lexic: number,
price_month: number | null, price: number,
price_year: number | null, sub: {
date_from: string,
date_to: string,
id: number,
},
} }
export type MatchSubscriptionsResponse = Array<Subscription> export type MatchSubscriptionsResponse = (
Record<SubscriptionType, Array<SubscriptionResponse>>
)
export const getSubscriptions = async ( export const getSubscriptions = async (
sport: SportTypes, sport: SportTypes,

@ -24,4 +24,4 @@ export * from './getSportActions'
export * from './getMatchPlaylists' export * from './getMatchPlaylists'
export * from './getPlayerPlaylists' export * from './getPlayerPlaylists'
export * from './getSubscriptions' export * from './getSubscriptions'
export * from './buyMatchSubscriptions' export * from './buySubscription'

@ -0,0 +1,15 @@
import { API_ROOT } from 'config'
import { callApi } from 'helpers'
export const setDefaultCard = async (cardId: string) => {
const config = {
body: {
card_id: cardId,
},
}
return callApi({
config,
url: `${API_ROOT}/account/set-default-card`,
})
}
Loading…
Cancel
Save