17.05.2026 funny
This commit is contained in:
1
dist/assets/index-BbQ5Ok1h.css
vendored
1
dist/assets/index-BbQ5Ok1h.css
vendored
File diff suppressed because one or more lines are too long
60
dist/assets/index-CbGm-SmX.js
vendored
60
dist/assets/index-CbGm-SmX.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-ChMX4U7G.css
vendored
Normal file
1
dist/assets/index-ChMX4U7G.css
vendored
Normal file
File diff suppressed because one or more lines are too long
60
dist/assets/index-DOZHm_HX.js
vendored
Normal file
60
dist/assets/index-DOZHm_HX.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ЭКСА — Ваш мост в мир цифровых активов</title>
|
||||
<script type="module" crossorigin src="/assets/index-CbGm-SmX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BbQ5Ok1h.css">
|
||||
<script type="module" crossorigin src="/assets/index-DOZHm_HX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ChMX4U7G.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -29,6 +29,7 @@ export function RouterProvider() {
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path={ROUTES.CONVERTER} element={<ConverterPage />} />
|
||||
<Route path={ROUTES.WALLET} element={<WalletPage />} />
|
||||
<Route path={ROUTES.WALLET_CHAIN} element={<WalletPage />} />
|
||||
<Route path={ROUTES.SWAP} element={<SwapPage />} />
|
||||
<Route path={ROUTES.BRIDGE} element={<BridgePage />} />
|
||||
<Route path={ROUTES.PROFILE} element={<ProfilePage />} />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMe } from '@features/auth'
|
||||
import { ROUTES } from '@shared/config/routes'
|
||||
import { usePortfolio, useCreateWallet } from '@features/wallet'
|
||||
import { usePortfolio, useCreateWallet, CHAINS, type Chain } from '@features/wallet'
|
||||
import { BalanceCard } from '@widgets/balance-card'
|
||||
import { TokenTable } from '@widgets/token-table'
|
||||
import { WalletHeader } from '@widgets/wallet-header'
|
||||
import { WalletChainTabs } from '@widgets/wallet-chain-tabs'
|
||||
import { Button } from '@shared/ui'
|
||||
import styles from './WalletPage.module.css'
|
||||
|
||||
@@ -13,6 +14,7 @@ export function WalletPage() {
|
||||
const { error: portfolioError } = usePortfolio()
|
||||
const { mutate: createWallet, isPending } = useCreateWallet()
|
||||
const navigate = useNavigate()
|
||||
const { chain: chainParam } = useParams<{ chain?: string }>()
|
||||
|
||||
const noWallet = (portfolioError as any)?.error?.includes('No wallets')
|
||||
|
||||
@@ -20,6 +22,11 @@ export function WalletPage() {
|
||||
if (isError) return <div className={styles.error}>Произошла ошибка. Попробуйте обновить страницу.</div>
|
||||
if (data && !data.kyc_verified) return <Navigate to={ROUTES.KYC} replace />
|
||||
|
||||
const upper = chainParam?.toUpperCase() as Chain | undefined
|
||||
const chain: Chain | undefined = upper && CHAINS.includes(upper) ? upper : undefined
|
||||
|
||||
if (!noWallet && !chain) return <Navigate to="/wallet/btc" replace />
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<WalletHeader />
|
||||
@@ -39,7 +46,8 @@ export function WalletPage() {
|
||||
) : (
|
||||
<>
|
||||
<BalanceCard />
|
||||
<TokenTable />
|
||||
<WalletChainTabs />
|
||||
<TokenTable chain={chain!} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const ROUTES = {
|
||||
HOME: '/',
|
||||
WALLET: '/wallet',
|
||||
WALLET_CHAIN: '/wallet/:chain',
|
||||
SWAP: "/swap",
|
||||
BRIDGE: "/bridge",
|
||||
LOGIN: '/login',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@shared/ui'
|
||||
import { useMe, useUploadAvatar } from '@features/auth'
|
||||
import styles from './ProfileAvatar.module.css'
|
||||
@@ -21,8 +21,14 @@ export function ProfileAvatar() {
|
||||
const { mutateAsync: upload, isPending } = useUploadAvatar()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [imgFailed, setImgFailed] = useState(false)
|
||||
|
||||
const avatarLink = data?.avatar_link ?? null
|
||||
const showImg = avatarLink && !imgFailed
|
||||
|
||||
useEffect(() => {
|
||||
setImgFailed(false)
|
||||
}, [avatarLink])
|
||||
|
||||
const openPicker = () => {
|
||||
if (isPending) return
|
||||
@@ -46,8 +52,17 @@ export function ProfileAvatar() {
|
||||
return (
|
||||
<div className={styles.col}>
|
||||
<div className={styles.avatar} onClick={openPicker}>
|
||||
{avatarLink ? (
|
||||
<img src={avatarLink} alt="avatar" className={styles.avatarImg} />
|
||||
{showImg ? (
|
||||
<img
|
||||
src={avatarLink}
|
||||
alt="avatar"
|
||||
className={styles.avatarImg}
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => {
|
||||
console.warn('[avatar] failed to load', avatarLink)
|
||||
setImgFailed(true)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
|
||||
76
src/widgets/token-table/model/useChainTokenRows.ts
Normal file
76
src/widgets/token-table/model/useChainTokenRows.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useWalletBalance } from '@features/wallet'
|
||||
import type { Chain } from '@features/wallet'
|
||||
import { TOKENS, type Token } from './tokens'
|
||||
|
||||
const NATIVE_TICKER: Record<Chain, string> = {
|
||||
BTC: 'BTC',
|
||||
ETH: 'ETH',
|
||||
SOL: 'SOL',
|
||||
TRX: 'TRX',
|
||||
BSC: 'BNB',
|
||||
}
|
||||
|
||||
const NATIVE_NAME: Record<Chain, string> = {
|
||||
BTC: 'Bitcoin',
|
||||
ETH: 'Ethereum',
|
||||
SOL: 'Solana',
|
||||
TRX: 'Tron',
|
||||
BSC: 'BNB',
|
||||
}
|
||||
|
||||
const DEFAULT_TOKEN_COLOR = '#2A2D3A'
|
||||
|
||||
function formatUsd(value: number | null | undefined): string {
|
||||
if (value == null) return '$—'
|
||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatPrice(value: number | null | undefined): string {
|
||||
if (value == null) return '$—'
|
||||
if (value >= 1) {
|
||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 })}`
|
||||
}
|
||||
|
||||
function lookupStatic(ticker: string): Token | undefined {
|
||||
return TOKENS.find((t) => t.ticker === ticker)
|
||||
}
|
||||
|
||||
export function useChainTokenRows(chain: Chain) {
|
||||
const { data, isLoading } = useWalletBalance(chain)
|
||||
|
||||
if (!data) return { rows: [] as Token[], isLoading }
|
||||
|
||||
const nativeTicker = NATIVE_TICKER[chain]
|
||||
const nativeStatic = lookupStatic(nativeTicker)
|
||||
|
||||
const nativeRow: Token = {
|
||||
ticker: nativeTicker,
|
||||
name: NATIVE_NAME[chain],
|
||||
logo: nativeStatic?.logo,
|
||||
color: nativeStatic?.color ?? DEFAULT_TOKEN_COLOR,
|
||||
price: formatPrice(data.native.usdPrice),
|
||||
change: 0,
|
||||
bal: data.native.formatted,
|
||||
usd: formatUsd(data.native.usdValue),
|
||||
fav: false,
|
||||
}
|
||||
|
||||
const tokenRows: Token[] = Object.entries(data.tokens).map(([symbol, amount]) => {
|
||||
const staticToken = lookupStatic(symbol)
|
||||
return {
|
||||
ticker: symbol,
|
||||
name: staticToken?.name ?? symbol,
|
||||
logo: staticToken?.logo,
|
||||
color: staticToken?.color ?? DEFAULT_TOKEN_COLOR,
|
||||
price: formatPrice(amount.usdPrice),
|
||||
change: 0,
|
||||
bal: amount.formatted,
|
||||
usd: formatUsd(amount.usdValue),
|
||||
fav: false,
|
||||
}
|
||||
})
|
||||
|
||||
return { rows: [nativeRow, ...tokenRows], isLoading }
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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', 'BSC']
|
||||
|
||||
type ChainSource =
|
||||
| { chain: 'BTC' | 'ETH' | 'SOL' | 'TRX' | 'BSC'; 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' },
|
||||
BSC: { chain: 'BSC', type: 'native' },
|
||||
}
|
||||
|
||||
function formatUsd(value: number | null | undefined): string {
|
||||
if (value == null) return '$—'
|
||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatPrice(value: number | null | undefined): string {
|
||||
if (value == null) return '$—'
|
||||
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 ?? t.bal
|
||||
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 }
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { useTokenRows } from '../model/useTokenRows'
|
||||
import { useChainTokenRows } from '../model/useChainTokenRows'
|
||||
import { SendModal, TICKER_TO_CHAIN, type Chain } from '@widgets/send-modal'
|
||||
import { ReceiveModal } from '@widgets/receive-modal'
|
||||
import styles from './TokenTable.module.css'
|
||||
|
||||
export function TokenTable() {
|
||||
const { rows, isLoading } = useTokenRows()
|
||||
const [favs, setFavs] = useState<boolean[]>(() => rows.map((t) => t.fav))
|
||||
interface Props {
|
||||
chain: Chain
|
||||
}
|
||||
|
||||
export function TokenTable({ chain }: Props) {
|
||||
const { rows, isLoading } = useChainTokenRows(chain)
|
||||
const [favs, setFavs] = useState<Record<string, boolean>>({})
|
||||
const [sendModal, setSendModal] = useState<{ open: boolean; network: Chain }>({
|
||||
open: false,
|
||||
network: 'ETH',
|
||||
@@ -17,7 +21,7 @@ export function TokenTable() {
|
||||
})
|
||||
|
||||
function openSend(ticker: string) {
|
||||
const network = TICKER_TO_CHAIN[ticker] ?? 'ETH'
|
||||
const network = TICKER_TO_CHAIN[ticker] ?? chain
|
||||
setSendModal({ open: true, network })
|
||||
}
|
||||
|
||||
@@ -26,16 +30,16 @@ export function TokenTable() {
|
||||
}
|
||||
|
||||
function openReceive(ticker: string) {
|
||||
const chain = TICKER_TO_CHAIN[ticker] ?? 'ETH'
|
||||
setReceiveModal({ open: true, chain })
|
||||
const target = TICKER_TO_CHAIN[ticker] ?? chain
|
||||
setReceiveModal({ open: true, chain: target })
|
||||
}
|
||||
|
||||
function closeReceive() {
|
||||
setReceiveModal((s) => ({ ...s, open: false }))
|
||||
}
|
||||
|
||||
function toggleFav(i: number) {
|
||||
setFavs((prev) => prev.map((v, idx) => (idx === i ? !v : v)))
|
||||
function toggleFav(ticker: string) {
|
||||
setFavs((prev) => ({ ...prev, [ticker]: !prev[ticker] }))
|
||||
}
|
||||
|
||||
const sendIcon = (
|
||||
@@ -64,14 +68,14 @@ export function TokenTable() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t, i) => (
|
||||
{rows.map((t) => (
|
||||
<tr key={t.ticker}>
|
||||
<td>
|
||||
<button
|
||||
className={`${styles.star} ${favs[i] ? styles.starOn : ''}`}
|
||||
onClick={() => toggleFav(i)}
|
||||
className={`${styles.star} ${favs[t.ticker] ? styles.starOn : ''}`}
|
||||
onClick={() => toggleFav(t.ticker)}
|
||||
type="button"
|
||||
aria-label={favs[i] ? 'Убрать из избранного' : 'В избранное'}
|
||||
aria-label={favs[t.ticker] ? 'Убрать из избранного' : 'В избранное'}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
@@ -79,7 +83,7 @@ export function TokenTable() {
|
||||
<td>
|
||||
<div className={styles.tokId}>
|
||||
<div className={styles.tokLogo} style={{ background: t.color }}>
|
||||
{t.logo && <img src={t.logo} alt={t.ticker} className={''} />}
|
||||
{t.logo ? <img src={t.logo} alt={t.ticker} className={''} /> : t.ticker[0]}
|
||||
</div>
|
||||
<div className={styles.balCol}>
|
||||
<b className={styles.cardTicker}>{t.ticker}</b>
|
||||
@@ -123,13 +127,13 @@ export function TokenTable() {
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className={styles.mobileList}>
|
||||
{rows.map((t, i) => (
|
||||
{rows.map((t) => (
|
||||
<div key={t.ticker} className={styles.card}>
|
||||
<button
|
||||
className={`${styles.star} ${favs[i] ? styles.starOn : ''}`}
|
||||
onClick={() => toggleFav(i)}
|
||||
className={`${styles.star} ${favs[t.ticker] ? styles.starOn : ''}`}
|
||||
onClick={() => toggleFav(t.ticker)}
|
||||
type="button"
|
||||
aria-label={favs[i] ? 'Убрать из избранного' : 'В избранное'}
|
||||
aria-label={favs[t.ticker] ? 'Убрать из избранного' : 'В избранное'}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
|
||||
1
src/widgets/wallet-chain-tabs/index.ts
Normal file
1
src/widgets/wallet-chain-tabs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WalletChainTabs } from './ui/WalletChainTabs'
|
||||
53
src/widgets/wallet-chain-tabs/ui/WalletChainTabs.module.css
Normal file
53
src/widgets/wallet-chain-tabs/ui/WalletChainTabs.module.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 24px 0 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.08));
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.65));
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.active {
|
||||
background: rgba(74, 109, 255, 0.18);
|
||||
border-color: rgba(74, 109, 255, 0.55);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tab {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
39
src/widgets/wallet-chain-tabs/ui/WalletChainTabs.tsx
Normal file
39
src/widgets/wallet-chain-tabs/ui/WalletChainTabs.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import type { Chain } from '@features/wallet'
|
||||
import btc from '@shared/assets/btc.svg'
|
||||
import eth from '@shared/assets/eth.svg'
|
||||
import sol from '@shared/assets/sol.svg'
|
||||
import trx from '@shared/assets/trx.svg'
|
||||
import bnb from '@shared/assets/bnb.svg'
|
||||
import styles from './WalletChainTabs.module.css'
|
||||
|
||||
interface TabItem {
|
||||
chain: Chain
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const TABS: TabItem[] = [
|
||||
{ chain: 'BTC', label: 'BTC', icon: btc },
|
||||
{ chain: 'ETH', label: 'ETH', icon: eth },
|
||||
{ chain: 'SOL', label: 'SOL', icon: sol },
|
||||
{ chain: 'TRX', label: 'TRX', icon: trx },
|
||||
{ chain: 'BSC', label: 'BSC', icon: bnb },
|
||||
]
|
||||
|
||||
export function WalletChainTabs() {
|
||||
return (
|
||||
<div className={styles.tabs}>
|
||||
{TABS.map((t) => (
|
||||
<NavLink
|
||||
key={t.chain}
|
||||
to={`/wallet/${t.chain.toLowerCase()}`}
|
||||
className={({ isActive }) => `${styles.tab} ${isActive ? styles.active : ''}`}
|
||||
>
|
||||
<img src={t.icon} alt={t.label} className={styles.icon} />
|
||||
<span>{t.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user