feat: integration with api server
This commit is contained in:
parent
ffeb365e8d
commit
70aac95661
@ -3,12 +3,12 @@
|
|||||||
"short_name": "",
|
"short_name": "",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/assets/android-chrome-192x192.png",
|
"src": "/src/assets/android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/assets/android-chrome-512x512.png",
|
"src": "/src/assets/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,26 @@
|
|||||||
import { createContext } from 'react'
|
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)
|
||||||
|
|||||||
@ -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 {
|
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 (
|
return (
|
||||||
<AuthContext.Provider value={auth}>
|
<AuthContext.Provider value={auth}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -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 BirthdayPage from '../BirthdayPage'
|
||||||
import BirthtimePage from '../BirthtimePage'
|
import BirthtimePage from '../BirthtimePage'
|
||||||
import CreateProfilePage from '../CreateProfilePage'
|
import CreateProfilePage from '../CreateProfilePage'
|
||||||
@ -22,7 +23,9 @@ function App(): JSX.Element {
|
|||||||
<Route path={routes.client.birthtime()} element={<BirthtimePage />} />
|
<Route path={routes.client.birthtime()} element={<BirthtimePage />} />
|
||||||
<Route path={routes.client.createProfile()} element={<CreateProfilePage />} />
|
<Route path={routes.client.createProfile()} element={<CreateProfilePage />} />
|
||||||
<Route path={routes.client.emailEnter()} element={<EmailEnterPage />} />
|
<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 />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</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
|
export default App
|
||||||
|
|||||||
@ -15,7 +15,7 @@ function BirthdayPage(): JSX.Element {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const birthdate = useSelector((state: RootState) => state.birthdate)
|
const birthdate = useSelector((state: RootState) => state.form.birthdate)
|
||||||
const [isDisabled, setIsDisabled] = useState(true)
|
const [isDisabled, setIsDisabled] = useState(true)
|
||||||
const links = [
|
const links = [
|
||||||
{ text: 'EULA', href: 'https://aura.wit.life/terms' },
|
{ text: 'EULA', href: 'https://aura.wit.life/terms' },
|
||||||
@ -24,7 +24,7 @@ function BirthdayPage(): JSX.Element {
|
|||||||
]
|
]
|
||||||
const handleNext = () => navigate(routes.client.birthtime())
|
const handleNext = () => navigate(routes.client.birthtime())
|
||||||
const handleValid = (birthdate: string) => {
|
const handleValid = (birthdate: string) => {
|
||||||
dispatch(actions.birthdate.update(birthdate))
|
dispatch(actions.form.addDate(birthdate))
|
||||||
setIsDisabled(birthdate === '')
|
setIsDisabled(birthdate === '')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,7 +38,9 @@ function BirthdayPage(): JSX.Element {
|
|||||||
onValid={handleValid}
|
onValid={handleValid}
|
||||||
onInvalid={() => setIsDisabled(true)}
|
onInvalid={() => setIsDisabled(true)}
|
||||||
/>
|
/>
|
||||||
<MainButton label={t('next')} onClick={handleNext} disabled={isDisabled}/>
|
<MainButton onClick={handleNext} disabled={isDisabled}>
|
||||||
|
{t('next')}
|
||||||
|
</MainButton>
|
||||||
<footer className='footer'>
|
<footer className='footer'>
|
||||||
<Policy links={links}>{t('privacyText')}</Policy>
|
<Policy links={links}>{t('privacyText')}</Policy>
|
||||||
<Purposes />
|
<Purposes />
|
||||||
|
|||||||
@ -12,15 +12,15 @@ function BirthtimePage(): JSX.Element {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const navigate = useNavigate();
|
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 handleNext = () => navigate(routes.client.createProfile())
|
||||||
const handleChange = (value: string) => dispatch(actions.birthtime.update(value))
|
const handleChange = (value: string) => dispatch(actions.form.addTime(value))
|
||||||
return (
|
return (
|
||||||
<section className='page'>
|
<section className='page'>
|
||||||
<Title variant="h2" className="mt-24">{t('bornTimeQuestion')}</Title>
|
<Title variant="h2" className="mt-24">{t('bornTimeQuestion')}</Title>
|
||||||
<p className="description">{t('nasaDataUsing')}</p>
|
<p className="description">{t('nasaDataUsing')}</p>
|
||||||
<TimePicker value={birthtime} onChange={handleChange}/>
|
<TimePicker value={birthtime} onChange={handleChange}/>
|
||||||
<MainButton label={t('next')} onClick={handleNext}/>
|
<MainButton onClick={handleNext}>{t('next')}</MainButton>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import Loader from "../Loader"
|
||||||
|
|
||||||
type ProcessItemProps = {
|
type ProcessItemProps = {
|
||||||
top: number
|
top: number
|
||||||
label: string
|
label: string
|
||||||
@ -12,7 +14,7 @@ function ProcessItem({ top, label, isDone }: ProcessItemProps): JSX.Element {
|
|||||||
{
|
{
|
||||||
isDone
|
isDone
|
||||||
? <div className='process-item__icon'>✅</div>
|
? <div className='process-item__icon'>✅</div>
|
||||||
: <div className="process-item__loader"><span></span></div>
|
: <Loader />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className='process-item__label'>{label}</div>
|
<div className='process-item__label'>{label}</div>
|
||||||
|
|||||||
@ -21,71 +21,8 @@
|
|||||||
.process-item__pick {
|
.process-item__pick {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
clear: both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-item__icon {
|
.process-item__icon {
|
||||||
font-size: 32px;
|
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); }
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FormField } from '../../types'
|
import { FormField } from '../../types'
|
||||||
import DateInput from './DateInput'
|
import DateInput from './DateInput'
|
||||||
import ErrorText from './ErrorText'
|
import ErrorText from '../ErrorText'
|
||||||
import { stringify, getCurrentYear } from './utils'
|
import { stringify, getCurrentYear } from './utils'
|
||||||
|
|
||||||
export function DatePicker(props: FormField<string>): JSX.Element {
|
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)}
|
onChange={(day: string) => setDay(day)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ErrorText
|
<ErrorText isShown={hasError} message={t('invalidDate')} />
|
||||||
isShown={hasError}
|
|
||||||
message={t('invalidDate')}
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -97,19 +97,3 @@
|
|||||||
border: 2px solid #dee5f9;
|
border: 2px solid #dee5f9;
|
||||||
padding-top: 5px;
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,27 +3,51 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
import { actions, RootState } from '../../store'
|
import { actions, RootState } from '../../store'
|
||||||
|
import { getClientLocale, getClientTimezone } from '../../locales'
|
||||||
import Title from '../Title'
|
import Title from '../Title'
|
||||||
import Policy from '../Policy'
|
import Policy from '../Policy'
|
||||||
import EmailInput from './EmailInput'
|
import EmailInput from './EmailInput'
|
||||||
import MainButton from '../MainButton'
|
import MainButton from '../MainButton'
|
||||||
|
import Loader, { LoaderColor } from '../Loader'
|
||||||
|
import ErrorText from '../ErrorText'
|
||||||
import routes from '../../routes'
|
import routes from '../../routes'
|
||||||
|
import { useAuth } from '../../auth'
|
||||||
|
|
||||||
function EmailEnterPage(): JSX.Element {
|
function EmailEnterPage(): JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const navigate = useNavigate()
|
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 [isDisabled, setIsDisabled] = useState(true)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
const links = [
|
const links = [
|
||||||
{ text: 'EULA', href: 'https://aura.wit.life/terms' },
|
{ text: 'EULA', href: 'https://aura.wit.life/terms' },
|
||||||
{ text: 'Privacy Policy', href: 'https://aura.wit.life/privacy' },
|
{ text: 'Privacy Policy', href: 'https://aura.wit.life/privacy' },
|
||||||
]
|
]
|
||||||
const handleValidEmail = (email: string) => {
|
const handleValidEmail = (email: string) => {
|
||||||
dispatch(actions.email.update(email))
|
dispatch(actions.form.addEmail(email))
|
||||||
setIsDisabled(false)
|
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 (
|
return (
|
||||||
<section className='page'>
|
<section className='page'>
|
||||||
@ -37,7 +61,10 @@ function EmailEnterPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
<p>{t('weDontShare')}</p>
|
<p>{t('weDontShare')}</p>
|
||||||
<Policy links={links} sizing='medium'>{t('continueAgree')}</Policy>
|
<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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/components/ErrorText/index.tsx
Normal file
18
src/components/ErrorText/index.tsx
Normal 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
|
||||||
27
src/components/ErrorText/styles.css
Normal file
27
src/components/ErrorText/styles.css
Normal 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;
|
||||||
|
}
|
||||||
21
src/components/Loader/index.tsx
Normal file
21
src/components/Loader/index.tsx
Normal 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
|
||||||
74
src/components/Loader/styles.css
Normal file
74
src/components/Loader/styles.css
Normal 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); }
|
||||||
|
}
|
||||||
@ -1,13 +1,11 @@
|
|||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
label: string;
|
|
||||||
} & 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(' ')
|
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
|
export default MainButton
|
||||||
|
|||||||
@ -15,6 +15,8 @@
|
|||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-btn:disabled {
|
.main-btn:disabled {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import { useSelector } from 'react-redux'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RootState } from '../../store'
|
||||||
import MainButton from '../MainButton'
|
import MainButton from '../MainButton'
|
||||||
import Policy from '../Policy'
|
import Policy from '../Policy'
|
||||||
import Countdown from '../Countdown'
|
import Countdown from '../Countdown'
|
||||||
@ -8,7 +10,7 @@ import CallToAction from '../CallToAction'
|
|||||||
|
|
||||||
function SubscriptionPage(): JSX.Element {
|
function SubscriptionPage(): JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const userEmail = 'some@email.com'
|
const email = useSelector((state: RootState) => state.form.email)
|
||||||
const links = [
|
const links = [
|
||||||
{ text: 'Subscription policy', href: 'https://aura.wit.life/' },
|
{ text: 'Subscription policy', href: 'https://aura.wit.life/' },
|
||||||
]
|
]
|
||||||
@ -24,12 +26,12 @@ function SubscriptionPage(): JSX.Element {
|
|||||||
const handleClick = () => console.log('What we will do?')
|
const handleClick = () => console.log('What we will do?')
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UserHeader email={userEmail} />
|
<UserHeader email={email} />
|
||||||
<section className='page'>
|
<section className='page'>
|
||||||
<CallToAction />
|
<CallToAction />
|
||||||
<Countdown start={10}/>
|
<Countdown start={10}/>
|
||||||
<Payment items={paymentItems} currency={currency} locale={locale}/>
|
<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>
|
<Policy links={links}>{t('subscriptionPolicy')}</Policy>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
import en from './en.ts'
|
import en from './en.ts'
|
||||||
|
|
||||||
|
export const getClientLocale = () => navigator.language.split('-')[0]
|
||||||
|
export const getClientTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
|
||||||
export default { en }
|
export default { en }
|
||||||
|
|||||||
@ -14,7 +14,9 @@ const routes = {
|
|||||||
server: {
|
server: {
|
||||||
locales: () => [apiHost, prefix, 'locales.json'].join('/'),
|
locales: () => [apiHost, prefix, 'locales.json'].join('/'),
|
||||||
translations: () => [apiHost, prefix, 't.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('/'),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
32
src/store/form.ts
Normal 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
|
||||||
@ -1,22 +1,23 @@
|
|||||||
import { combineReducers, configureStore } from '@reduxjs/toolkit'
|
import { combineReducers, configureStore, createAction } from '@reduxjs/toolkit'
|
||||||
import birthdate, { actions as birthdateActions } from './birthdate'
|
import token, { actions as tokenActions } from './token'
|
||||||
import birthtime, { actions as birthtimeActions } from './birthtime'
|
import user, { actions as userActions } from './user'
|
||||||
import email, { actions as emailActions } from './email'
|
import form, { actions as formActions } from './form'
|
||||||
import { loadStore, backupStore } from './storageHelper'
|
import { loadStore, backupStore } from './storageHelper'
|
||||||
|
|
||||||
const preloadedState = loadStore()
|
const preloadedState = loadStore()
|
||||||
export const reducer = combineReducers({
|
export const reducer = combineReducers({ token, user, form })
|
||||||
email,
|
|
||||||
birthdate,
|
|
||||||
birthtime,
|
|
||||||
})
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
email: emailActions,
|
token: tokenActions,
|
||||||
birthdate: birthdateActions,
|
user: userActions,
|
||||||
birthtime: birthtimeActions,
|
form: formActions,
|
||||||
|
reset: createAction('reset'),
|
||||||
}
|
}
|
||||||
export type RootState = ReturnType<typeof reducer>
|
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 AppDispatch = typeof store.dispatch
|
||||||
export type StoreType = typeof store
|
export type StoreType = typeof store
|
||||||
export const unsubscribe = backupStore(store)
|
export const unsubscribe = backupStore(store)
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export const backupStore = (store: ToolkitStore) => {
|
|||||||
const saveState = () => {
|
const saveState = () => {
|
||||||
const state = store.getState()
|
const state = store.getState()
|
||||||
try {
|
try {
|
||||||
const serializedState = JSON.stringify(state)
|
const serializedState = window.btoa(JSON.stringify(state))
|
||||||
localStorage.setItem(storageKey, serializedState)
|
localStorage.setItem(storageKey, serializedState)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// nothing to do
|
// nothing to do
|
||||||
@ -31,7 +31,7 @@ export const loadStore = () => {
|
|||||||
if (serializedState === null) {
|
if (serializedState === null) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return JSON.parse(serializedState)
|
return JSON.parse(window.atob(serializedState))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/store/token.ts
Normal file
20
src/store/token.ts
Normal 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
45
src/store/user.ts
Normal 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
|
||||||
83
src/types.ts
83
src/types.ts
@ -6,3 +6,86 @@ export interface FormField<T> {
|
|||||||
onValid: (value: T) => void
|
onValid: (value: T) => void
|
||||||
onInvalid: () => 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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user