feat: add api provider

This commit is contained in:
Aidar Shaikhutdin @makeweb.space 2023-05-10 15:53:21 +06:00
parent 9bfd15819e
commit 3e9cb7f233
23 changed files with 516 additions and 158 deletions

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

@ -0,0 +1,24 @@
import { createContext } from 'react'
import { createMethod } from './utils'
import {
User,
Auras,
Elements,
AuthTokens,
Assets,
AssetCategories,
DailyForecasts,
} from './resources'
export interface ApiContextValue {
auth: ReturnType<typeof createMethod<AuthTokens.Payload, AuthTokens.Response>>
getElements: ReturnType<typeof createMethod<Elements.Payload, Elements.Response>>
getUser: ReturnType<typeof createMethod<User.GetPayload, User.Response>>
updateUser: ReturnType<typeof createMethod<User.PatchPayload, User.Response>>
getAssets: ReturnType<typeof createMethod<Assets.Payload, Assets.Response>>
getAssetCategories: ReturnType<typeof createMethod<AssetCategories.Payload, AssetCategories.Response>>
getDailyForecasts: ReturnType<typeof createMethod<DailyForecasts.Payload, DailyForecasts.Response>>
getAuras: ReturnType<typeof createMethod<Auras.Payload, Auras.Response>>
}
export const ApiContext = createContext<ApiContextValue>({} as ApiContextValue)

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

@ -0,0 +1,24 @@
import { ApiContextValue } from './ApiContext'
import { createMethod } from './utils'
import {
User,
Auras,
Elements,
AuthTokens,
Assets,
AssetCategories,
DailyForecasts,
} from './resources'
export function createApi(): ApiContextValue {
return {
auth: createMethod<AuthTokens.Payload, AuthTokens.Response>(AuthTokens.createRequest),
getElements: createMethod<Elements.Payload, Elements.Response>(Elements.createRequest),
getUser: createMethod<User.GetPayload, User.Response>(User.createGetRequest),
updateUser: createMethod<User.PatchPayload, User.Response>(User.createPatchRequest),
getAssets: createMethod<Assets.Payload, Assets.Response>(Assets.createRequest),
getAssetCategories: createMethod<AssetCategories.Payload, AssetCategories.Response>(AssetCategories.createRequest),
getDailyForecasts: createMethod<DailyForecasts.Payload, DailyForecasts.Response>(DailyForecasts.createRequest),
getAuras: createMethod<Auras.Payload, Auras.Response>(Auras.createRequest),
}
}

6
src/api/index.ts Normal file
View File

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

View File

@ -0,0 +1,28 @@
import routes from "../../routes"
import { AuthToken } from "../types"
import { getAuthHeaders } from "../utils"
export interface Payload {
locale: string
token: AuthToken
}
export interface Response {
asset_categories: AssetCategory[]
}
export interface AssetCategory {
id: number
name: string
slug: string
path: string
}
export const createRequest = ({ locale, token }: Payload): Request => {
const url = new URL(routes.server.assetCategories())
const query = new URLSearchParams({ locale })
url.search = query.toString()
return new Request(url, { method: 'GET', headers: getAuthHeaders(token) })
}

View File

@ -0,0 +1,50 @@
import routes from "../../routes"
import { AuthToken } from "../types"
import { AssetCategory } from "./AssetCategories"
import { getAuthHeaders } from "../utils"
export interface Payload {
token: AuthToken
category: string
page?: number
perPage?: number
}
export interface Response {
asset_category: AssetCategory
assets: Asset[]
}
export interface Asset {
id: string
url: string
asset_data: AssetData
}
export interface AssetData {
id: string
storage: string
metadata: AssetMetadata
}
export interface AssetMetadata {
size: number
width: number
height: number
filename: string
mime_type: string
}
export const createRequest = ({ token, category, page, perPage }: Payload): Request => {
const url = new URL(routes.server.assets(category))
const query = new URLSearchParams()
if (page && perPage) {
query.append('page', page.toString())
query.append('per_page', perPage.toString())
}
url.search = query.toString()
return new Request(url, { method: 'GET', headers: getAuthHeaders(token) })
}

View File

@ -0,0 +1,54 @@
import routes from "../../routes"
import { AuthToken } from "../types"
import { getAuthHeaders } from "../utils"
export interface Payload {
token: AuthToken
}
export interface Response {
user_aura: UserAura
}
export interface UserAura {
updated_at: string
viewed_at: string | null
aurapic: string | null
stats: UserAuraStat[]
config: UserAuraConfig
}
export interface UserAuraStat {
stat: string
value: number
label: string
}
export interface UserAuraConfig {
birthRate: number
imageURL: string
particleIntensity: number
particleIntensityVariation: number
particleLifeSpan: number
particleSize: number
particleSizeVariation: number
particleVelocity: number
speedFactor: number
spreadingAngle: number
stretchFactor: number
holes: [{
from: number
to: number
}]
animations: {
[key: string]: {
keyTimes: number[]
values: number[]
}
}
}
export const createRequest = ({ token }: Payload): Request => {
const url = new URL(routes.server.auras())
return new Request(url, { method: 'GET', headers: getAuthHeaders(token) })
}

View File

@ -0,0 +1,37 @@
import routes from "../../routes"
import { AuthToken } from "../types"
import { User } from "./User"
import { getBaseHeaders } from "../utils"
export interface Payload {
email: string
timezone: string
locale: string
}
export interface Response {
auth: {
token: AuthToken
payload: JwtPayload
user: User
}
}
export interface JwtPayload {
sub: number
email: string
loc: string
tz: number
state: string
iat: number
exp: number
jti: string
type: string
iss: string
}
export const createRequest = ({ locale, timezone, email }: Payload): Request => {
const url = new URL(routes.server.token())
const body = JSON.stringify({ auth: { locale, timezone, email }})
return new Request(url, { method: 'POST', headers: getBaseHeaders(), body })
}

View File

@ -0,0 +1,35 @@
import routes from "../../routes"
import { getBaseHeaders } from "../utils"
export interface Payload {
locale: string
}
export interface Response {
data: {
variant: string
groups: ElementGroup[]
}
}
export interface ElementGroup {
name: string
items: ElementGroupItem[]
}
export interface ElementGroupItem {
type: string
href: string
title: string
url_slug: string
[key: string]: string
}
export const createRequest = ({ locale }: Payload): Request => {
const url = new URL(routes.server.elements())
const query = new URLSearchParams({ locale })
url.search = query.toString()
return new Request(url, { method: 'GET', headers: getBaseHeaders() })
}

123
src/api/resources/User.ts Normal file
View File

@ -0,0 +1,123 @@
import routes from "../../routes"
import { AuthToken } from "../types"
import { getAuthHeaders } from "../utils"
export interface GetPayload {
token: AuthToken
}
export interface PatchPayload extends GetPayload {
user: Partial<UserPatch>
}
export interface Response {
user: User
meta?: {
links: {
self: string
}
}
}
export interface UserPatch {
locale: string
timezone: string
profile_attributes: Partial<Pick<UserProfile, 'gender' | 'full_name' | 'relationship_status' | 'birthday'> & {
birthplace_id: null
birthplace_attributes: {
address?: string
coords?: string
},
remote_userpic_url: string
}>
daily_push_subs_attributes: [{
time: string
daily_push_id: 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
}
export const createGetRequest = ({ token }: GetPayload): Request => {
const url = new URL(routes.server.user())
return new Request(url, { method: 'GET', headers: getAuthHeaders(token) })
}
export const createPatchRequest = ({ token, user }: PatchPayload): Request => {
const url = new URL(routes.server.user())
const body = JSON.stringify({ user })
return new Request(url, { method: 'PATCH', headers: getAuthHeaders(token), body })
}

View File

@ -0,0 +1,30 @@
import routes from "../../routes"
import { AuthToken } from "../types"
import { getAuthHeaders } from "../utils"
export interface Payload {
token: AuthToken
}
export interface Response {
user_daily_forecast: UserDailyForecast
}
export interface UserDailyForecast {
day: string
updated_at: string
viewed_at: string | null
sign_id: number
forecasts: Forecast[]
}
export interface Forecast {
category_name: string
category: string
body: string
}
export const createRequest = ({ token }: Payload): Request => {
const url = new URL(routes.server.dailyForecasts())
return new Request(url, { method: 'GET', headers: getAuthHeaders(token) })
}

View File

@ -0,0 +1,7 @@
export * as Assets from './Assets'
export * as AssetCategories from './AssetCategories'
export * as User from './User'
export * as DailyForecasts from './UserDailyForecasts'
export * as Auras from './Auras'
export * as Elements from './Elements'
export * as AuthTokens from './AuthTokens'

18
src/api/types.ts Normal file
View File

@ -0,0 +1,18 @@
export interface AuthError {
title: string
detail: string
source: {
pointer: string
parameter: string
}
}
export interface ApiError {
base: string[]
}
export interface ErrorResponse {
errors: AuthError[] | ApiError
}
export type AuthToken = string

4
src/api/useApi.ts Normal file
View File

@ -0,0 +1,4 @@
import { useContext } from 'react'
import { ApiContext } from './ApiContext'
export const useApi = () => useContext(ApiContext)

28
src/api/utils.ts Normal file
View File

@ -0,0 +1,28 @@
import { AuthToken } from "./types"
import { ErrorResponse } from "./types"
export function createMethod<P, R>(createRequest: (payload: P) => Request) {
return async (payload: P): Promise<R> => {
const request = createRequest(payload)
const response = await fetch(request)
const data: R & ErrorResponse = await response.json()
if (response.ok) return data
const error = Array.isArray(data.errors) ? data.errors[0]?.title : data.errors.base[0]
throw new Error(error)
}
}
export function getBaseHeaders(): Headers {
return new Headers({
'Content-Type': 'application/json',
})
}
export function getAuthHeaders(token: AuthToken): Headers {
const headers = getBaseHeaders()
headers.append('Authorization', `Bearer ${token}`)
return headers
}

View File

@ -1,5 +1,5 @@
import { createContext } from 'react'
import { AuthToken, User } from '../types'
import { AuthToken, User, SignUpPayload } from '../api'
export interface AuthContextValue {
user: User | null
@ -9,18 +9,4 @@ export interface AuthContextValue {
addBirthday: (birthday: string, token: AuthToken) => 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)
export const AuthContext = createContext<AuthContextValue>({} as AuthContextValue)

View File

@ -1,57 +1,23 @@
import { useDispatch, useSelector } from 'react-redux'
import { AuthContext, SignUpPayload } from './AuthContext'
import { RootState, actions } from '../store'
import { AuthToken } from '../types'
import routes from '../routes'
import { useApi, AuthToken, SignUpPayload } from '../api'
import { AuthContext } from './AuthContext'
export function AuthProvider({ children }: React.PropsWithChildren<unknown>): JSX.Element {
const api = useApi()
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<AuthToken> => {
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))
return token
} 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 signUp = async (payload: SignUpPayload): Promise<AuthToken> => {
const { auth: { token, user } } = await api.auth(payload)
dispatch(actions.token.update(token))
dispatch(actions.user.update(user))
return token
}
const addBirthday = async (birthday: string, token: AuthToken): 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 payload = { user: { profile_attributes: { birthday } }, token }
const { user } = await api.updateUser(payload)
dispatch(actions.user.update(user))
}
const logout = () => dispatch(actions.reset())
const auth = { signUp, logout, addBirthday, token, user: user.id ? user : null }

View File

@ -1,10 +1,13 @@
import './styles.css'
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
color?: 'black' | 'blue'
};
function MainButton({ className, children, ...props}: ButtonProps): JSX.Element {
const combinedClassNames = ['main-btn', className].filter(Boolean).join(' ')
function MainButton({ className, children, color, ...props}: ButtonProps): JSX.Element {
const colorClass = color ? `main-btn--${color}` : 'main-btn--black'
const combinedClassNames = ['main-btn', colorClass, className].filter(Boolean).join(' ')
return <button className={combinedClassNames} {...props}>{children}</button>
}

View File

@ -1,9 +1,7 @@
.main-btn {
align-items: center;
background: #000;
border: none;
border-radius: 20px;
color: #fff;
cursor: pointer;
display: flex;
font-size: 18px;
@ -22,3 +20,13 @@
.main-btn:disabled {
opacity: 50%;
}
.main-btn--black {
background: #000;
color: #fff;
}
.main-btn--blue {
background: #306ed7;
color: #fff;
}

View File

@ -1,30 +1,30 @@
import React from 'react'
import { BrowserRouter } from 'react-router-dom'
import i18next from 'i18next'
import { BrowserRouter } from 'react-router-dom'
import { I18nextProvider, initReactI18next } from 'react-i18next'
import { Provider } from 'react-redux'
import { store } from './store'
import resources from './locales'
import routes from './routes'
import { AuthProvider } from './auth'
import { ApiContext, createApi } from './api'
import resources, { getClientLocale } from './locales'
import App from './components/App'
const init = async () => {
const response = await fetch(routes.server.translations())
const data = await response.json()
const defaultLanguage = data.meta.locale
// TODO: add translations from data.translations
const api = createApi()
const lng = getClientLocale()
const i18nextInstance = i18next.createInstance()
const options = { lng: defaultLanguage, resources }
const options = { lng, resources }
await i18nextInstance.use(initReactI18next).init(options)
return (
<React.StrictMode>
<I18nextProvider i18n={i18nextInstance}>
<Provider store={store}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
<ApiContext.Provider value={api}>
<AuthProvider>
<App />
</AuthProvider>
</ApiContext.Provider>
</BrowserRouter>
</Provider>
</I18nextProvider>

View File

@ -10,13 +10,17 @@ const routes = {
emailEnter: () => [host, 'email'].join('/'),
subscription: () => [host, 'subscription'].join('/'),
createProfile: () => [host, 'profile', 'create'].join('/'),
paymentMethod: () => [host, 'payment', 'method'].join('/'),
wallpaper: () => [host, 'wallpaper'].join('/'),
},
server: {
locales: () => [apiHost, prefix, 'locales.json'].join('/'),
translations: () => [apiHost, prefix, 't.json'].join('/'),
elements: () => [apiHost, prefix, 'elements.json'].join('/'),
user: () => [apiHost, prefix, 'user.json'].join('/'),
token: () => [apiHost, prefix, 'auth', 'token.json'].join('/'),
assets: (category: string) => [apiHost, prefix, 'assets', 'categories', `${category}.json`].join('/'),
assetCategories: () => [apiHost, prefix, 'assets', 'categories.json'].join('/'),
dailyForecasts: () => [apiHost, prefix, 'user', 'daily_forecasts.json'].join('/'),
auras: () => [apiHost, prefix, 'user', 'aura.json'].join('/'),
},
}

View File

@ -1,6 +1,6 @@
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import type { AuthToken } from '../types'
import type { AuthToken } from '../api'
const initialState: AuthToken = ''

View File

@ -1,6 +1,6 @@
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import type { User } from '../types'
import type { User } from '../api'
import { getClientLocale, getClientTimezone } from '../locales'
const initialState: User = {

View File

@ -12,80 +12,3 @@ export interface SignupForm {
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
}