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. 82
      src/features/AddCardForm/index.tsx
  13. 41
      src/features/AddCardForm/styled.tsx
  14. 81
      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. 38
      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. 32
      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:
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

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

@ -7,6 +7,7 @@
<meta name="theme-color" content="#000000" />
<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">
<script src="https://js.stripe.com/v3" async></script>
<title>Instat TV</title>
</head>
<body>

@ -1 +1,3 @@
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 { publicLexics } from './public'
const matchPopupLexics = {
apply: 13491,
@ -75,7 +77,6 @@ export const indexLexics = {
round_highilights: 13050,
save: 828,
search_results: 9014,
select_language: 1005,
sport: 12993,
team: 658,
to_home: 13376,
@ -88,4 +89,6 @@ export const indexLexics = {
...proceduresLexics,
...matchPopupLexics,
...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 = {
bank_card: 14205,
@ -8,7 +9,6 @@ const navigations = {
}
export const userAccountLexics = {
add_card: 8313,
change: 12614,
country: 835,
delete: 848,
@ -27,4 +27,5 @@ export const userAccountLexics = {
user_account: 12928,
...navigations,
...publicLexics,
...paymentLexics,
}

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

@ -1,8 +1,5 @@
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 Column = styled.div`
@ -14,21 +11,37 @@ export const Column = styled.div`
export const ButtonsBlock = styled.div`
display: flex;
flex-direction: column;
align-items: start;
margin-top: 40px;
`
export const Input = styled(InputBase).attrs(() => ({
withError: false,
}))`
width: auto;
export const Input = styled.input`
color: #fff;
font-family: Montserrat, Tahoma, sans-serif;
font-size: 20px;
font-weight: bold;
line-height: 48px;
background-color: transparent;
border: none;
outline: none;
width: 100%;
padding: 0;
margin: 0;
${InputWrapper} {
height: 50px;
padding: 0 24px;
margin-top: 10px;
:-webkit-autofill,
:-webkit-autofill:hover,
:-webkit-autofill:focus,
:-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} {
margin-top: 0;
}
export const Errors = styled.span`
margin-bottom: 40px;
font-size: 16px;
line-height: 16px;
color: red;
`

@ -9,6 +9,8 @@ import {
import { indexLexics } from 'config/lexics/indexLexics'
import { PAGES } from 'config'
import { StripeElements } from 'features/StripeElements'
import { useLexicsConfig } from 'features/LexicsStore'
import { ExtendedSearchStore, ExtendedSearchPage } from 'features/ExtendedSearchPage'
@ -16,6 +18,7 @@ import { MatchSwitchesStore } from 'features/MatchSwitches'
import { UserFavoritesStore } from 'features/UserFavorites/store'
import { MatchPopup, MatchPopupStore } from 'features/MatchPopup'
import { BuyMatchPopup, BuyMatchPopupStore } from 'features/BuyMatchPopup'
import { CardsStore } from 'features/CardsStore'
const HomePage = lazy(() => import('features/HomePage'))
const TeamPage = lazy(() => import('features/TeamPage'))
@ -28,43 +31,47 @@ export const AuthenticatedApp = () => {
useLexicsConfig(indexLexics)
return (
<MatchSwitchesStore>
<UserFavoritesStore>
<ExtendedSearchStore>
<MatchPopupStore>
<BuyMatchPopupStore>
<MatchPopup />
<BuyMatchPopup />
<StripeElements>
<CardsStore>
<MatchSwitchesStore>
<UserFavoritesStore>
<ExtendedSearchStore>
<MatchPopupStore>
<BuyMatchPopupStore>
<MatchPopup />
<BuyMatchPopup />
{/* в Switch как прямой children можно рендерить только Route или Redirect */}
<Switch>
<Route path={PAGES.useraccount}>
<UserAccount />
</Route>
<Route exact path={PAGES.home}>
<HomePage />
</Route>
<Route path={`/:sportName${PAGES.tournament}/:pageId`}>
<TournamentPage />
</Route>
<Route path={`/:sportName${PAGES.team}/:pageId`}>
<TeamPage />
</Route>
<Route path={`/:sportName${PAGES.player}/:pageId`}>
<PlayerPage />
</Route>
<Route path={`/:sportName${PAGES.match}/:pageId`}>
<MatchPage />
</Route>
<Route path={PAGES.extendedSearch}>
<ExtendedSearchPage />
</Route>
<Redirect to={PAGES.home} />
</Switch>
</BuyMatchPopupStore>
</MatchPopupStore>
</ExtendedSearchStore>
</UserFavoritesStore>
</MatchSwitchesStore>
{/* в Switch как прямой children можно рендерить только Route или Redirect */}
<Switch>
<Route path={PAGES.useraccount}>
<UserAccount />
</Route>
<Route exact path={PAGES.home}>
<HomePage />
</Route>
<Route path={`/:sportName${PAGES.tournament}/:pageId`}>
<TournamentPage />
</Route>
<Route path={`/:sportName${PAGES.team}/:pageId`}>
<TeamPage />
</Route>
<Route path={`/:sportName${PAGES.player}/:pageId`}>
<PlayerPage />
</Route>
<Route path={`/:sportName${PAGES.match}/:pageId`}>
<MatchPage />
</Route>
<Route path={PAGES.extendedSearch}>
<ExtendedSearchPage />
</Route>
<Redirect to={PAGES.home} />
</Switch>
</BuyMatchPopupStore>
</MatchPopupStore>
</ExtendedSearchStore>
</UserFavoritesStore>
</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'
export const ErrorStep = () => {
const { close } = useBuyMatchPopupStore()
const { close, error: paymentError } = useBuyMatchPopupStore()
return (
<Wrapper width={517}>
@ -27,7 +27,9 @@ export const ErrorStep = () => {
</HeaderActions>
</Header>
<Body marginTop={30} marginBottom={35}>
<ResultText t='error_not_enough_balance' />
<ResultText>
{paymentError || <T9n t='error_payment_unsuccessful' />}
</ResultText>
</Body>
</Wrapper>
)

@ -1,7 +1,8 @@
import styled from 'styled-components/macro'
import { T9n } from 'features/T9n'
import { ButtonOutline } from 'features/Common'
import capitalize from 'lodash/capitalize'
import { useCardsStore } from 'features/CardsStore'
const Wrapper = styled.div`
display: flex;
@ -17,29 +18,14 @@ const CardInfo = styled.span`
color: rgba(255, 255, 255, 0.7);
`
const ChangeCardButton = styled(ButtonOutline)`
border: none;
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;
export const SelectedCard = () => {
const { defaultCard } = useCardsStore()
:hover {
color: rgba(255, 255, 255);
}
`
if (!defaultCard) return null
export const SelectedCard = () => (
<Wrapper>
<CardInfo>Mastercard 4432</CardInfo>
<ChangeCardButton>
<T9n t='change_card' />
</ChangeCardButton>
</Wrapper>
)
return (
<Wrapper>
<CardInfo>{capitalize(defaultCard?.brand)} {defaultCard?.last4}</CardInfo>
</Wrapper>
)
}

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

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

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

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

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

@ -6,24 +6,53 @@ import {
} from 'react'
import { useHistory } from 'react-router-dom'
import type { PaymentIntent, StripeError } from '@stripe/stripe-js'
import last from 'lodash/last'
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 { MatchSubscription } from 'features/BuyMatchPopup/types'
import { useCardsStore } from 'features/CardsStore'
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 { 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 = () => {
const history = useHistory()
const { defaultCard } = useCardsStore()
const { openMatchPopup } = useMatchPopupStore()
const { handle3DSecure } = useStripe3DSecure()
const [steps, setSteps] = useState<Array<Steps>>([])
const [match, setMatch] = useState<Match | null>(null)
const [error, setError] = useState('')
const goTo = useCallback(
(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]
newState.pop()
return newState
@ -56,6 +87,7 @@ export const useBuyMatchPopup = () => {
const closePopup = () => {
setMatch(null)
setSteps([])
setError('')
resetSubscriptions()
}
@ -68,7 +100,7 @@ export const useBuyMatchPopup = () => {
history.push(matchLink)
}
const handleSuccessfulSubscription = () => {
const onSuccessfulSubscription = () => {
closePopup()
if (!match) return
if (match.calc) {
@ -80,15 +112,46 @@ export const useBuyMatchPopup = () => {
}
}
const subscribeToMatch = (e: MouseEvent) => {
e.stopPropagation()
if (selectedSubscription) {
buyMatchSubscription(selectedSubscription)
.then(handleSuccessfulSubscription)
.catch(() => goTo(Steps.Error))
const goToError = () => goTo(Steps.Error)
const onConfirmationError = (stripeError?: StripeError) => {
setError(stripeError?.message || '')
goToError()
}
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(() => {
if (match) {
fetchSubscriptions(match.sportType, match.id)
@ -98,6 +161,7 @@ export const useBuyMatchPopup = () => {
return {
close: closePopup,
currentStep: last(steps),
error,
goBack,
goTo,
match,

@ -1,6 +1,7 @@
import { useCallback } from 'react'
import isEmpty from 'lodash/isEmpty'
import flatMap from 'lodash/flatMap'
import map from 'lodash/map'
import { useLexicsStore } from 'features/LexicsStore'
@ -10,7 +11,10 @@ import type { MatchSubscriptions } from '../../types'
export const useLexicsFetcher = () => {
const { addLexicsConfig } = useLexicsStore()
const fetchLexics = useCallback((subscriptions: MatchSubscriptions) => {
const lexics = map(subscriptions, ({ lexic }) => lexic)
const lexics = flatMap(
subscriptions,
(periodSubscriptions) => map(periodSubscriptions, ({ lexic }) => lexic),
)
if (!isEmpty(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 {
useMemo,
useState,
useCallback,
} from 'react'
import filter from 'lodash/filter'
import { getSubscriptions } from 'requests'
import { SportTypes } from 'config'
@ -15,28 +12,28 @@ import { SubscriptionType } from '../../types'
import { transformSubsciptions } from '../helpers'
import { useLexicsFetcher } from './useLexicsFetcher'
const defaultSubscriptions: MatchSubscriptions = {
[SubscriptionType.Month]: [],
[SubscriptionType.Year]: [],
}
export const useSubscriptions = () => {
const { fetchLexics } = useLexicsFetcher()
const [selectedPeriod, setSelectedPeriod] = useState(SubscriptionType.Month)
const [subscriptionsList, setSubscriptionsList] = useState<MatchSubscriptions>([])
const [matchSubscriptions, setMatchSubscriptionsList] = useState(defaultSubscriptions)
const [selectedSubscription, setSelectedSubscription] = useState<MatchSubscription | null>(null)
const fetchSubscriptions = useCallback((sport: SportTypes, id: number) => {
getSubscriptions(sport, id)
.then(transformSubsciptions)
.then(fetchLexics)
.then(setSubscriptionsList)
.then(setMatchSubscriptionsList)
}, [fetchLexics])
const subscriptions = useMemo(
() => filter(subscriptionsList, { type: selectedPeriod }),
[selectedPeriod, subscriptionsList],
)
const resetSubscriptions = useCallback(() => {
setSelectedPeriod(SubscriptionType.Month)
setSelectedSubscription(null)
setSubscriptionsList([])
setMatchSubscriptionsList(defaultSubscriptions)
}, [])
return {
@ -46,6 +43,6 @@ export const useSubscriptions = () => {
resetSubscriptions,
selectedPeriod,
selectedSubscription,
subscriptions,
subscriptions: matchSubscriptions[selectedPeriod],
}
}

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

@ -1,3 +1,5 @@
import type { SubscriptionResponse } from 'requests'
export enum Steps {
CardSelection = 'CardSelection',
Error = 'Error',
@ -10,12 +12,9 @@ export enum SubscriptionType {
Year = 'year',
}
export type MatchSubscription = {
export type MatchSubscription = SubscriptionResponse & {
currency: string,
id: number,
lexic: number,
price: number,
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',
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 type { Languages } from './config'
import { langsList } from './config'
import {
Wrapper,
@ -22,7 +23,7 @@ export const LanguageSelect = () => {
open,
} = useToggle()
const handleLangChange = (locale: string) => () => {
const handleLangChange = (locale: Languages) => () => {
changeLang(locale)
close()
}

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

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

@ -1,3 +1,5 @@
import { Fragment } from 'react'
import { currencySymbols } from 'config'
import { T9n } from 'features/T9n'
@ -12,19 +14,26 @@ type Props = {
amount: number,
className?: string,
currency?: string,
perPeriod?: string,
perPeriod?: string | null,
}
export const Price = ({
amount,
className,
currency = currencySymbols.RUB,
perPeriod = 'month',
perPeriod,
}: Props) => (
<PriceWrapper className={className}>
<PriceAmount>{amount}</PriceAmount>
<PriceDetails>
{currency} / <T9n t={perPeriod} />
{currency}
{
perPeriod && (
<Fragment>
/ <T9n t={perPeriod} />
</Fragment>
)
}
</PriceDetails>
</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 { CardNumberWrapper } from './styled'
import { CardNumberWrapper, CustomRadio } from './styled'
type Props = {
card: Card,
checked?: boolean,
number: string,
type: 'visa' | 'mastercard',
onChange: (cardId: string) => void,
onDelete: (cardId: string) => void,
}
export const BankCard = ({
card,
checked,
number,
type,
onChange,
onDelete,
}: Props) => (
<CardNumberWrapper>
<Radio
label={`${type} ${number}`}
<CustomRadio
label={`${card.brand} •••• ${card.last4}`}
checked={checked}
onClick={() => {}}
onChange={() => onChange(card.id)}
/>
<InlineButton>
<InlineButton onClick={() => onDelete(card.id)}>
Удалить
</InlineButton>
</CardNumberWrapper>

@ -4,6 +4,7 @@ import { devices } from 'config/devices'
import { Label } from 'features/Common/Radio/styled'
import { RadioSvg } from 'features/Common/Radio/Icon'
import { Radio } from 'features/Common'
import { InlineButton } from '../../styled'
@ -18,17 +19,30 @@ export const CardNumberWrapper = styled.div`
margin-bottom: 10px;
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} {
height: 100%;
width: 100%;
font-size: 20px;
line-height: 24px;
margin-left: 25px;
text-transform: capitalize;
}
${RadioSvg} {
margin-right: 24px;
}
@media ${devices.tablet} {
max-width: 415px;
@ -45,8 +59,4 @@ export const CardNumberWrapper = styled.div`
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 { useCardsStore } from 'features/CardsStore'
import { BankCard } from '../BankCard'
import { OutlineButton, Icon } from '../../styled'
import { FormWrapper, Wrapper } from './styled'
export const PageBankCards = () => {
const { isOpen, toggle } = useToggle()
const {
cards,
defaultCard,
fetchCards,
onDeleteCard,
onSetDefaultCard,
} = useCardsStore()
useEffect(() => {
if (isNull(cards)) {
fetchCards()
}
}, [fetchCards, cards])
return (
<Wrapper>
<BankCard type='mastercard' number='•••• 4432' checked />
<BankCard type='mastercard' number='•••• 4432' />
<BankCard type='mastercard' number='•••• 4432' />
{map(cards, (card) => (
<BankCard
key={card.id}
card={card}
checked={card.id === defaultCard?.id}
onChange={onSetDefaultCard}
onDelete={onDeleteCard}
/>
))}
<FormWrapper>
{isOpen
? <AddCardForm />
: (
<OutlineButton
type='button'
onClick={toggle}
>
<Icon src='plusIcon' />
<T9n t='add_card' />
</OutlineButton>
)}
<AddCardForm />
</FormWrapper>
</Wrapper>
)

@ -1,7 +1,7 @@
import { PAGES } from 'config'
import { removeToken } from 'helpers'
export const logoutIfUnauthorized = (response: Response) => {
export const logoutIfUnauthorized = async (response: Response) => {
if (response.status === 401 || response.status === 403) {
removeToken()
window.location.pathname = PAGES.login
@ -10,5 +10,7 @@ export const logoutIfUnauthorized = (response: Response) => {
const error = new Error(response.statusText)
// eslint-disable-next-line no-console
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,
SportTypes,
} from 'config'
import { SubscriptionType } from 'features/BuyMatchPopup/types'
import { callApi } from 'helpers'
const proc = PROCEDURES.get_match_subscriptions
type Subscription = {
export type SubscriptionResponse = {
currency_id: number,
currency_iso: keyof typeof currencySymbols,
id: number,
lexic: number,
price_month: number | null,
price_year: number | null,
price: number,
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 (
sport: SportTypes,

@ -24,4 +24,4 @@ export * from './getSportActions'
export * from './getMatchPlaylists'
export * from './getPlayerPlaylists'
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