feat: add user status state

This commit is contained in:
Aidar Shaikhutdin @makeweb.space 2023-06-19 11:43:09 +03:00
parent 2bc71feb3e
commit 9255c79dc6
12 changed files with 123 additions and 60 deletions

View File

@ -21,29 +21,31 @@ export interface AppleReceiptPayload extends AuthPayload {
export type Payload = ChargebeeReceiptPayload | AppleReceiptPayload export type Payload = ChargebeeReceiptPayload | AppleReceiptPayload
export interface Response { export interface Response {
subscription_receipt: { subscription_receipt: SubscriptionReceipt
id: string }
user_id: number
status: number export interface SubscriptionReceipt {
expires_at: null | string id: string
requested_at: string user_id: number
created_at: string status: number
data: { expires_at: null | string
input: { requested_at: string
subscription_items: [ created_at: string
{ data: {
item_price_id: string input: {
} subscription_items: [
], {
payment_intent: { item_price_id: string
gw_token: string
gateway_account_id: string
} }
}, ],
app_bundle_id: string payment_intent: {
autorenewable: boolean gw_token: string
error: string gateway_account_id: string
} }
},
app_bundle_id: string
autorenewable: boolean
error: string
} }
} }

View File

@ -1,6 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom' import { Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '../../auth' import { useAuth } from '../../auth'
import { useSelector } from 'react-redux'
import { selectors } from '../../store'
import routes, { hasNavigation } from '../../routes'
import BirthdayPage from '../BirthdayPage' import BirthdayPage from '../BirthdayPage'
import BirthtimePage from '../BirthtimePage' import BirthtimePage from '../BirthtimePage'
import CreateProfilePage from '../CreateProfilePage' import CreateProfilePage from '../CreateProfilePage'
@ -13,7 +16,6 @@ import NotFoundPage from '../NotFoundPage'
import Header from '../Header' import Header from '../Header'
import Navbar from '../Navbar' import Navbar from '../Navbar'
import Footer from '../Footer' import Footer from '../Footer'
import routes, { hasNavigation } from '../../routes'
import './styles.css' import './styles.css'
function App(): JSX.Element { function App(): JSX.Element {
@ -62,9 +64,14 @@ function SkipStep(): JSX.Element {
} }
function MainPage(): JSX.Element { function MainPage(): JSX.Element {
const { user } = useAuth() const status = useSelector(selectors.selectStatus)
const page = user ? routes.client.wallpaper() : routes.client.birthday() const pageMapper = {
return <Navigate to={page} replace={true} /> 'lead': routes.client.birthday(),
'registred': routes.client.subscription(),
'subscribed': routes.client.wallpaper(),
'unsubscribed': routes.client.subscription(),
}
return <Navigate to={pageMapper[status]} replace={true} />
} }
export default App export default App

View File

@ -49,6 +49,7 @@ function EmailEnterPage(): JSX.Element {
}) })
.then(([{ user }, { item_prices }]) => { .then(([{ user }, { item_prices }]) => {
dispatch(actions.user.update(user)) dispatch(actions.user.update(user))
dispatch(actions.status.update('registred'))
dispatch(actions.subscriptionPlan.setAll(item_prices)) dispatch(actions.subscriptionPlan.setAll(item_prices))
}) })
.then(() => navigate(routes.client.subscription())) .then(() => navigate(routes.client.subscription()))

View File

@ -1,8 +1,10 @@
import { useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import { selectors } from '../../store' import { selectors } from '../../store'
import { usePayment } from '../../payment' import { usePayment } from '../../payment'
import { actions } from '../../store'
import { import {
ApplePayBanner, ApplePayBanner,
ApplePayButton, ApplePayButton,
@ -15,15 +17,23 @@ import UserHeader from '../UserHeader'
import Title from '../Title' import Title from '../Title'
import Loader from '../Loader' import Loader from '../Loader'
import secure from './secure.png' import secure from './secure.png'
import routes from '../../routes'
import './styles.css' import './styles.css'
function PaymentPage(): JSX.Element { function PaymentPage(): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const { applePay } = usePayment() const { applePay } = usePayment()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const dispatch = useDispatch()
const navigate = useNavigate()
const isLoading = applePay === null const isLoading = applePay === null
const isApplePayAvailable = import.meta.env.PROD && applePay?.canMakePayments() const isApplePayAvailable = import.meta.env.PROD && applePay?.canMakePayments()
const email = useSelector(selectors.selectEmail) const email = useSelector(selectors.selectEmail)
const onSuccess = useCallback(() => {
dispatch(actions.status.update('subscribed'))
navigate(routes.client.wallpaper())
}, [dispatch, navigate])
const onError = useCallback((error: Error) => console.error(error), [])
return ( return (
<> <>
@ -36,13 +46,17 @@ function PaymentPage(): JSX.Element {
<img src={secure} alt='100% Secure' /> <img src={secure} alt='100% Secure' />
</div> </div>
<Title variant='h1' className='mb-45'>{t('choose_payment')}</Title> <Title variant='h1' className='mb-45'>{t('choose_payment')}</Title>
{ isApplePayAvailable ? <ApplePayButton /> : <GooglePayButton /> } { isApplePayAvailable
?
<ApplePayButton onSuccess={onSuccess} onError={onError} />
:
<GooglePayButton onSuccess={onSuccess} onError={onError} /> }
<div className='payment-divider'>{t('or').toUpperCase()}</div> <div className='payment-divider'>{t('or').toUpperCase()}</div>
<CardButton onClick={() => setOpen(true)} /> <CardButton onClick={() => setOpen(true)} />
<p className='payment-warining'> <p className='payment-warining'>
{t('will_be_charged', { strongText: <strong>{t('trial_price')}</strong> })} {t('will_be_charged', { strongText: <strong>{t('trial_price')}</strong> })}
</p> </p>
<CardModal open={open} onClose={() => setOpen(false)} /> <CardModal open={open} onClose={() => setOpen(false)} onSuccess={onSuccess} onError={onError} />
</> </>
)} )}
</section> </section>

View File

@ -1,20 +1,22 @@
import { useCallback, useEffect, useId } from 'react' import { useCallback, useEffect, useId } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { PaymentIntent } from '@chargebee/chargebee-js-types' import { PaymentIntent } from '@chargebee/chargebee-js-types'
import { useApi, useApiCall, extractErrorMessage } from '../../../../api' import { useApi, useApiCall, extractErrorMessage, SubscriptionReceipts } from '../../../../api'
import { usePayment, ApplePayButtonOptions } from '../../../../payment' import { usePayment, ApplePayButtonOptions } from '../../../../payment'
import { useAuth } from '../../../../auth' import { useAuth } from '../../../../auth'
import Loader from '../../../Loader' import Loader from '../../../Loader'
import ErrorText from '../../../ErrorText' import ErrorText from '../../../ErrorText'
import routes from '../../../../routes'
const currencyCode = 'USD' const currencyCode = 'USD'
const paymentMethod = 'apple_pay' const paymentMethod = 'apple_pay'
export function ApplePayButton(): JSX.Element { interface ApplePayButtonProps {
onSuccess: (receipt: SubscriptionReceipts.SubscriptionReceipt) => void
onError: (error: Error) => void
}
export function ApplePayButton({ onSuccess, onError }: ApplePayButtonProps): JSX.Element {
const api = useApi() const api = useApi()
const navigate = useNavigate()
const buttonId = useId() const buttonId = useId()
const { i18n } = useTranslation() const { i18n } = useTranslation()
const { token } = useAuth() const { token } = useAuth()
@ -38,14 +40,13 @@ export function ApplePayButton(): JSX.Element {
applePay?.mountPaymentButton(`#${buttonId}`, buttonOptions) applePay?.mountPaymentButton(`#${buttonId}`, buttonOptions)
.then(() => applePay?.handlePayment()) .then(() => applePay?.handlePayment())
.then((paymentIntent) => { .then((paymentIntent) => {
console.log('Success payment by ApplePay', paymentIntent)
return api.createSubscriptionReceipt({ return api.createSubscriptionReceipt({
token, receiptData: paymentIntent.id, autorenewable: true, sandbox: true, token, receiptData: paymentIntent.id, autorenewable: true, sandbox: true,
}) })
}) })
.then(() => navigate(routes.client.wallpaper())) .then(({ subscription_receipt }: SubscriptionReceipts.Response) => onSuccess(subscription_receipt))
.catch(console.error) .catch((error: Error) => onError(error))
}, [data, applePay, buttonId, navigate, i18n.language, api, token]) }, [data, applePay, buttonId, i18n.language, api, token, onSuccess, onError])
return isPending ? <Loader /> : ( return isPending ? <Loader /> : (
<div id={buttonId} style={{ height: 60 }}>{ <div id={buttonId} style={{ height: 60 }}>{

View File

@ -1,12 +1,11 @@
import { useEffect, useRef, useState, ChangeEvent } from 'react' import { useEffect, useRef, useState, ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { import {
CardCVV, CardComponent, CardExpiry, CardNumber, Provider CardCVV, CardComponent, CardExpiry, CardNumber, Provider
} from '@chargebee/chargebee-js-react-wrapper' } from '@chargebee/chargebee-js-react-wrapper'
import ChargebeeComponents from '@chargebee/chargebee-js-react-wrapper/dist/components/ComponentGroup' import ChargebeeComponents from '@chargebee/chargebee-js-react-wrapper/dist/components/ComponentGroup'
import { usePayment } from '../../../../payment' import { usePayment } from '../../../../payment'
import { useApi } from '../../../../api' import { useApi, SubscriptionReceipts } from '../../../../api'
import { useAuth } from '../../../../auth' import { useAuth } from '../../../../auth'
import Modal from '../../../Modal' import Modal from '../../../Modal'
import Title from '../../../Title' import Title from '../../../Title'
@ -18,11 +17,12 @@ import amex from './amex.svg'
import diners from './diners.svg' import diners from './diners.svg'
import discover from './discover.svg' import discover from './discover.svg'
import { cardStyles } from './styles' import { cardStyles } from './styles'
import routes from '../../../../routes'
interface CardModalProps { interface CardModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
onSuccess: (receipt: SubscriptionReceipts.SubscriptionReceipt) => void
onError: (error: Error) => void
} }
interface Field { interface Field {
@ -54,9 +54,8 @@ const isReady = (fields: CompletedFields) => Object.values(fields).every((comple
const itemPriceId = 'aura-membership-2-week-USD' const itemPriceId = 'aura-membership-2-week-USD'
export function CardModal({ open, onClose }: CardModalProps): JSX.Element { export function CardModal({ open, onClose, onSuccess, onError }: CardModalProps): JSX.Element {
const api = useApi() const api = useApi()
const navigate = useNavigate()
const cardRef = useRef<ChargebeeComponents>(null) const cardRef = useRef<ChargebeeComponents>(null)
const [status, setStatus] = useState<Status>('idle') const [status, setStatus] = useState<Status>('idle')
const [fields, setFields] = useState(initCompletedFields) const [fields, setFields] = useState(initCompletedFields)
@ -80,12 +79,14 @@ export function CardModal({ open, onClose }: CardModalProps): JSX.Element {
.then((result: ChargebeeTokenizeResult) => { .then((result: ChargebeeTokenizeResult) => {
return api.createSubscriptionReceipt({ token, itemPriceId, gwToken: result.token }) return api.createSubscriptionReceipt({ token, itemPriceId, gwToken: result.token })
}) })
.then(() => { .then(({ subscription_receipt }: SubscriptionReceipts.Response) => {
setStatus('success') setStatus('success')
navigate(routes.client.wallpaper()) onSuccess(subscription_receipt)
console.log('Success payment by Card') })
.catch((error: Error) => {
setStatus('error')
onError(error)
}) })
.catch(console.error)
} }
useEffect(() => { useEffect(() => {

View File

@ -1,20 +1,22 @@
import { useCallback, useEffect, useId } from 'react' import { useCallback, useEffect, useId } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { PaymentIntent } from '@chargebee/chargebee-js-types' import { PaymentIntent } from '@chargebee/chargebee-js-types'
import { extractErrorMessage, useApi, useApiCall } from '../../../../api' import { SubscriptionReceipts, extractErrorMessage, useApi, useApiCall } from '../../../../api'
import { usePayment, GooglePayButtonOptions } from '../../../../payment' import { usePayment, GooglePayButtonOptions } from '../../../../payment'
import { useAuth } from '../../../../auth' import { useAuth } from '../../../../auth'
import Loader from '../../../Loader' import Loader from '../../../Loader'
import ErrorText from '../../../ErrorText' import ErrorText from '../../../ErrorText'
import routes from '../../../../routes'
const currencyCode = 'USD' const currencyCode = 'USD'
const paymentMethod = 'google_pay' const paymentMethod = 'google_pay'
export function GooglePayButton(): JSX.Element { interface GooglePayButtonProps {
onSuccess: (receipt: SubscriptionReceipts.SubscriptionReceipt) => void
onError: (error: Error) => void
}
export function GooglePayButton({ onSuccess, onError }: GooglePayButtonProps): JSX.Element {
const api = useApi() const api = useApi()
const navigate = useNavigate()
const buttonId = useId() const buttonId = useId()
const { i18n } = useTranslation() const { i18n } = useTranslation()
const { token } = useAuth() const { token } = useAuth()
@ -42,9 +44,9 @@ export function GooglePayButton(): JSX.Element {
console.log('Success payment by GooglePay', result) console.log('Success payment by GooglePay', result)
// TODO: implement api.createSubscriptionReceipt for GooglePay // TODO: implement api.createSubscriptionReceipt for GooglePay
}) })
.then(() => navigate(routes.client.wallpaper())) .then(() => onSuccess({} as SubscriptionReceipts.SubscriptionReceipt))
.catch(console.error) .catch((error: Error) => onError(error))
}, [data, googlePay, buttonId, navigate, i18n.language]) }, [data, googlePay, buttonId, i18n.language, onSuccess, onError])
return isPending ? <Loader /> : ( return isPending ? <Loader /> : (
<div id={buttonId} style={{ height: 60 }}>{ <div id={buttonId} style={{ height: 60 }}>{

View File

@ -33,7 +33,12 @@ const routes = {
}, },
} }
export const entrypoints = [routes.client.root(), routes.client.birthday(), routes.client.wallpaper()] export const entrypoints = [
routes.client.root(),
routes.client.birthday(),
routes.client.subscription(),
routes.client.wallpaper(),
]
export const isEntrypoint = (path: string) => entrypoints.includes(path) export const isEntrypoint = (path: string) => entrypoints.includes(path)
export const isNotEntrypoint = (path: string) => !isEntrypoint(path) export const isNotEntrypoint = (path: string) => !isEntrypoint(path)
export const withNavigationRoutes = [routes.client.wallpaper()] export const withNavigationRoutes = [routes.client.wallpaper()]

View File

@ -3,20 +3,23 @@ import token, { actions as tokenActions, selectToken } from './token'
import user, { actions as userActions, selectUser } from './user' import user, { actions as userActions, selectUser } from './user'
import form, { actions as formActions, selectors as formSelectors } from './form' import form, { actions as formActions, selectors as formSelectors } from './form'
import subscriptionPlans, { actions as subscriptionPlasActions, selectPlanById } from './subscriptionPlan' import subscriptionPlans, { actions as subscriptionPlasActions, selectPlanById } from './subscriptionPlan'
import status, { actions as userStatusActions, selectStatus } from './status'
import { loadStore, backupStore } from './storageHelper' import { loadStore, backupStore } from './storageHelper'
const preloadedState = loadStore() const preloadedState = loadStore()
export const reducer = combineReducers({ token, user, form, subscriptionPlans }) export const reducer = combineReducers({ token, user, form, status, subscriptionPlans })
export const actions = { export const actions = {
token: tokenActions, token: tokenActions,
user: userActions, user: userActions,
form: formActions, form: formActions,
status: userStatusActions,
subscriptionPlan: subscriptionPlasActions, subscriptionPlan: subscriptionPlasActions,
reset: createAction('reset'), reset: createAction('reset'),
} }
export const selectors = { export const selectors = {
selectToken, selectToken,
selectUser, selectUser,
selectStatus,
selectPlanById, selectPlanById,
...formSelectors, ...formSelectors,
} }

25
src/store/status.ts Normal file
View File

@ -0,0 +1,25 @@
import { createSlice, createSelector } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
type UserStatus = 'lead' | 'registred' | 'subscribed' | 'unsubscribed'
const initialState = 'lead' as UserStatus
const userStatusSlice = createSlice({
name: 'status',
initialState,
reducers: {
update(state, action: PayloadAction<UserStatus>) {
state = action.payload
return state
},
},
extraReducers: (builder) => builder.addCase('reset', () => initialState)
})
export const { actions } = userStatusSlice
export const selectStatus = createSelector(
(state: { status: UserStatus }) => state.status,
(status) => status
)
export default userStatusSlice.reducer

View File

@ -1,4 +1,6 @@
import { createSlice, createEntityAdapter, createSelector, EntityState } from '@reduxjs/toolkit' import {
createSlice, createEntityAdapter, createSelector, EntityState
} from '@reduxjs/toolkit'
import { SubscriptionItems } from '../api' import { SubscriptionItems } from '../api'
type SubscriptionPlan = SubscriptionItems.ItemPrice type SubscriptionPlan = SubscriptionItems.ItemPrice

View File

@ -8,7 +8,7 @@ const authTokenSlice = createSlice({
name: 'token', name: 'token',
initialState, initialState,
reducers: { reducers: {
update(state, action: PayloadAction<string>) { update(state, action: PayloadAction<AuthToken>) {
state = action.payload state = action.payload
return state return state
}, },