feat: integration with api server

This commit is contained in:
Aidar Shaikhutdin @makeweb.space 2023-05-09 20:30:54 +06:00
parent ffeb365e8d
commit 70aac95661
30 changed files with 485 additions and 201 deletions

View File

@ -3,12 +3,12 @@
"short_name": "",
"icons": [
{
"src": "/assets/android-chrome-192x192.png",
"src": "/src/assets/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/android-chrome-512x512.png",
"src": "/src/assets/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}

View File

@ -1,3 +1,26 @@
import { createContext } from 'react'
import { AuthToken, User } from '../types'
export const AuthContext = createContext<unknown>({})
export interface AuthContextValue {
user: User | null
token: AuthToken
logout: () => void
signUp: (payload: SignUpPayload) => Promise<void>
addBirthday: (birthday: string) => Promise<void>
}
export interface SignUpPayload {
email: string
timezone: string
locale: string
}
const initialContext: AuthContextValue = {
token: '',
user: null,
logout: () => undefined,
signUp: () => Promise.resolve(),
addBirthday: () => Promise.resolve(),
}
export const AuthContext = createContext<AuthContextValue>(initialContext)

View File

@ -1,7 +1,58 @@
import { AuthContext } from './AuthContext'
import { useDispatch, useSelector } from 'react-redux'
import { AuthContext, SignUpPayload } from './AuthContext'
import { RootState, actions } from '../store'
import routes from '../routes'
export function AuthProvider({ children }: React.PropsWithChildren<unknown>): JSX.Element {
const auth = {}
const dispatch = useDispatch()
const token = useSelector((state: RootState) => state.token)
const user = useSelector((state: RootState) => state.user)
const signUp = async ({ email, timezone, locale }: SignUpPayload): Promise<void> => {
const response = await fetch(routes.server.token(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ auth: { email, timezone, locale } })
})
if (response.ok) {
const { auth: { token, user } } = await response.json()
dispatch(actions.token.update(token))
dispatch(actions.user.update(user))
} else {
const { errors } = await response.json()
if (Array.isArray(errors)) {
const [error] = errors
throw new Error(error.title)
} else {
const { base: [error] } = errors
throw new Error(error)
}
}
}
const addBirthday = async (birthday: string): Promise<void> => {
const response = await fetch(routes.server.user(), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ user: { profile_attributes: { birthday } } })
})
if (response.ok) {
const { user } = await response.json()
dispatch(actions.user.update(user))
} else {
const { errors } = await response.json()
if (Array.isArray(errors)) {
const [error] = errors
throw new Error(error.title)
} else {
const { base: [error] } = errors
throw new Error(error)
}
}
}
const logout = () => dispatch(actions.reset())
const auth = { signUp, logout, addBirthday, token, user: user.id ? user : null }
return (
<AuthContext.Provider value={auth}>
{children}

View File

@ -1,4 +1,5 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { Routes, Route, Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../../auth'
import BirthdayPage from '../BirthdayPage'
import BirthtimePage from '../BirthtimePage'
import CreateProfilePage from '../CreateProfilePage'
@ -22,7 +23,9 @@ function App(): JSX.Element {
<Route path={routes.client.birthtime()} element={<BirthtimePage />} />
<Route path={routes.client.createProfile()} element={<CreateProfilePage />} />
<Route path={routes.client.emailEnter()} element={<EmailEnterPage />} />
<Route path={routes.client.subscription()} element={<SubscriptionPage />} />
<Route path={routes.client.subscription()} element={<PrivateOutlet />}>
<Route path='' element={<SubscriptionPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
@ -30,4 +33,9 @@ function App(): JSX.Element {
)
}
function PrivateOutlet(): JSX.Element {
const { user } = useAuth()
return user ? <Outlet /> : <Navigate to={routes.client.root()} />
}
export default App

View File

@ -15,7 +15,7 @@ function BirthdayPage(): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const navigate = useNavigate()
const birthdate = useSelector((state: RootState) => state.birthdate)
const birthdate = useSelector((state: RootState) => state.form.birthdate)
const [isDisabled, setIsDisabled] = useState(true)
const links = [
{ text: 'EULA', href: 'https://aura.wit.life/terms' },
@ -24,7 +24,7 @@ function BirthdayPage(): JSX.Element {
]
const handleNext = () => navigate(routes.client.birthtime())
const handleValid = (birthdate: string) => {
dispatch(actions.birthdate.update(birthdate))
dispatch(actions.form.addDate(birthdate))
setIsDisabled(birthdate === '')
}
@ -38,7 +38,9 @@ function BirthdayPage(): JSX.Element {
onValid={handleValid}
onInvalid={() => setIsDisabled(true)}
/>
<MainButton label={t('next')} onClick={handleNext} disabled={isDisabled}/>
<MainButton onClick={handleNext} disabled={isDisabled}>
{t('next')}
</MainButton>
<footer className='footer'>
<Policy links={links}>{t('privacyText')}</Policy>
<Purposes />

View File

@ -12,15 +12,15 @@ function BirthtimePage(): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const navigate = useNavigate();
const birthtime = useSelector((state: RootState) => state.birthtime)
const birthtime = useSelector((state: RootState) => state.form.birthtime)
const handleNext = () => navigate(routes.client.createProfile())
const handleChange = (value: string) => dispatch(actions.birthtime.update(value))
const handleChange = (value: string) => dispatch(actions.form.addTime(value))
return (
<section className='page'>
<Title variant="h2" className="mt-24">{t('bornTimeQuestion')}</Title>
<p className="description">{t('nasaDataUsing')}</p>
<TimePicker value={birthtime} onChange={handleChange}/>
<MainButton label={t('next')} onClick={handleNext}/>
<MainButton onClick={handleNext}>{t('next')}</MainButton>
</section>
)
}

View File

@ -1,3 +1,5 @@
import Loader from "../Loader"
type ProcessItemProps = {
top: number
label: string
@ -12,7 +14,7 @@ function ProcessItem({ top, label, isDone }: ProcessItemProps): JSX.Element {
{
isDone
? <div className='process-item__icon'>&#9989;</div>
: <div className="process-item__loader"><span></span></div>
: <Loader />
}
</div>
<div className='process-item__label'>{label}</div>

View File

@ -21,71 +21,8 @@
.process-item__pick {
position: relative;
margin-right: 15px;
width: 32px;
height: 32px;
clear: both;
}
.process-item__icon {
font-size: 32px;
}
.process-item__loader {
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
width: 32px;
height: 32px;
animation: loader-1-1 4.8s linear infinite;
}
@keyframes loader-1-1 {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.process-item__loader span {
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
height: 32px;
width: 32px;
clip: rect(0, 32px, 32px, 16px);
animation: loader-1-2 1.2s linear infinite;
}
@keyframes loader-1-2 {
0% { transform: rotate(0deg); }
100% { transform: rotate(220deg); }
}
.process-item__loader span::after {
position: absolute;
box-sizing: border-box;
content: "";
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
height: 32px;
width: 32px;
clip: rect(0, 32px, 32px, 16px);
border: 3px solid #000;
border-radius: 50%;
animation: loader-1-3 1.2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite;
}
@keyframes loader-1-3 {
0% { transform: rotate(-140deg); }
50% { transform: rotate(-160deg); }
100% { transform: rotate(140deg); }
}

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormField } from '../../types'
import DateInput from './DateInput'
import ErrorText from './ErrorText'
import ErrorText from '../ErrorText'
import { stringify, getCurrentYear } from './utils'
export function DatePicker(props: FormField<string>): JSX.Element {
@ -63,10 +63,7 @@ export function DatePicker(props: FormField<string>): JSX.Element {
onChange={(day: string) => setDay(day)}
/>
</div>
<ErrorText
isShown={hasError}
message={t('invalidDate')}
/>
<ErrorText isShown={hasError} message={t('invalidDate')} />
</form>
)
}

View File

@ -1,15 +0,0 @@
type ErrorTextProps = {
isShown: boolean
message: string
}
function ErrorText({ message, isShown }: ErrorTextProps): JSX.Element {
const className = isShown ? 'date-picker__error--shown' : ''
return (
<p className={`date-picker__error ${className}`}>
{message}
</p>
)
}
export default ErrorText

View File

@ -97,19 +97,3 @@
border: 2px solid #dee5f9;
padding-top: 5px;
}
.date-picker__error {
color: #ff5c5d;
font-size: 12px;
left: 12px;
line-height: 16px;
margin-left: 12px;
position: absolute;
transform: translateY(-32px);
transition: all .5s;
}
.date-picker__error--shown {
position: static;
transform: translateY(6px);
}

View File

@ -3,27 +3,51 @@ import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { actions, RootState } from '../../store'
import { getClientLocale, getClientTimezone } from '../../locales'
import Title from '../Title'
import Policy from '../Policy'
import EmailInput from './EmailInput'
import MainButton from '../MainButton'
import Loader, { LoaderColor } from '../Loader'
import ErrorText from '../ErrorText'
import routes from '../../routes'
import { useAuth } from '../../auth'
function EmailEnterPage(): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const navigate = useNavigate()
const email = useSelector((state: RootState) => state.email)
const { user, signUp, addBirthday } = useAuth()
const { email, birthdate, birthtime } = useSelector((state: RootState) => state.form)
const [isDisabled, setIsDisabled] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const links = [
{ text: 'EULA', href: 'https://aura.wit.life/terms' },
{ text: 'Privacy Policy', href: 'https://aura.wit.life/privacy' },
]
const handleValidEmail = (email: string) => {
dispatch(actions.email.update(email))
dispatch(actions.form.addEmail(email))
setIsDisabled(false)
}
const handleClick = () => navigate(routes.client.subscription())
const handleClick = () => {
if (user) {
navigate(routes.client.subscription())
return
}
setError(null)
setIsLoading(true)
signUp({
email,
timezone: getClientTimezone(),
locale: getClientLocale(),
})
.then(() => addBirthday(`${birthdate} ${birthtime}`))
.then(() => navigate(routes.client.subscription()))
.catch((error: Error) => setError(error))
.finally(() => setIsLoading(false))
}
return (
<section className='page'>
@ -37,7 +61,10 @@ function EmailEnterPage(): JSX.Element {
/>
<p>{t('weDontShare')}</p>
<Policy links={links} sizing='medium'>{t('continueAgree')}</Policy>
<MainButton label={t('continue')} onClick={handleClick} disabled={isDisabled} />
<MainButton onClick={handleClick} disabled={isDisabled}>
{isLoading ? <Loader color={LoaderColor.White} /> : t('continue')}
</MainButton>
<ErrorText size='medium' isShown={Boolean(error)} message={error?.message} />
</section>
)
}

View File

@ -0,0 +1,18 @@
import './styles.css'
type ErrorTextProps = {
isShown: boolean
message: string | null | undefined
size?: 'small' | 'medium' | 'large'
}
function ErrorText({ message, isShown, size }: ErrorTextProps): JSX.Element {
const classNames = ['error-text', isShown ? 'error--shown' : '', size ? `error--${size}` : '']
return (
<p className={classNames.filter(Boolean).join(' ')}>
{message}
</p>
)
}
export default ErrorText

View File

@ -0,0 +1,27 @@
.error-text {
color: #ff5c5d;
font-size: 12px;
left: 12px;
line-height: 16px;
margin-left: 12px;
position: absolute;
transform: translateY(-32px);
transition: all .5s;
}
.error--shown {
position: static;
transform: translateY(6px);
}
.error--small {
font-size: 12px;
}
.error--medium {
font-size: 14px;
}
.error--large {
font-size: 16px;
}

View File

@ -0,0 +1,21 @@
import './styles.css'
export enum LoaderColor {
White = 'white',
Black = 'black',
}
type LoaderProps = {
color?: LoaderColor
}
function Loader({ color = LoaderColor.Black }: LoaderProps): JSX.Element {
const colorClass = color === LoaderColor.White ? 'loader__white' : 'loader__black'
return (
<div className='loader-container'>
<div className={`loader ${colorClass}`}><span></span></div>
</div>
)
}
export default Loader

View File

@ -0,0 +1,74 @@
.loader {
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
width: 32px;
height: 32px;
animation: loader-1-1 4.8s linear infinite;
}
.loader-container {
position: relative;
width: 32px;
height: 32px;
clear: both;
}
.loader.loader__black span::after {
border-color: #000;
}
.loader.loader__white span::after {
border-color: #fff;
}
@keyframes loader-1-1 {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loader span {
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
height: 32px;
width: 32px;
clip: rect(0, 32px, 32px, 16px);
animation: loader-1-2 1.2s linear infinite;
}
@keyframes loader-1-2 {
0% { transform: rotate(0deg); }
100% { transform: rotate(220deg); }
}
.loader span::after {
position: absolute;
box-sizing: border-box;
content: "";
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
height: 32px;
width: 32px;
clip: rect(0, 32px, 32px, 16px);
border: 3px solid transparent;
border-radius: 50%;
animation: loader-1-3 1.2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite;
}
@keyframes loader-1-3 {
0% { transform: rotate(-140deg); }
50% { transform: rotate(-160deg); }
100% { transform: rotate(140deg); }
}

View File

@ -1,13 +1,11 @@
import './styles.css'
type ButtonProps = {
label: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
function MainButton({ className, label, ...props}: ButtonProps): JSX.Element {
function MainButton({ className, children, ...props}: ButtonProps): JSX.Element {
const combinedClassNames = ['main-btn', className].filter(Boolean).join(' ')
return <button className={combinedClassNames} {...props}>{label}</button>
return <button className={combinedClassNames} {...props}>{children}</button>
}
export default MainButton

View File

@ -15,6 +15,8 @@
min-width: 250px;
padding: 12px 16px;
width: 100%;
position: relative;
z-index: 3;
}
.main-btn:disabled {

View File

@ -1,4 +1,6 @@
import { useSelector } from 'react-redux'
import { useTranslation } from 'react-i18next'
import { RootState } from '../../store'
import MainButton from '../MainButton'
import Policy from '../Policy'
import Countdown from '../Countdown'
@ -8,7 +10,7 @@ import CallToAction from '../CallToAction'
function SubscriptionPage(): JSX.Element {
const { t } = useTranslation()
const userEmail = 'some@email.com'
const email = useSelector((state: RootState) => state.form.email)
const links = [
{ text: 'Subscription policy', href: 'https://aura.wit.life/' },
]
@ -24,12 +26,12 @@ function SubscriptionPage(): JSX.Element {
const handleClick = () => console.log('What we will do?')
return (
<>
<UserHeader email={userEmail} />
<UserHeader email={email} />
<section className='page'>
<CallToAction />
<Countdown start={10}/>
<Payment items={paymentItems} currency={currency} locale={locale}/>
<MainButton label={t('getAccess')} onClick={handleClick} />
<MainButton onClick={handleClick}>{t('getAccess')}</MainButton>
<Policy links={links}>{t('subscriptionPolicy')}</Policy>
</section>
</>

View File

@ -1,3 +1,6 @@
import en from './en.ts'
export const getClientLocale = () => navigator.language.split('-')[0]
export const getClientTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone
export default { en }

View File

@ -14,7 +14,9 @@ const routes = {
server: {
locales: () => [apiHost, prefix, 'locales.json'].join('/'),
translations: () => [apiHost, prefix, 't.json'].join('/'),
userRegistration: () => [apiHost, prefix, 'user', 'registration.json'].join('/'),
elements: () => [apiHost, prefix, 'elements.json'].join('/'),
user: () => [apiHost, prefix, 'user.json'].join('/'),
token: () => [apiHost, prefix, 'auth', 'token.json'].join('/'),
},
}

View File

@ -1,20 +0,0 @@
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
const birthdateSlice = createSlice({
name: 'birthdate',
initialState: '',
reducers: {
update(state, action: PayloadAction<string>) {
state = action.payload
return state
},
clear(state) {
state = ''
return state
},
},
})
export const { actions } = birthdateSlice
export default birthdateSlice.reducer

View File

@ -1,20 +0,0 @@
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
const birthtimeSlice = createSlice({
name: 'birthtime',
initialState: '12:00',
reducers: {
update(state, action: PayloadAction<string>) {
state = action.payload
return state
},
clear(state) {
state = ''
return state
},
},
})
export const { actions } = birthtimeSlice
export default birthtimeSlice.reducer

View File

@ -1,20 +0,0 @@
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
const emailSlice = createSlice({
name: 'email',
initialState: '',
reducers: {
update(state, action: PayloadAction<string>) {
state = action.payload
return state
},
clear(state) {
state = ''
return state
},
},
})
export const { actions } = emailSlice
export default emailSlice.reducer

32
src/store/form.ts Normal file
View File

@ -0,0 +1,32 @@
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import { SignupForm } from '../types'
const initialState: SignupForm = {
birthdate: '',
birthtime: '12:00',
email: '',
}
const formSlice = createSlice({
name: 'form',
initialState,
reducers: {
addDate(state, action: PayloadAction<string>) {
state.birthdate = action.payload
return state
},
addTime(state, action: PayloadAction<string>) {
state.birthtime = action.payload
return state
},
addEmail(state, action: PayloadAction<string>) {
state.email = action.payload
return state
},
},
extraReducers: (builder) => builder.addCase('reset', () => initialState),
})
export const { actions } = formSlice
export default formSlice.reducer

View File

@ -1,22 +1,23 @@
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import birthdate, { actions as birthdateActions } from './birthdate'
import birthtime, { actions as birthtimeActions } from './birthtime'
import email, { actions as emailActions } from './email'
import { combineReducers, configureStore, createAction } from '@reduxjs/toolkit'
import token, { actions as tokenActions } from './token'
import user, { actions as userActions } from './user'
import form, { actions as formActions } from './form'
import { loadStore, backupStore } from './storageHelper'
const preloadedState = loadStore()
export const reducer = combineReducers({
email,
birthdate,
birthtime,
})
export const reducer = combineReducers({ token, user, form })
export const actions = {
email: emailActions,
birthdate: birthdateActions,
birthtime: birthtimeActions,
token: tokenActions,
user: userActions,
form: formActions,
reset: createAction('reset'),
}
export type RootState = ReturnType<typeof reducer>
export const store = configureStore({ reducer, preloadedState })
export const store = configureStore({
reducer,
preloadedState,
devTools: import.meta.env.DEV,
})
export type AppDispatch = typeof store.dispatch
export type StoreType = typeof store
export const unsubscribe = backupStore(store)

View File

@ -6,7 +6,7 @@ export const backupStore = (store: ToolkitStore) => {
const saveState = () => {
const state = store.getState()
try {
const serializedState = JSON.stringify(state)
const serializedState = window.btoa(JSON.stringify(state))
localStorage.setItem(storageKey, serializedState)
} catch (err) {
// nothing to do
@ -31,7 +31,7 @@ export const loadStore = () => {
if (serializedState === null) {
return undefined
}
return JSON.parse(serializedState)
return JSON.parse(window.atob(serializedState))
} catch (err) {
return undefined
}

20
src/store/token.ts Normal file
View File

@ -0,0 +1,20 @@
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import type { AuthToken } from '../types'
const initialState: AuthToken = ''
const authTokenSlice = createSlice({
name: 'token',
initialState,
reducers: {
update(state, action: PayloadAction<string>) {
state = action.payload
return state
},
},
extraReducers: (builder) => builder.addCase('reset', () => initialState)
})
export const { actions } = authTokenSlice
export default authTokenSlice.reducer

45
src/store/user.ts Normal file
View File

@ -0,0 +1,45 @@
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import type { User } from '../types'
import { getClientLocale, getClientTimezone } from '../locales'
const initialState: User = {
id: undefined,
username: null,
email: '',
locale: getClientLocale(),
state: '',
timezone: getClientTimezone(),
new_registration: false,
stat: {
last_online_at: null,
prev_online_at: null
},
profile: {
full_name: null,
gender: null,
birthday: null,
birthplace: null,
age: null,
sign: null,
userpic: null,
userpic_mime_type: undefined,
relationship_status: '',
human_relationship_status: ''
},
daily_push_subs: []
}
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
update(state, action: PayloadAction<Partial<User>>) {
return { ...state, ...action.payload }
},
},
extraReducers: (builder) => builder.addCase('reset', () => initialState),
})
export const { actions } = userSlice
export default userSlice.reducer

View File

@ -6,3 +6,86 @@ export interface FormField<T> {
onValid: (value: T) => void
onInvalid: () => void
}
export interface SignupForm {
email: string
birthdate: string
birthtime: string
}
export type AuthToken = string
export interface User {
id: string | null | undefined
username: string | null
email: string
locale: string
state: string
timezone: string
new_registration: boolean
stat: {
last_online_at: string | null
prev_online_at: string | null
}
profile: UserProfile
daily_push_subs: Subscription[]
}
export interface UserProfile {
full_name: string | null
gender: string | null
birthday: string | null
birthplace: UserBirhplace | null
age: UserAge | null
sign: UserSign | null
userpic: UserPic | null
userpic_mime_type: string | undefined
relationship_status: string
human_relationship_status: string
}
export interface UserAge {
years: number
days: number
}
export interface UserSign {
house: number
ruler: string
dates: {
start: {
month: number
day: number
}
end: {
month: number
day: number
}
}
sign: string
char: string
polarity: string
modality: string
triplicity: string
}
export interface UserPic {
th: string
th2x: string
lg: string
}
export interface UserBirhplace {
id: string
address: string
coords: string
}
export interface Subscription {
id: string
daily_push_id: string
time: string
updated_at: string
created_at: string
last_sent_at: string | null
}