feat: implement operator apis

This commit is contained in:
jinhojang6 2024-10-23 00:29:37 +09:00
parent d03ae125d2
commit 562789ec78
No known key found for this signature in database
GPG Key ID: 1762F21FE8B543F8
18 changed files with 297 additions and 115 deletions

View File

@ -0,0 +1,43 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../../common/api'
const useQueryOptions = {
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
retry: 1,
}
interface Props {
walletAddress: string | null
setUserInfo?: (userInfo: any) => void
}
const useGetUserInfo = ({ walletAddress, setUserInfo }: Props) => {
const queryKey = ['getUserInfo']
const queryClient = useQueryClient()
const fetchData = async () => {
const token = sessionStorage.getItem('accessToken')
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
return await api.get('/user').then((res) => {
setUserInfo && setUserInfo(res.data)
return res.data
})
}
const updateCache = (newData: any) => {
queryClient.setQueryData(queryKey, newData)
}
const response = useQuery({
queryKey: queryKey,
queryFn: fetchData,
enabled: !!walletAddress,
...useQueryOptions,
})
return { ...response, updateCache }
}
export default useGetUserInfo

View File

@ -4,6 +4,7 @@ import { api } from '../../common/api'
interface StakeRequest {
operator_id: string
setIsStaked: (isStaked: boolean) => void
}
interface StakeResponse {
@ -13,11 +14,35 @@ interface StakeResponse {
const postStake = async ({
operator_id,
setIsStaked,
}: StakeRequest): Promise<StakeResponse> => {
const response: AxiosResponse<StakeResponse> = await api.post(
`/operators/${operator_id}/stake`,
)
return response.data
try {
const token = sessionStorage.getItem('accessToken')
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
const response: AxiosResponse<StakeResponse> = await api.post(
`/user/operators/${operator_id}/stake`,
)
setIsStaked(true)
return response.data
} catch (error: any) {
if (error.response) {
if (error.response.status === 403) {
alert(
'Access Denied: You do not have permission to perform this action.',
)
throw new Error(
'Access Denied: You do not have permission to perform this action.',
)
}
if (error.response.status === 404) {
alert('Operator not found. Please check the operator ID.')
throw new Error('Operator not found. Please check the operator ID.')
}
}
throw new Error('An unexpected error occurred. Please try again later.')
}
}
export const useStakeOperator = () => {

View File

@ -4,6 +4,7 @@ import { api } from '../../common/api'
interface UnstakeRequest {
operator_id: string
setIsStaked: (isStaked: boolean) => void
}
interface UnstakeResponse {
@ -13,11 +14,35 @@ interface UnstakeResponse {
const postUnstake = async ({
operator_id,
setIsStaked,
}: UnstakeRequest): Promise<UnstakeResponse> => {
const response: AxiosResponse<UnstakeResponse> = await api.post(
`/operators/${operator_id}/unstake`,
)
return response.data
try {
const token = sessionStorage.getItem('accessToken')
api.defaults.headers.common['Authorization'] = `Bearer ${token}`
const response: AxiosResponse<UnstakeResponse> = await api.post(
`/user/operators/${operator_id}/unstake`,
)
setIsStaked(false)
return response.data
} catch (error: any) {
if (error.response) {
if (error.response.status === 403) {
alert(
'Access Denied: You do not have permission to perform this action.',
)
throw new Error(
'Access Denied: You do not have permission to perform this action.',
)
}
if (error.response.status === 404) {
alert('Operator not found. Please check the operator ID.')
throw new Error('Operator not found. Please check the operator ID.')
}
}
throw new Error('An unexpected error occurred. Please try again later.')
}
}
export const useUnstakeOperator = () => {

0
atoms/filter.ts Normal file
View File

3
atoms/userInfo.ts Normal file
View File

@ -0,0 +1,3 @@
import { atom } from 'jotai'
export const userInfo = atom<any>(null)

3
atoms/wallet.ts Normal file
View File

@ -0,0 +1,3 @@
import { atom } from 'jotai'
export const walletAddressAtom = atom<string | null>(null)

View File

@ -25,7 +25,7 @@ api.interceptors.response.use(
if (error.response?.status === 401) {
try {
const refreshToken = await localStorage.getItem('refreshToken')
const refreshToken = await sessionStorage.getItem('refreshToken')
await api
.post('/token/refresh', {
refreshToken,

View File

@ -1,9 +1,9 @@
import { ProcessedOperator } from '@/containers/Dashboard/DashboardContainer'
import styled from '@emotion/styled'
import Link from 'next/link'
import React, { useState } from 'react'
import { useStakeOperator } from '../../../../apis/operators/useStakeOperator'
import { useUnstakeOperator } from '../../../../apis/operators/useUnstakeOperator'
import { ProcessedOperator } from '../../../../types/operators'
interface OperatorCardProps {
operator: ProcessedOperator
@ -21,15 +21,13 @@ const OperatorCard: React.FC<OperatorCardProps> = ({
if (isStaked) {
unstake.mutate({
operator_id: operatorId,
setIsStaked,
})
setIsStaked(false)
} else {
stake.mutate({
operator_id: operatorId,
setIsStaked,
})
setIsStaked(true)
}
}
@ -41,14 +39,14 @@ const OperatorCard: React.FC<OperatorCardProps> = ({
<OperatorInfo>
<OperatorName>{operator.name}</OperatorName>
<PointsPerHour>
<Label>Per Hour</Label>
<Value>{operator.pointsPerHour} rp</Value>
<Label>XP/Block</Label>
<Value>{operator.pointsPerHour} XP</Value>
</PointsPerHour>
</OperatorInfo>
<Actions>
<ActionButton
onClick={() => handleStake(operator.id)}
isStaked={isStaked}
isStaked={!!isStaked}
>
{isStaked ? 'Unstake' : 'Stake'}
</ActionButton>
@ -64,6 +62,17 @@ const OperatorCard: React.FC<OperatorCardProps> = ({
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
color: rgb(var(--lsd-text-primary));
a {
display: flex;
}
`
const IconButton = styled.button`
background-color: transparent;
border-left: none;
@ -92,17 +101,6 @@ const Value = styled.div`
line-height: 20px;
`
const Container = styled.div`
display: flex;
flex-direction: column;
color: rgb(var(--lsd-text-primary));
a {
display: flex;
}
`
const OperatorImage = styled.img`
width: 100%;
aspect-ratio: 1;

View File

@ -1,7 +1,8 @@
import { breakpoints } from '@/configs/ui.configs'
import { ProcessedOperator } from '@/containers/Dashboard/DashboardContainer'
import styled from '@emotion/styled'
import Link from 'next/link'
import React from 'react'
import { ProcessedOperator } from '../../../../types/operators'
import OperatorCard from './OperatorCard'
interface OperatorGridProps {
@ -13,6 +14,13 @@ const OperatorGrid: React.FC<OperatorGridProps> = ({
isLoading,
data,
}: OperatorGridProps) => {
const stakedOperators = data?.filter((operator) => operator.isStaked)
const totalXpPerBlock = data?.reduce(
(acc, operator) => acc + (operator.pointsPerHour ?? 0),
0,
)
return (
<Container>
<Header>
@ -31,31 +39,45 @@ const OperatorGrid: React.FC<OperatorGridProps> = ({
<Stats>
<Stat>
<Label>Total Operators</Label>
<Value>7</Value>
<Value>{data?.length || 0}</Value>
</Stat>
<Stat>
<Label>Staked</Label>
<Value>6</Value>
<Value>{stakedOperators?.length || 0}</Value>
</Stat>
<Stat>
<Label>Unstaked</Label>
<Value>1</Value>
<Value>{data?.length - (stakedOperators?.length || 0)}</Value>
</Stat>
<Stat>
<Label>XP/Block</Label>
<Value>912</Value>
<Value>{totalXpPerBlock || 0}</Value>
</Stat>
</Stats>
<Grid>
{isLoading
? Array.from({ length: 12 }).map((_, index) => (
<PlaceholderCard key={index}>
<Placeholder />
</PlaceholderCard>
))
: data?.map((operator) => (
<OperatorCard key={operator.id} operator={operator} />
))}
{isLoading ? (
Array.from({ length: 12 }).map((_, index) => (
<PlaceholderCard key={index}>
<Placeholder />
</PlaceholderCard>
))
) : data?.length === 0 ? (
<AddOperator href="https://logos.co/exit" target="_blank">
<PlusIcon>
<img
src="/assets/plus.svg"
width={10}
height={10}
alt="Add Operator"
/>
</PlusIcon>
<span>Add Operator</span>
</AddOperator>
) : (
data?.map((operator) => (
<OperatorCard key={operator.id} operator={operator} />
))
)}
</Grid>
</Container>
)
@ -162,4 +184,26 @@ const Placeholder = styled.div`
opacity: 0.5;
`
const AddOperator = styled(Link)`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 24px;
width: 100%;
height: 258px;
padding: 24px;
span {
text-decoration: none;
color: white;
}
&:hover {
span {
text-decoration: underline;
}
}
`
export default OperatorGrid

View File

@ -1,6 +1,6 @@
import { defaultFilterState } from '@/states/filterState'
import { defaultFilterState, filterAtom } from '@/states/filterState'
import styled from '@emotion/styled'
import { atom, useAtom } from 'jotai'
import { useSetAtom } from 'jotai'
import React, { useEffect, useRef, useState } from 'react'
import Checkbox from './Checkbox'
@ -15,8 +15,6 @@ interface DropdownProps {
prefill?: string[]
}
const filterAtom = atom(defaultFilterState)
const Dropdown: React.FC<DropdownProps> = ({
title,
options,
@ -30,7 +28,7 @@ const Dropdown: React.FC<DropdownProps> = ({
const dropdownRef = useRef<HTMLDivElement>(null)
const [filter, setFilter] = useAtom(filterAtom)
const setFilter = useSetAtom(filterAtom)
const defaultState = defaultFilterState

View File

@ -1,9 +1,9 @@
import { breakpoints } from '@/configs/ui.configs'
import { ProcessedOperator } from '@/containers/Dashboard/DashboardContainer'
import styled from '@emotion/styled'
import 'lazysizes'
import Link from 'next/link'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { ProcessedOperator } from '../../../../types/operators'
interface OperatorGridProps {
isLoading: boolean

View File

@ -6,7 +6,14 @@ import React from 'react'
interface NavbarProps {}
export const navItems = [
interface NavItem {
label: string
href: string
isDisabled?: boolean
isSoon?: boolean
}
export const navItems: NavItem[] = [
{
label: 'Countdown',
href: '/countdown',
@ -18,8 +25,8 @@ export const navItems = [
{
label: 'Dashboard',
href: '/dashboard',
isDisabled: true,
isSoon: true,
// isDisabled: true,
// isSoon: true,
},
// {
// label: 'Leaderboard',

View File

@ -1,6 +1,8 @@
import { truncateString } from '@/utils/general.utils'
import styled from '@emotion/styled'
import { useAtom } from 'jotai'
import React, { useEffect, useRef, useState } from 'react'
import { walletAddressAtom } from '../../../atoms/wallet'
import { api } from '../../../common/api'
import { getMEAddressAndSignature } from './magicEden'
import { getOKXAddressAndSignature } from './okx'
@ -17,7 +19,7 @@ const options = [
const Dropdown: React.FC = () => {
const [isExpanded, setIsExpanded] = useState(false)
const [walletAddress, setWalletAddress] = useState<string | null>(null)
const [walletAddress, setWalletAddress] = useAtom(walletAddressAtom)
const walletHandlers = {
okx: getOKXAddressAndSignature,
@ -40,7 +42,10 @@ const Dropdown: React.FC = () => {
setWalletAddress(address)
const response = await api.post('/token/pair', { address, signature })
console.log('Token pair response:', response)
const { access, refresh } = response.data.token
sessionStorage.setItem('accessToken', access)
sessionStorage.setItem('refreshToken', refresh)
}
} catch (error) {
console.error('Failed to connect or disconnect wallet:', error)

View File

@ -3,59 +3,34 @@ import { OperatorPanel } from '@/components/Dashboard/OperatorPanel'
import { ProgressBar } from '@/components/Dashboard/ProgressBar'
import { breakpoints } from '@/configs/ui.configs'
import styled from '@emotion/styled'
import { useAtomValue, useSetAtom } from 'jotai'
import React from 'react'
import useGetOperators from '../../../apis/operators/useGetOperators'
import { getRandomSubset, processOperators } from '../../../utils/operators'
import useGetUserInfo from '../../../apis/operators/useGetUserInfo'
import { userInfo } from '../../../atoms/userInfo'
import { walletAddressAtom } from '../../../atoms/wallet'
import { processMyOperators } from '../../../utils/operators'
export type DashboardPageProps = React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>
export interface Operator {
id: number
name: string
archetype: string
image_400_url: string
image_400_jpeg_url: string
comp: string
background: string
skin: string
helmet: string
jacket: string
}
export interface Group {
id: number
name: string
operators: Operator[]
}
export interface ProcessedOperator {
id: string
image: string
name: string
archetype: string
gif: string
comp: string
background: string
skin: string
helmet: string
jacket: string
pointsPerHour: number
isStaked: boolean
isPinned: boolean
}
const DashboardContainer: React.FC<DashboardPageProps> = ({
children,
...props
}) => {
const { data, isLoading } = useGetOperators()
const setUserInfo = useSetAtom(userInfo)
const processedOperators = processOperators(data as Group[], [])
const walletAddress = useAtomValue(walletAddressAtom)
const random20Operators = getRandomSubset(processedOperators, 20)
const { data: userInfoData, isLoading: isUserInfoLoading } = useGetUserInfo({
walletAddress,
setUserInfo,
})
// console.log('userInfoData', userInfoData)
const processedOperators = processMyOperators(userInfoData?.operators)
return (
<Container {...props}>
@ -70,7 +45,10 @@ const DashboardContainer: React.FC<DashboardPageProps> = ({
<DesktopProgressBar>
<ProgressBar progress={30} claimPosition={76} />
</DesktopProgressBar>
<OperatorGrid data={random20Operators} isLoading={isLoading} />
<OperatorGrid
data={processedOperators}
isLoading={isUserInfoLoading}
/>
</RightColumn>
</Wrapper>
</Container>

View File

@ -1,10 +1,11 @@
import { Dropdown } from '@/components/Dropdown'
import { OperatorGrid } from '@/components/Explore/OperatorGrid'
import { defaultFilterState, FilterState } from '@/states/filterState'
import { defaultFilterState, filterAtom } from '@/states/filterState'
import styled from '@emotion/styled'
import { atom, useAtom } from 'jotai'
import { useAtom } from 'jotai'
import React, { useCallback, useMemo } from 'react'
import useGetOperators from '../../../apis/operators/useGetOperators'
import {
ARCHETYPE,
BACKGROUND,
@ -17,19 +18,18 @@ import { processOperators, shuffleOperators } from '../../../utils/operators'
interface ExploreSectionProps {}
const filterAtom = atom<FilterState>(defaultFilterState)
const ExploreSection: React.FC<ExploreSectionProps> = () => {
const ExploreContainer: React.FC<ExploreSectionProps> = () => {
const { data, isLoading } = useGetOperators()
const [filter, setFilter] = useAtom(filterAtom)
const processedOperators = processOperators(
data as any,
filter.archetype.slice(),
filter?.archetype?.slice(),
)
const selectedOperators = useMemo(() => {
if (!processedOperators || !filter) return []
const filterCopied = JSON.parse(JSON.stringify(filter))
return processedOperators
@ -65,42 +65,42 @@ const ExploreSection: React.FC<ExploreSectionProps> = () => {
options={ARCHETYPE}
onSelectionChange={handleFilterChange}
filterType="archetype"
prefill={filter.archetype.slice()}
prefill={filter?.archetype.slice()}
/>
<Dropdown
title="Comp"
options={COMP}
onSelectionChange={handleFilterChange}
filterType="comp"
prefill={filter.comp.slice()}
prefill={filter?.comp.slice()}
/>
<Dropdown
title="Skin"
options={SKIN}
onSelectionChange={handleFilterChange}
filterType="skin"
prefill={filter.skin.slice()}
prefill={filter?.skin.slice()}
/>
<Dropdown
title="Helmet"
options={HELMET}
onSelectionChange={handleFilterChange}
filterType="helmet"
prefill={filter.helmet.slice()}
prefill={filter?.helmet.slice()}
/>
<Dropdown
title="Jacket"
options={JACKET}
onSelectionChange={handleFilterChange}
filterType="jacket"
prefill={filter.jacket.slice()}
prefill={filter?.jacket.slice()}
/>
<Dropdown
title="Background"
options={BACKGROUND}
onSelectionChange={handleFilterChange}
filterType="background"
prefill={filter.background.slice()}
prefill={filter?.background.slice()}
/>
<ResetAll onClick={handleResetAll}>
Reset All <img src="/assets/close-black.svg" alt="close-black" />
@ -181,4 +181,4 @@ const ResetAll = styled.button`
}
`
export default ExploreSection
export default ExploreContainer

View File

@ -27,7 +27,7 @@ export const defaultFilterState: FilterState = {
background: BACKGROUND,
}
const filterAtom = atom<FilterState>(defaultFilterState)
export const filterAtom = atom<FilterState>(defaultFilterState)
const wrapFilterState = (
filter: FilterState,

View File

@ -9,3 +9,38 @@ export type Archetype =
| 'Outlaw'
| 'Philosopher'
| 'Polymath'
export interface Operator {
id: number
name: string
archetype: string
image_400_url: string
image_400_jpeg_url: string
comp: string
background: string
skin: string
helmet: string
jacket: string
}
export interface Group {
id: number
name: string
operators: Operator[]
}
export interface ProcessedOperator {
id: string
image: string
name: string
archetype: string
gif: string
comp: string
background: string
skin: string
helmet: string
jacket: string
pointsPerHour?: number
isStaked?: boolean
isPinned?: boolean
}

View File

@ -1,14 +1,10 @@
import {
Group,
ProcessedOperator,
} from '@/containers/Dashboard/DashboardContainer'
import { Archetype } from '../types/operators'
import { Archetype, Group, ProcessedOperator } from '../types/operators'
export function processOperators(
data: Group[],
selectedArchetypes: Archetype[],
): ProcessedOperator[] {
const hasSelectedArchetypes = selectedArchetypes.length > 0
const hasSelectedArchetypes = selectedArchetypes?.length > 0
return data?.flatMap((group) => {
const groupArchetype = group.name.slice(0, -1) as Archetype
@ -23,7 +19,6 @@ export function processOperators(
image: operator.image_400_jpeg_url,
gif: operator.image_400_url,
name: operator.name,
pointsPerHour: Math.floor(Math.random() * 500),
comp: operator.comp,
background: operator.background,
skin: operator.skin,
@ -39,6 +34,29 @@ export function processOperators(
})
}
export function processMyOperators(operators: any[]) {
if (!operators) {
return []
}
return operators?.map((operator) => ({
id: operator.id.toString(),
arcgetypeId: operator.archetype_id,
image: operator.image_400_jpeg_url,
gif: operator.image_400_url,
name: operator.name,
pointsPerHour: operator.staking_xp_per_block,
comp: operator.comp,
background: operator.background,
skin: operator.skin,
helmet: operator.helmet,
jacket: operator.jacket,
archetype: operator.archetype,
isStaked: operator.is_currently_staked,
isPinned: operator.is_user_pinned,
}))
}
export function getRandomSubset<T>(array: T[], count: number): T[] {
const shuffled = array?.sort(() => 0.5 - Math.random())
return shuffled?.slice(0, count)