feat: add payment and wallpaper pages

This commit is contained in:
Aidar Shaikhutdin @makeweb.space 2023-05-18 19:03:20 +06:00
parent 3e9cb7f233
commit 6bf832da0d
37 changed files with 672 additions and 85 deletions

View File

@ -8,6 +8,8 @@ import {
Assets, Assets,
AssetCategories, AssetCategories,
DailyForecasts, DailyForecasts,
SubscriptionItems,
SubscriptionCheckout,
} from './resources' } from './resources'
export interface ApiContextValue { export interface ApiContextValue {
@ -19,6 +21,8 @@ export interface ApiContextValue {
getAssetCategories: ReturnType<typeof createMethod<AssetCategories.Payload, AssetCategories.Response>> getAssetCategories: ReturnType<typeof createMethod<AssetCategories.Payload, AssetCategories.Response>>
getDailyForecasts: ReturnType<typeof createMethod<DailyForecasts.Payload, DailyForecasts.Response>> getDailyForecasts: ReturnType<typeof createMethod<DailyForecasts.Payload, DailyForecasts.Response>>
getAuras: ReturnType<typeof createMethod<Auras.Payload, Auras.Response>> getAuras: ReturnType<typeof createMethod<Auras.Payload, Auras.Response>>
getSubscriptionItems: ReturnType<typeof createMethod<SubscriptionItems.Payload, SubscriptionItems.Response>>
getSubscriptionCheckout: ReturnType<typeof createMethod<SubscriptionCheckout.Payload, SubscriptionCheckout.Response>>
} }
export const ApiContext = createContext<ApiContextValue>({} as ApiContextValue) export const ApiContext = createContext<ApiContextValue>({} as ApiContextValue)

View File

@ -8,17 +8,23 @@ import {
Assets, Assets,
AssetCategories, AssetCategories,
DailyForecasts, DailyForecasts,
SubscriptionItems,
SubscriptionCheckout,
} from './resources' } from './resources'
export function createApi(): ApiContextValue { const api = {
return { auth: createMethod<AuthTokens.Payload, AuthTokens.Response>(AuthTokens.createRequest),
auth: createMethod<AuthTokens.Payload, AuthTokens.Response>(AuthTokens.createRequest), getElements: createMethod<Elements.Payload, Elements.Response>(Elements.createRequest),
getElements: createMethod<Elements.Payload, Elements.Response>(Elements.createRequest), getUser: createMethod<User.GetPayload, User.Response>(User.createGetRequest),
getUser: createMethod<User.GetPayload, User.Response>(User.createGetRequest), updateUser: createMethod<User.PatchPayload, User.Response>(User.createPatchRequest),
updateUser: createMethod<User.PatchPayload, User.Response>(User.createPatchRequest), getAssets: createMethod<Assets.Payload, Assets.Response>(Assets.createRequest),
getAssets: createMethod<Assets.Payload, Assets.Response>(Assets.createRequest), getAssetCategories: createMethod<AssetCategories.Payload, AssetCategories.Response>(AssetCategories.createRequest),
getAssetCategories: createMethod<AssetCategories.Payload, AssetCategories.Response>(AssetCategories.createRequest), getDailyForecasts: createMethod<DailyForecasts.Payload, DailyForecasts.Response>(DailyForecasts.createRequest),
getDailyForecasts: createMethod<DailyForecasts.Payload, DailyForecasts.Response>(DailyForecasts.createRequest), getAuras: createMethod<Auras.Payload, Auras.Response>(Auras.createRequest),
getAuras: createMethod<Auras.Payload, Auras.Response>(Auras.createRequest), getSubscriptionItems: createMethod<SubscriptionItems.Payload, SubscriptionItems.Response>(SubscriptionItems.createRequest),
} getSubscriptionCheckout: createMethod<SubscriptionCheckout.Payload, SubscriptionCheckout.Response>(SubscriptionCheckout.createRequest),
}
export function createApi(): ApiContextValue {
return api
} }

View File

@ -1,6 +1,6 @@
export * from './useApi' export * from './useApi'
export * from './useApiCall'
export * from './ApiContext' export * from './ApiContext'
export * from './api' export * from './api'
export * from './types' export * from './types'
export type { User } from './resources/User' export * from './resources'
export type { Payload as SignUpPayload } from './resources/AuthTokens'

View File

@ -0,0 +1,36 @@
import routes from "../../routes"
import { AuthToken } from "../types"
import { getAuthHeaders } from "../utils"
export interface Payload {
token: AuthToken
embed?: boolean
locale: string
itemPriceId: string
}
export interface Response {
hosted_page: string
}
export interface HostedPage {
id: string
url: string
embed: boolean
type: string
object: string
state: string
resource_version: number
created_at: number
updated_at: number
expires_at: number
}
export const createRequest = ({ locale, token, itemPriceId, embed = false }: Payload): Request => {
const url = new URL(routes.server.subscriptionCheckout())
const query = new URLSearchParams({ locale, item_price_id: itemPriceId, embed: embed.toString() })
url.search = query.toString()
return new Request(url, { method: 'GET', headers: getAuthHeaders(token) })
}

View File

@ -0,0 +1,41 @@
import routes from "../../routes"
import { AuthToken } from "../types"
import { getAuthHeaders } from "../utils"
export interface Payload {
locale: string
token: AuthToken
}
export interface Response {
item_prices: ItemPrice[]
}
export interface ItemPrice {
currency_code: string
external_name: string
free_quantity: number
id: string
is_taxable: boolean
item_id: string
item_type: string
name: string
object: string
period: number
period_unit: string
price: number
pricing_model: string
resource_version: number
status: string
created_at: number
updated_at: number
}
export const createRequest = ({ locale, token }: Payload): Request => {
const url = new URL(routes.server.subscriptionItems())
const query = new URLSearchParams({ locale })
url.search = query.toString()
return new Request(url, { method: 'GET', headers: getAuthHeaders(token) })
}

View File

@ -5,3 +5,5 @@ export * as DailyForecasts from './UserDailyForecasts'
export * as Auras from './Auras' export * as Auras from './Auras'
export * as Elements from './Elements' export * as Elements from './Elements'
export * as AuthTokens from './AuthTokens' export * as AuthTokens from './AuthTokens'
export * as SubscriptionItems from './UserSubscriptionItemPrices'
export * as SubscriptionCheckout from './UserSubscriptionCheckout'

24
src/api/useApiCall.ts Normal file
View File

@ -0,0 +1,24 @@
import { useState, useEffect } from "react";
interface HookResult<T> {
isPending: boolean
error: Error | null
data: T
}
type ApiMethod<T> = () => Promise<T>
export function useApiCall<T>(apiMethod: ApiMethod<T>): HookResult<T> {
const [data, setData] = useState<T>({} as T)
const [error, setError] = useState<Error | null>(null)
const [isPending, setIsPending] = useState<boolean>(true)
useEffect(() => {
apiMethod()
.then((data: T) => setData(data))
.catch((error: Error) => setError(error))
.finally(() => setIsPending(false))
}, [apiMethod])
return { isPending, error, data }
}

View File

@ -1,12 +1,11 @@
import { createContext } from 'react' import { createContext } from 'react'
import { AuthToken, User, SignUpPayload } from '../api' import { AuthToken, User } from '../api'
export interface AuthContextValue { export interface AuthContextValue {
user: User | null user: User.User | null
token: AuthToken token: AuthToken
logout: () => void logout: () => void
signUp: (payload: SignUpPayload) => Promise<AuthToken> signUp: (token: AuthToken, user: User.User) => AuthToken
addBirthday: (birthday: string, token: AuthToken) => Promise<void>
} }
export const AuthContext = createContext<AuthContextValue>({} as AuthContextValue) export const AuthContext = createContext<AuthContextValue>({} as AuthContextValue)

View File

@ -1,26 +1,25 @@
import { useCallback, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { RootState, actions } from '../store' import { actions, selectors } from '../store'
import { useApi, AuthToken, SignUpPayload } from '../api' import { AuthToken, User } from '../api'
import { AuthContext } from './AuthContext' import { AuthContext } from './AuthContext'
export function AuthProvider({ children }: React.PropsWithChildren<unknown>): JSX.Element { export function AuthProvider({ children }: React.PropsWithChildren<unknown>): JSX.Element {
const api = useApi()
const dispatch = useDispatch() const dispatch = useDispatch()
const token = useSelector((state: RootState) => state.token) const token = useSelector(selectors.selectToken)
const user = useSelector((state: RootState) => state.user) const user = useSelector(selectors.selectUser)
const signUp = async (payload: SignUpPayload): Promise<AuthToken> => { const signUp = useCallback((token: AuthToken, user: User.User): AuthToken => {
const { auth: { token, user } } = await api.auth(payload)
dispatch(actions.token.update(token)) dispatch(actions.token.update(token))
dispatch(actions.user.update(user)) dispatch(actions.user.update(user))
return token return token
} }, [dispatch])
const addBirthday = async (birthday: string, token: AuthToken): Promise<void> => { const logout = useCallback(() => dispatch(actions.reset()), [dispatch])
const payload = { user: { profile_attributes: { birthday } }, token } const auth = useMemo(() => ({
const { user } = await api.updateUser(payload) signUp,
dispatch(actions.user.update(user)) logout,
} token,
const logout = () => dispatch(actions.reset()) user: user.id ? user : null
const auth = { signUp, logout, addBirthday, token, user: user.id ? user : null } }), [token, user, signUp, logout])
return ( return (
<AuthContext.Provider value={auth}> <AuthContext.Provider value={auth}>
{children} {children}

View File

@ -1,41 +1,62 @@
import { Routes, Route, Navigate, Outlet } from 'react-router-dom' import { useState } from 'react'
import { Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '../../auth' 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'
import EmailEnterPage from '../EmailEnterPage' import EmailEnterPage from '../EmailEnterPage'
import SubscriptionPage from '../SubscriptionPage' import SubscriptionPage from '../SubscriptionPage'
import PaymentPage from '../PaymentPage'
import WallpaperPage from '../WallpaperPage'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
import Header from '../Header' import Header from '../Header'
import routes from '../../routes' import Navbar from '../Navbar'
import routes, { hasNavigation } from '../../routes'
import './styles.css' import './styles.css'
function App(): JSX.Element { function App(): JSX.Element {
return (
<Routes>
<Route element={<Layout />}>
<Route path={routes.client.root()} element={
<Navigate to={routes.client.birthday()} />
} />
<Route path={routes.client.birthday()} element={<BirthdayPage />} />
<Route path={routes.client.birthtime()} element={<BirthtimePage />} />
<Route path={routes.client.createProfile()} element={<SkipStep />} />
<Route path={routes.client.emailEnter()} element={<EmailEnterPage />} />
<Route element={<PrivateOutlet />}>
<Route path={routes.client.subscription()} element={<SubscriptionPage />} />
<Route path={routes.client.paymentMethod()} element={<PaymentPage />} />
<Route path={routes.client.wallpaper()} element={<WallpaperPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
)
}
function Layout(): JSX.Element {
const location = useLocation()
const showNavbar = hasNavigation(location.pathname)
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false)
return ( return (
<div className='container'> <div className='container'>
<Header /> <Header openMenu={() => setIsMenuOpen(true)}/>
<main className='content'> <main className='content'><Outlet /></main>
<Routes> { showNavbar ? <Navbar isOpen={isMenuOpen} closeMenu={() => setIsMenuOpen(false)} /> : null}
<Route path={routes.client.root()} element={
<Navigate to={routes.client.birthday()} />
} />
<Route path={routes.client.birthday()} element={<BirthdayPage />} />
<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={<PrivateOutlet />}>
<Route path='' element={<SubscriptionPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
</div> </div>
) )
} }
function PrivateOutlet(): JSX.Element { function PrivateOutlet(): JSX.Element {
const { user } = useAuth() const { user } = useAuth()
return user ? <Outlet /> : <Navigate to={routes.client.root()} /> return user ? <Outlet /> : <Navigate to={routes.client.root()} replace={true} />
}
function SkipStep(): JSX.Element {
const { user } = useAuth()
return user ? <Navigate to={routes.client.emailEnter()} replace={true} /> : <CreateProfilePage />
} }
export default App export default App

View File

@ -22,3 +22,7 @@
overflow: hidden; overflow: hidden;
padding: 15px 32px; padding: 15px 32px;
} }
.page-responsive {
width: 100%;
}

View File

@ -2,8 +2,10 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom' 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, selectors } from '../../store'
import { getClientLocale, getClientTimezone } from '../../locales' import { getClientTimezone } from '../../locales'
import { useAuth } from '../../auth'
import { useApi } from '../../api'
import Title from '../Title' import Title from '../Title'
import Policy from '../Policy' import Policy from '../Policy'
import EmailInput from './EmailInput' import EmailInput from './EmailInput'
@ -11,17 +13,19 @@ import MainButton from '../MainButton'
import Loader, { LoaderColor } from '../Loader' import Loader, { LoaderColor } from '../Loader'
import ErrorText from '../ErrorText' 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 api = useApi()
const { t, i18n } = useTranslation()
const dispatch = useDispatch() const dispatch = useDispatch()
const navigate = useNavigate() const navigate = useNavigate()
const { user, signUp, addBirthday } = useAuth() const { user, signUp } = useAuth()
const { email, birthdate, birthtime } = useSelector((state: RootState) => state.form) const { email, birthdate, birthtime } = useSelector(selectors.selectForm)
const [isDisabled, setIsDisabled] = useState(true) const [isDisabled, setIsDisabled] = useState(true)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const timezone = getClientTimezone()
const locale = i18n.language
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' },
@ -37,15 +41,17 @@ function EmailEnterPage(): JSX.Element {
} }
setError(null) setError(null)
setIsLoading(true) setIsLoading(true)
signUp({ api.auth({ email, timezone, locale })
email, .then(({ auth: { token, user } }) => signUp(token, user))
timezone: getClientTimezone(), .then((token) => {
locale: getClientLocale(), const birthday = `${birthdate} ${birthtime}`
}) const payload = { user: { profile_attributes: { birthday } }, token }
.then((token) => addBirthday(`${birthdate} ${birthtime}`, token)) return api.updateUser(payload)
.then(() => navigate(routes.client.subscription())) })
.catch((error: Error) => setError(error)) .then(({ user }) => dispatch(actions.user.update(user)))
.finally(() => setIsLoading(false)) .then(() => navigate(routes.client.subscription()))
.catch((error: Error) => setError(error))
.finally(() => setIsLoading(false))
} }

View File

@ -1,18 +1,24 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import routes, { isNotEntrypoint } from '../../routes' import routes, { hasNavigation, isNotEntrypoint } from '../../routes'
import BackButton from '../BackButton' import BackButton from '../BackButton'
import iconUrl from './icon.png' import iconUrl from './icon.png'
import menuUrl from './menu.png'
import './styles.css' import './styles.css'
function Header(): JSX.Element { type HeaderProps = {
openMenu: () => void
}
function Header({ openMenu }: HeaderProps): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [initialPath, setInitialPath] = useState<string | null>(null); const [initialPath, setInitialPath] = useState<string | null>(null);
const [isNavigated, setIsNavigated] = useState<boolean>(false); const [isNavigated, setIsNavigated] = useState<boolean>(false);
const showBackButton = isNotEntrypoint(location.pathname) const showBackButton = isNotEntrypoint(location.pathname)
const showMenuButton = hasNavigation(location.pathname)
useEffect(() => { useEffect(() => {
if (!initialPath) { if (!initialPath) {
@ -36,6 +42,9 @@ function Header(): JSX.Element {
{ showBackButton ? <BackButton className="pa" onClick={goBack} /> : null } { showBackButton ? <BackButton className="pa" onClick={goBack} /> : null }
<img src={iconUrl} alt="logo" width="40" height="40" /> <img src={iconUrl} alt="logo" width="40" height="40" />
<span className="header__title">{t('appName')}</span> <span className="header__title">{t('appName')}</span>
{showMenuButton ? <div className="header__menu-btn" onClick={openMenu}>
<img src={menuUrl} alt="menu" width="40" height="40" />
</div> : null}
</header> </header>
) )
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -1,12 +1,13 @@
.header { .header {
align-items: center;
display: flex; display: flex;
position: relative;
align-items: center;
justify-content: center; justify-content: center;
background: #eff1fd; background: #eff1fd;
width: 100%;
height: 50px; height: 50px;
min-height: 50px; min-height: 50px;
position: relative; z-index: 1;
width: 100%;
} }
.header__title { .header__title {
@ -15,3 +16,12 @@
margin-left: 10px; margin-left: 10px;
text-transform: uppercase; text-transform: uppercase;
} }
.header__menu-btn {
position: absolute;
top: 5px;
right: 28px;
width: 40px;
height: 40px;
cursor: pointer;
}

View File

@ -0,0 +1,34 @@
import './styles.css'
type NavbarProps = {
isOpen: boolean
closeMenu: () => void
}
function Navbar({ isOpen, closeMenu }: NavbarProps): JSX.Element {
const combinedClassNames = ['navbar', isOpen && 'navbar--open'].filter(Boolean).join(' ')
return (
<aside className={combinedClassNames}>
<div className='navbar__overlay' onClick={closeMenu}></div>
<div className='navbar__panel'>
<div className="navbar__close-btn" onClick={closeMenu}>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<title>cross</title>
<path d="M10.051 12l-10.051 10.051 1.949 1.949 10.051-10.051 10.051 10.051 1.949-1.949-10.051-10.051 10.051-10.051-1.949-1.949-10.051 10.051-10.051-10.051-1.949 1.949 10.051 10.051z"></path>
</svg>
</div>
<nav className='navbar__nav'>
<a href='#' target="_blank" rel="noreferrer nofollow">Privacy policy</a>
<a href='#' target="_blank" rel="noreferrer nofollow">Terms of use</a>
<a href='#' target="_blank" rel="noreferrer nofollow">Payment terms</a>
<a href='#' target="_blank" rel="noreferrer nofollow">Subscription terms</a>
<a href='#' target="_blank" rel="noreferrer nofollow">Money back policy</a>
<a href='#' target="_blank" rel="noreferrer nofollow">FAQ</a>
<a href='#' target="_blank" rel="noreferrer nofollow">Contact us</a>
</nav>
</div>
</aside>
)
}
export default Navbar

View File

@ -0,0 +1,53 @@
.navbar {
position: fixed;
overflow: hidden;
z-index: -1;
transition: z-index .2s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.navbar.navbar--open {
z-index: 5;
}
.navbar.navbar--open .navbar__panel {
transform: none;
}
.navbar.navbar--open .navbar__overlay {
display: block;
}
.navbar__panel {
position: relative;
width: 312px;
height: 100vh;
padding: 20px;
color: #000;
transform: translateX(-100%);
transition: transform .2s cubic-bezier(0.22, 0.61, 0.36, 1);
background-color: rgb(255, 255, 255);
box-shadow: rgba(0, 0, 0, 0.14) 0px 16px 24px 2px;
}
.navbar__nav > a {
display: block;
margin-bottom: 10px;
font-size: 16px;
line-height: 1.5;
}
.navbar__close-btn {
cursor: pointer;
margin-bottom: 20px;
}
.navbar__overlay {
display: none;
position: fixed;
top: 0;
right: 0;
left: 0;
bottom: 0;
background: rgba(0,0,0,.4);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" baseProfile="tiny" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px" viewBox="0 0 512 210.2" xml:space="preserve">
<path fill="#fff" id="XMLID_34_" d="M93.6,27.1C87.6,34.2,78,39.8,68.4,39c-1.2-9.6,3.5-19.8,9-26.1c6-7.3,16.5-12.5,25-12.9
C103.4,10,99.5,19.8,93.6,27.1 M102.3,40.9c-13.9-0.8-25.8,7.9-32.4,7.9c-6.7,0-16.8-7.5-27.8-7.3c-14.3,0.2-27.6,8.3-34.9,21.2
c-15,25.8-3.9,64,10.6,85c7.1,10.4,15.6,21.8,26.8,21.4c10.6-0.4,14.8-6.9,27.6-6.9c12.9,0,16.6,6.9,27.8,6.7
c11.6-0.2,18.9-10.4,26-20.8c8.1-11.8,11.4-23.3,11.6-23.9c-0.2-0.2-22.4-8.7-22.6-34.3c-0.2-21.4,17.5-31.6,18.3-32.2
C123.3,42.9,107.7,41.3,102.3,40.9 M182.6,11.9v155.9h24.2v-53.3h33.5c30.6,0,52.1-21,52.1-51.4c0-30.4-21.1-51.2-51.3-51.2H182.6z
M206.8,32.3h27.9c21,0,33,11.2,33,30.9c0,19.7-12,31-33.1,31h-27.8V32.3z M336.6,169c15.2,0,29.3-7.7,35.7-19.9h0.5v18.7h22.4V90.2
c0-22.5-18-37-45.7-37c-25.7,0-44.7,14.7-45.4,34.9h21.8c1.8-9.6,10.7-15.9,22.9-15.9c14.8,0,23.1,6.9,23.1,19.6v8.6l-30.2,1.8
c-28.1,1.7-43.3,13.2-43.3,33.2C298.4,155.6,314.1,169,336.6,169z M343.1,150.5c-12.9,0-21.1-6.2-21.1-15.7c0-9.8,7.9-15.5,23-16.4
l26.9-1.7v8.8C371.9,140.1,359.5,150.5,343.1,150.5z M425.1,210.2c23.6,0,34.7-9,44.4-36.3L512,54.7h-24.6l-28.5,92.1h-0.5
l-28.5-92.1h-25.3l41,113.5l-2.2,6.9c-3.7,11.7-9.7,16.2-20.4,16.2c-1.9,0-5.6-0.2-7.1-0.4v18.7C417.3,210,423.3,210.2,425.1,210.2z
"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 80 38.1" style="enable-background:new 0 0 80 38.1;" xml:space="preserve">
<style type="text/css">
.st0{fill:#5F6368;}
.st1{fill:#4285F4;}
.st2{fill:#34A853;}
.st3{fill:#FBBC04;}
.st4{fill:#EA4335;}
</style>
<path class="st0" d="M37.8,19.7V29h-3V6h7.8c1.9,0,3.7,0.7,5.1,2c1.4,1.2,2.1,3,2.1,4.9c0,1.9-0.7,3.6-2.1,4.9c-1.4,1.3-3.1,2-5.1,2
L37.8,19.7L37.8,19.7z M37.8,8.8v8h5c1.1,0,2.2-0.4,2.9-1.2c1.6-1.5,1.6-4,0.1-5.5c0,0-0.1-0.1-0.1-0.1c-0.8-0.8-1.8-1.3-2.9-1.2
L37.8,8.8L37.8,8.8z"/>
<path class="st0" d="M56.7,12.8c2.2,0,3.9,0.6,5.2,1.8s1.9,2.8,1.9,4.8V29H61v-2.2h-0.1c-1.2,1.8-2.9,2.7-4.9,2.7
c-1.7,0-3.2-0.5-4.4-1.5c-1.1-1-1.8-2.4-1.8-3.9c0-1.6,0.6-2.9,1.8-3.9c1.2-1,2.9-1.4,4.9-1.4c1.8,0,3.2,0.3,4.3,1v-0.7
c0-1-0.4-2-1.2-2.6c-0.8-0.7-1.8-1.1-2.9-1.1c-1.7,0-3,0.7-3.9,2.1l-2.6-1.6C51.8,13.8,53.9,12.8,56.7,12.8z M52.9,24.2
c0,0.8,0.4,1.5,1,1.9c0.7,0.5,1.5,0.8,2.3,0.8c1.2,0,2.4-0.5,3.3-1.4c1-0.9,1.5-2,1.5-3.2c-0.9-0.7-2.2-1.1-3.9-1.1
c-1.2,0-2.2,0.3-3,0.9C53.3,22.6,52.9,23.3,52.9,24.2z"/>
<path class="st0" d="M80,13.3l-9.9,22.7h-3l3.7-7.9l-6.5-14.7h3.2l4.7,11.3h0.1l4.6-11.3H80z"/>
<path class="st1" d="M25.9,17.7c0-0.9-0.1-1.8-0.2-2.7H13.2v5.1h7.1c-0.3,1.6-1.2,3.1-2.6,4v3.3H22C24.5,25.1,25.9,21.7,25.9,17.7z"
/>
<path class="st2" d="M13.2,30.6c3.6,0,6.6-1.2,8.8-3.2l-4.3-3.3c-1.2,0.8-2.7,1.3-4.5,1.3c-3.4,0-6.4-2.3-7.4-5.5H1.4v3.4
C3.7,27.8,8.2,30.6,13.2,30.6z"/>
<path class="st3" d="M5.8,19.9c-0.6-1.6-0.6-3.4,0-5.1v-3.4H1.4c-1.9,3.7-1.9,8.1,0,11.9L5.8,19.9z"/>
<path class="st4" d="M13.2,9.4c1.9,0,3.7,0.7,5.1,2l0,0l3.8-3.8c-2.4-2.2-5.6-3.5-8.8-3.4c-5,0-9.6,2.8-11.8,7.3l4.4,3.4
C6.8,11.7,9.8,9.4,13.2,9.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -0,0 +1,5 @@
<svg width="23" height="16" viewBox="0 0 23 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" width="22" height="16" rx="2" fill="white"/>
<rect x="0.5" y="2.66406" width="22" height="2.66667" fill="#9FB8FF"/>
<rect x="3" y="7.35938" width="17" height="2" rx="1" fill="#CEDBFF"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@ -0,0 +1,67 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { selectors } from '../../store'
import { useAuth } from '../../auth'
import { useApi, useApiCall, SubscriptionCheckout } from '../../api'
import UserHeader from '../UserHeader'
import Title from '../Title'
import Loader from '../Loader'
import MainButton from '../MainButton'
import applePaySafeCheckout from './Apple-Pay.png'
import gPaySafeCheckout from './Google-Pay.png'
import secure from './Secure.png'
import ApplePay from './Apple-Pay.svg'
import GooglePay from './G-Pay.svg'
import card from './card.svg'
import routes from '../../routes'
import './styles.css'
const isAndroid = () => /Android/i.test(navigator.userAgent)
const isApple = () => /Macintosh|iPhone|iPad|iPod/i.test(navigator.userAgent)
function PaymentPage(): JSX.Element {
const api = useApi()
const { token } = useAuth()
const { i18n } = useTranslation()
const locale = i18n.language
const navigate = useNavigate()
const email = useSelector(selectors.selectEmail)
const handleClick = () => navigate(routes.client.wallpaper())
const loadData = useCallback(() => {
return api.getSubscriptionItems({ locale, token })
.then(({ item_prices }) => item_prices.find(({ id }) => id === 'aura-membership-2-week-USD'))
.then((item) => api.getSubscriptionCheckout({ locale, token, itemPriceId: item?.id || '' }))
}, [api, locale, token])
const { data, isPending } = useApiCall<SubscriptionCheckout.Response>(loadData)
console.log(data, isPending)
return (
<>
<UserHeader email={email} />
<section className='page'>
<div className='page-header'>
{isAndroid() && <img src={gPaySafeCheckout} alt='Guaranteed safe checkout' />}
{isApple() && <img src={applePaySafeCheckout} alt='Guaranteed safe checkout' />}
<img src={secure} alt='100% Secure' />
</div>
<Title variant='h1' className='mb-45'>Choose Payment Method</Title>
{isPending ? <Loader /> : (
<>
<MainButton onClick={handleClick}>
{isAndroid() && <img className='payment-btn' src={GooglePay} alt='Google Pay' />}
{isApple() && <img className='payment-btn' src={ApplePay} alt='Apple Pay' />}
</MainButton>
<div className='payment-divider'>OR</div>
<MainButton color='blue' onClick={handleClick}>
<img className='payment-card' src={card} alt='Credit / Debit Card' />
Credit / Debit Card
</MainButton>
</>
)}
</section>
</>
)
}
export default PaymentPage

View File

@ -0,0 +1,32 @@
.page-header {
width: 100%;
text-align: center;
margin: 10px 0 30px;
}
.page-header > img {
max-width: 400px;
width: 100%;
margin-bottom: 10px;
}
.page-header > img:last-child {
max-width: 100px;
}
.payment-card {
margin-right: 8px;
}
.payment-divider {
width: 100%;
height: 30px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.payment-btn {
height: 25px;
}

View File

@ -1,16 +1,19 @@
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RootState } from '../../store' import { useNavigate } from 'react-router-dom'
import { selectors } 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'
import Payment, { Currency, Locale } from '../Payment' import Payment, { Currency, Locale } from '../Payment'
import UserHeader from '../UserHeader' import UserHeader from '../UserHeader'
import CallToAction from '../CallToAction' import CallToAction from '../CallToAction'
import routes from '../../routes'
function SubscriptionPage(): JSX.Element { function SubscriptionPage(): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const email = useSelector((state: RootState) => state.form.email) const navigate = useNavigate()
const email = useSelector(selectors.selectEmail)
const links = [ const links = [
{ text: 'Subscription policy', href: 'https://aura.wit.life/' }, { text: 'Subscription policy', href: 'https://aura.wit.life/' },
] ]
@ -23,7 +26,7 @@ function SubscriptionPage(): JSX.Element {
description: '2-Week Plan', description: '2-Week Plan',
}, },
] ]
const handleClick = () => console.log('What we will do?') const handleClick = () => navigate(routes.client.paymentMethod())
return ( return (
<> <>
<UserHeader email={email} /> <UserHeader email={email} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,61 @@
import { useCallback } from 'react'
import { useApi, useApiCall, Assets, DailyForecasts } from '../../api'
import { useAuth } from '../../auth'
import { saveFile } from './utils'
import Loader, { LoaderColor } from '../Loader'
import './styles.css'
type Forecasts = DailyForecasts.Forecast[]
type PersonalAssets = Assets.Asset[]
interface WallpaperData {
assets: PersonalAssets
forecasts: Forecasts
}
function WallpaperPage(): JSX.Element {
const api = useApi()
const { user, token } = useAuth()
const category = user?.profile.sign?.sign || ''
const loadData = useCallback(() => {
return Promise.all([
api.getAssets({ token, category }),
api.getDailyForecasts({ token }),
])
.then(([{ assets }, { user_daily_forecast }]) => ({
assets,
forecasts: user_daily_forecast.forecasts,
}))
}, [api, category, token])
const { data, isPending } = useApiCall<WallpaperData>(loadData)
const { assets, forecasts } = data
const asset = assets?.at(0)
const handleDownload = () => {
asset && saveFile(asset.url, asset.asset_data.id)
}
return (
<section className='wallpaper-page'>
<div className='wallpaper-image'>
{asset ? <img src={asset.url} alt={category} /> : null}
{asset ? <div className='btn-download' onClick={handleDownload}></div> : null}
{isPending ? <Loader color={LoaderColor.White}/> : null}
</div>
<div className='wallpaper-content'>
{isPending ? null : (
<>
<h1 className='wallpaper-title'>Analysis of personal background</h1>
{forecasts.map((forecast) => (
<div key={forecast.category_name} className='wallpaper-forecast'>
<h2 className='wallpaper-subtitle'>{forecast.category}</h2>
<p className='wallpaper-text'>{forecast.body}</p>
</div>
))}
</>
)}
</div>
</section>
)
}
export default WallpaperPage

View File

@ -0,0 +1,68 @@
.wallpaper-page {
position: relative;
width: 100%;
}
.wallpaper-image > img {
width: 100%;
height: 500px;
object-fit: cover;
object-position: center;
}
.wallpaper-image {
display: flex;
justify-content: center;
align-items: center;
background-color: #04040a;
width: 100%;
height: 500px;
}
.wallpaper-content {
color: #fff;
background-color: #04040a;
line-height: 1.3;
padding-bottom: 15px;
position: relative;
min-height: calc(100vh - 500px);
}
.wallpaper-content::before {
content: '';
display: block;
position: absolute;
top: -30px;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(0deg, #04040a, transparent);
}
.wallpaper-title,
.wallpaper-subtitle {
color: #ea445a;
}
.wallpaper-subtitle {
margin-bottom: 5px;
}
.wallpaper-title,
.wallpaper-forecast {
padding: 0 32px;
margin-bottom: 15px;
}
.btn-download {
position: absolute;
cursor: pointer;
top: 28px;
right: 32px;
width: 50px;
height: 50px;
background-image: url(./Dowload.png);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}

View File

@ -0,0 +1,13 @@
export const saveFile = (url: string, href = 'file.ext'): void => {
fetch(url, { mode: 'no-cors' })
.then((response) => response.arrayBuffer())
.then((buffer) => {
const url = URL.createObjectURL(new Blob([buffer]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', href)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
})
}

View File

@ -55,6 +55,7 @@ ol,ul {
} }
a { a {
color: inherit;
text-decoration: none; text-decoration: none;
} }
@ -98,6 +99,10 @@ a,button,div,input,select,textarea {
margin-bottom: 24px; margin-bottom: 24px;
} }
.mb-45 {
margin-bottom: 45px;
}
.pa { .pa {
position: absolute; position: absolute;
} }

View File

@ -19,13 +19,18 @@ const routes = {
token: () => [apiHost, prefix, 'auth', 'token.json'].join('/'), token: () => [apiHost, prefix, 'auth', 'token.json'].join('/'),
assets: (category: string) => [apiHost, prefix, 'assets', 'categories', `${category}.json`].join('/'), assets: (category: string) => [apiHost, prefix, 'assets', 'categories', `${category}.json`].join('/'),
assetCategories: () => [apiHost, prefix, 'assets', 'categories.json'].join('/'), assetCategories: () => [apiHost, prefix, 'assets', 'categories.json'].join('/'),
dailyForecasts: () => [apiHost, prefix, 'user', 'daily_forecasts.json'].join('/'), dailyForecasts: () => [apiHost, prefix, 'user', 'daily_forecast.json'].join('/'),
auras: () => [apiHost, prefix, 'user', 'aura.json'].join('/'), auras: () => [apiHost, prefix, 'user', 'aura.json'].join('/'),
subscriptionItems: () => [apiHost, prefix, 'user', 'subscription', 'item_prices.json'].join('/'),
subscriptionCheckout: () => [apiHost, prefix, 'user', 'subscription', 'checkout', 'new.json'].join('/'),
}, },
} }
export const entrypoints = [routes.client.root(), routes.client.birthday()] export const entrypoints = [routes.client.root(), routes.client.birthday(), 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 hasNavigation = (path: string) => withNavigationRoutes.includes(path)
export const hasNoNavigation = (path: string) => !hasNavigation(path)
export default routes export default routes

View File

@ -1,4 +1,4 @@
import { createSlice } from '@reduxjs/toolkit' import { createSlice, createSelector } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit' import type { PayloadAction } from '@reduxjs/toolkit'
import { SignupForm } from '../types' import { SignupForm } from '../types'
@ -29,4 +29,32 @@ const formSlice = createSlice({
}) })
export const { actions } = formSlice export const { actions } = formSlice
const selectForm = createSelector(
(state: { form: SignupForm }) => state.form,
(form) => form
)
const selectEmail = createSelector(
(state: { form: SignupForm }) => state.form.email,
(email) => email
)
const selectBirthdate = createSelector(
(state: { form: SignupForm }) => state.form.birthdate,
(birthdate) => birthdate
)
const selectBirthtime = createSelector(
(state: { form: SignupForm }) => state.form.birthtime,
(birthtime) => birthtime
)
const selectBirthday = createSelector(
selectBirthdate,
selectBirthtime,
(birthdate, birthtime) => `${birthdate} ${birthtime}`
)
export const selectors = {
selectForm,
selectEmail,
selectBirthdate,
selectBirthtime,
selectBirthday,
}
export default formSlice.reducer export default formSlice.reducer

View File

@ -1,7 +1,7 @@
import { combineReducers, configureStore, createAction } from '@reduxjs/toolkit' import { combineReducers, configureStore, createAction } from '@reduxjs/toolkit'
import token, { actions as tokenActions } from './token' import token, { actions as tokenActions, selectToken } from './token'
import user, { actions as userActions } from './user' import user, { actions as userActions, selectUser } from './user'
import form, { actions as formActions } from './form' import form, { actions as formActions, selectors as formSelectors } from './form'
import { loadStore, backupStore } from './storageHelper' import { loadStore, backupStore } from './storageHelper'
const preloadedState = loadStore() const preloadedState = loadStore()
@ -12,6 +12,7 @@ export const actions = {
form: formActions, form: formActions,
reset: createAction('reset'), reset: createAction('reset'),
} }
export const selectors = { selectToken, selectUser, ...formSelectors }
export type RootState = ReturnType<typeof reducer> export type RootState = ReturnType<typeof reducer>
export const store = configureStore({ export const store = configureStore({
reducer, reducer,

View File

@ -10,6 +10,7 @@ export const backupStore = (store: ToolkitStore) => {
localStorage.setItem(storageKey, serializedState) localStorage.setItem(storageKey, serializedState)
} catch (err) { } catch (err) {
// nothing to do // nothing to do
console.error('Error while saving state', err)
} }
} }

View File

@ -1,4 +1,4 @@
import { createSlice } from '@reduxjs/toolkit' import { createSlice, createSelector } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit' import type { PayloadAction } from '@reduxjs/toolkit'
import type { AuthToken } from '../api' import type { AuthToken } from '../api'
@ -17,4 +17,8 @@ const authTokenSlice = createSlice({
}) })
export const { actions } = authTokenSlice export const { actions } = authTokenSlice
export const selectToken = createSelector(
(state: { token: AuthToken }) => state.token,
(token) => token
)
export default authTokenSlice.reducer export default authTokenSlice.reducer

View File

@ -1,9 +1,9 @@
import { createSlice } from '@reduxjs/toolkit' import { createSlice, createSelector } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit' import type { PayloadAction } from '@reduxjs/toolkit'
import type { User } from '../api' import { User } from '../api'
import { getClientLocale, getClientTimezone } from '../locales' import { getClientLocale, getClientTimezone } from '../locales'
const initialState: User = { const initialState: User.User = {
id: undefined, id: undefined,
username: null, username: null,
email: '', email: '',
@ -34,7 +34,7 @@ const userSlice = createSlice({
name: 'user', name: 'user',
initialState, initialState,
reducers: { reducers: {
update(state, action: PayloadAction<Partial<User>>) { update(state, action: PayloadAction<Partial<User.User>>) {
return { ...state, ...action.payload } return { ...state, ...action.payload }
}, },
}, },
@ -42,4 +42,8 @@ const userSlice = createSlice({
}) })
export const { actions } = userSlice export const { actions } = userSlice
export const selectUser = createSelector(
(state: { user: User.User }) => state.user,
(user) => user
)
export default userSlice.reducer export default userSlice.reducer