Compare commits
2 Commits
61878c20ba
...
79f1ee371b
| Author | SHA1 | Date | |
|---|---|---|---|
| 79f1ee371b | |||
| 0f88a68c59 |
@@ -1,11 +1,13 @@
|
|||||||
import { Navigate, Outlet } from 'react-router-dom'
|
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||||
import { useIsAuthenticated } from '@features/auth'
|
import { useIsAuthenticated } from '@features/auth'
|
||||||
import { ROUTES } from '@shared/config/routes'
|
import { ROUTES } from '@shared/config/routes'
|
||||||
|
|
||||||
export function GuestRoute() {
|
export function GuestRoute() {
|
||||||
const { isAuthenticated, isLoading } = useIsAuthenticated()
|
const { isAuthenticated, isLoading } = useIsAuthenticated()
|
||||||
|
const location = useLocation()
|
||||||
|
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? ROUTES.WALLET
|
||||||
|
|
||||||
if (isLoading) return null
|
if (isLoading) return null
|
||||||
if (isAuthenticated) return <Navigate to={ROUTES.WALLET} replace />
|
if (isAuthenticated) return <Navigate to={from} replace />
|
||||||
return <Outlet />
|
return <Outlet />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export function RouterProvider() {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path={ROUTES.CONVERTER} element={<ConverterPage />} />
|
<Route path={ROUTES.CONVERTER} element={<ConverterPage />} />
|
||||||
|
<Route path={ROUTES.WALLET} element={<WalletPage />} />
|
||||||
|
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route path={ROUTES.WALLET} element={<WalletPage />} />
|
|
||||||
<Route path={ROUTES.SWAP} element={<SwapPage />} />
|
<Route path={ROUTES.SWAP} element={<SwapPage />} />
|
||||||
<Route path={ROUTES.PROFILE} element={<ProfilePage />} />
|
<Route path={ROUTES.PROFILE} element={<ProfilePage />} />
|
||||||
<Route path={ROUTES.SEED_PHRASE} element={<SeedPhrasePage />} />
|
<Route path={ROUTES.SEED_PHRASE} element={<SeedPhrasePage />} />
|
||||||
|
|||||||
36
src/features/wallet/api/walletApi.ts
Normal file
36
src/features/wallet/api/walletApi.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { api } from '@shared/api/base'
|
||||||
|
|
||||||
|
export type Chain = 'ETH' | 'BSC' | 'BTC' | 'TRX' | 'SOL'
|
||||||
|
|
||||||
|
export interface FormattedAmount {
|
||||||
|
raw: string
|
||||||
|
formatted: string
|
||||||
|
decimals: number
|
||||||
|
usdPrice: number
|
||||||
|
usdValue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletBalanceData {
|
||||||
|
chain: Chain
|
||||||
|
address: string
|
||||||
|
native: FormattedAmount
|
||||||
|
tokens: Record<string, FormattedAmount>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceEntry {
|
||||||
|
usd: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHAINS: Chain[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL']
|
||||||
|
|
||||||
|
export async function getWalletBalance(chain: Chain): Promise<WalletBalanceData> {
|
||||||
|
const res = await api.get<{ success: boolean; data: WalletBalanceData }>(`/api/wallets/${chain}/balance`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPrices(symbols: string[]): Promise<Record<string, PriceEntry>> {
|
||||||
|
const res = await api.get<{ success: boolean; data: Record<string, PriceEntry> }>(
|
||||||
|
`/api/prices?symbols=${symbols.join(',')}`
|
||||||
|
)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
3
src/features/wallet/index.ts
Normal file
3
src/features/wallet/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { useAllWalletBalances, usePrices } from './model/useWalletData'
|
||||||
|
export type { Chain, FormattedAmount, WalletBalanceData, PriceEntry } from './api/walletApi'
|
||||||
|
export { CHAINS } from './api/walletApi'
|
||||||
20
src/features/wallet/model/useWalletData.ts
Normal file
20
src/features/wallet/model/useWalletData.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQuery, useQueries } from '@tanstack/react-query'
|
||||||
|
import { getWalletBalance, getPrices, CHAINS } from '../api/walletApi'
|
||||||
|
|
||||||
|
export function useAllWalletBalances() {
|
||||||
|
return useQueries({
|
||||||
|
queries: CHAINS.map(chain => ({
|
||||||
|
queryKey: ['wallet', 'balance', chain],
|
||||||
|
queryFn: () => getWalletBalance(chain),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrices(symbols: string[]) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['wallet', 'prices', symbols.join(',')],
|
||||||
|
queryFn: () => getPrices(symbols),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -68,7 +68,6 @@ export function Converter() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.swapBtn}
|
className={styles.swapBtn}
|
||||||
onClick={c.toggleMode}
|
|
||||||
aria-label="Поменять направление"
|
aria-label="Поменять направление"
|
||||||
>
|
>
|
||||||
<svg width={16} height={16} viewBox="0 0 16 16" fill="none">
|
<svg width={16} height={16} viewBox="0 0 16 16" fill="none">
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
|
||||||
import { loginStart, loginComplete, AUTH_QUERY_KEY } from '@features/auth'
|
import { loginStart, loginComplete, AUTH_QUERY_KEY } from '@features/auth'
|
||||||
import { tokenStore } from '@shared/api/tokenStore'
|
import { tokenStore } from '@shared/api/tokenStore'
|
||||||
import { clearCsrfCache } from '@shared/api/csrf'
|
import { clearCsrfCache } from '@shared/api/csrf'
|
||||||
import { ROUTES } from '@shared/config/routes'
|
|
||||||
import type { ApiErrorResponse } from '@shared/api/types'
|
import type { ApiErrorResponse } from '@shared/api/types'
|
||||||
|
|
||||||
function extractErrorMessage(error: unknown): string {
|
function extractErrorMessage(error: unknown): string {
|
||||||
@@ -17,10 +15,7 @@ export function useLoginForm() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [verificationCode, setVerificationCode] = useState('')
|
const [verificationCode, setVerificationCode] = useState('')
|
||||||
const [codeSent, setCodeSent] = useState(false)
|
const [codeSent, setCodeSent] = useState(false)
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? ROUTES.WALLET
|
|
||||||
|
|
||||||
const startMutation = useMutation({
|
const startMutation = useMutation({
|
||||||
mutationFn: loginStart,
|
mutationFn: loginStart,
|
||||||
@@ -33,7 +28,6 @@ export function useLoginForm() {
|
|||||||
clearCsrfCache()
|
clearCsrfCache()
|
||||||
tokenStore.set(access_token)
|
tokenStore.set(access_token)
|
||||||
queryClient.setQueryData(AUTH_QUERY_KEY, access_token)
|
queryClient.setQueryData(AUTH_QUERY_KEY, access_token)
|
||||||
navigate(from, { replace: true })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
69
src/widgets/token-table/model/useTokenRows.ts
Normal file
69
src/widgets/token-table/model/useTokenRows.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useAllWalletBalances, usePrices } from '@features/wallet'
|
||||||
|
import { CHAINS } from '@features/wallet'
|
||||||
|
import { TOKENS } from './tokens'
|
||||||
|
import type { WalletBalanceData } from '@features/wallet'
|
||||||
|
|
||||||
|
const PRICE_SYMBOLS = ['BTC', 'ETH', 'SOL', 'TRX', 'ARB']
|
||||||
|
|
||||||
|
type ChainSource =
|
||||||
|
| { chain: 'BTC' | 'ETH' | 'SOL' | 'TRX'; type: 'native' }
|
||||||
|
| { chain: 'ETH'; type: 'token'; symbol: string }
|
||||||
|
|
||||||
|
const CHAIN_MAP: Record<string, ChainSource> = {
|
||||||
|
BTC: { chain: 'BTC', type: 'native' },
|
||||||
|
ETH: { chain: 'ETH', type: 'native' },
|
||||||
|
SOL: { chain: 'SOL', type: 'native' },
|
||||||
|
TRX: { chain: 'TRX', type: 'native' },
|
||||||
|
ARB: { chain: 'ETH', type: 'token', symbol: 'ARB' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsd(value: number): string {
|
||||||
|
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(value: number): string {
|
||||||
|
if (value >= 1) {
|
||||||
|
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
|
}
|
||||||
|
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 })}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokenRows() {
|
||||||
|
const balanceResults = useAllWalletBalances()
|
||||||
|
const pricesQuery = usePrices(PRICE_SYMBOLS)
|
||||||
|
|
||||||
|
const isLoading = balanceResults.some(r => r.isLoading) || pricesQuery.isLoading
|
||||||
|
|
||||||
|
const balanceByChain: Record<string, WalletBalanceData> = {}
|
||||||
|
CHAINS.forEach((chain, i) => {
|
||||||
|
const data = balanceResults[i].data
|
||||||
|
if (data) balanceByChain[chain] = data
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = TOKENS.map(t => {
|
||||||
|
const src = CHAIN_MAP[t.ticker]
|
||||||
|
const chainData = src ? balanceByChain[src.chain] : undefined
|
||||||
|
|
||||||
|
let bal = t.bal
|
||||||
|
let usd = t.usd
|
||||||
|
|
||||||
|
if (chainData) {
|
||||||
|
const amount =
|
||||||
|
src.type === 'native'
|
||||||
|
? chainData.native
|
||||||
|
: chainData.tokens[src.type === 'token' ? src.symbol : t.ticker]
|
||||||
|
|
||||||
|
if (amount) {
|
||||||
|
bal = amount.formatted
|
||||||
|
usd = formatUsd(amount.usdValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceEntry = pricesQuery.data?.[t.ticker]
|
||||||
|
const price = priceEntry ? formatPrice(priceEntry.usd) : t.price
|
||||||
|
|
||||||
|
return { ...t, price, bal, usd }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { rows, isLoading }
|
||||||
|
}
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { TOKENS } from '../model/tokens'
|
import { useTokenRows } from '../model/useTokenRows'
|
||||||
import styles from './TokenTable.module.css'
|
import styles from './TokenTable.module.css'
|
||||||
|
|
||||||
export function TokenTable() {
|
export function TokenTable() {
|
||||||
const [favs, setFavs] = useState<boolean[]>(TOKENS.map((t) => t.fav))
|
const { rows, isLoading } = useTokenRows()
|
||||||
|
const [favs, setFavs] = useState<boolean[]>(() => rows.map((t) => t.fav))
|
||||||
|
|
||||||
function toggleFav(i: number) {
|
function toggleFav(i: number) {
|
||||||
setFavs((prev) => prev.map((v, idx) => (idx === i ? !v : v)))
|
setFavs((prev) => prev.map((v, idx) => (idx === i ? !v : v)))
|
||||||
@@ -17,7 +18,7 @@ export function TokenTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.wrap}>
|
<div className={`${styles.wrap} ${isLoading ? styles.loading : ''}`}>
|
||||||
{/* Desktop table */}
|
{/* Desktop table */}
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -31,7 +32,7 @@ export function TokenTable() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{TOKENS.map((t, i) => (
|
{rows.map((t, i) => (
|
||||||
<tr key={t.ticker}>
|
<tr key={t.ticker}>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
@@ -81,7 +82,7 @@ export function TokenTable() {
|
|||||||
|
|
||||||
{/* Mobile card list */}
|
{/* Mobile card list */}
|
||||||
<div className={styles.mobileList}>
|
<div className={styles.mobileList}>
|
||||||
{TOKENS.map((t, i) => (
|
{rows.map((t, i) => (
|
||||||
<div key={t.ticker} className={styles.card}>
|
<div key={t.ticker} className={styles.card}>
|
||||||
<button
|
<button
|
||||||
className={`${styles.star} ${favs[i] ? styles.starOn : ''}`}
|
className={`${styles.star} ${favs[i] ? styles.starOn : ''}`}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user