17.05.2026 funny

This commit is contained in:
2026-05-17 14:03:33 +03:00
parent b5791a0871
commit b0dc637b8f
16 changed files with 286 additions and 159 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЭКСА — Ваш мост в мир цифровых активов</title> <title>ЭКСА — Ваш мост в мир цифровых активов</title>
<script type="module" crossorigin src="/assets/index-CbGm-SmX.js"></script> <script type="module" crossorigin src="/assets/index-DOZHm_HX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BbQ5Ok1h.css"> <link rel="stylesheet" crossorigin href="/assets/index-ChMX4U7G.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -29,6 +29,7 @@ export function RouterProvider() {
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route path={ROUTES.CONVERTER} element={<ConverterPage />} /> <Route path={ROUTES.CONVERTER} element={<ConverterPage />} />
<Route path={ROUTES.WALLET} element={<WalletPage />} /> <Route path={ROUTES.WALLET} element={<WalletPage />} />
<Route path={ROUTES.WALLET_CHAIN} element={<WalletPage />} />
<Route path={ROUTES.SWAP} element={<SwapPage />} /> <Route path={ROUTES.SWAP} element={<SwapPage />} />
<Route path={ROUTES.BRIDGE} element={<BridgePage />} /> <Route path={ROUTES.BRIDGE} element={<BridgePage />} />
<Route path={ROUTES.PROFILE} element={<ProfilePage />} /> <Route path={ROUTES.PROFILE} element={<ProfilePage />} />

View File

@@ -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 { useMe } from '@features/auth'
import { ROUTES } from '@shared/config/routes' 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 { BalanceCard } from '@widgets/balance-card'
import { TokenTable } from '@widgets/token-table' import { TokenTable } from '@widgets/token-table'
import { WalletHeader } from '@widgets/wallet-header' import { WalletHeader } from '@widgets/wallet-header'
import { WalletChainTabs } from '@widgets/wallet-chain-tabs'
import { Button } from '@shared/ui' import { Button } from '@shared/ui'
import styles from './WalletPage.module.css' import styles from './WalletPage.module.css'
@@ -13,6 +14,7 @@ export function WalletPage() {
const { error: portfolioError } = usePortfolio() const { error: portfolioError } = usePortfolio()
const { mutate: createWallet, isPending } = useCreateWallet() const { mutate: createWallet, isPending } = useCreateWallet()
const navigate = useNavigate() const navigate = useNavigate()
const { chain: chainParam } = useParams<{ chain?: string }>()
const noWallet = (portfolioError as any)?.error?.includes('No wallets') 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 (isError) return <div className={styles.error}>Произошла ошибка. Попробуйте обновить страницу.</div>
if (data && !data.kyc_verified) return <Navigate to={ROUTES.KYC} replace /> 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 ( return (
<div className={styles.page}> <div className={styles.page}>
<WalletHeader /> <WalletHeader />
@@ -39,7 +46,8 @@ export function WalletPage() {
) : ( ) : (
<> <>
<BalanceCard /> <BalanceCard />
<TokenTable /> <WalletChainTabs />
<TokenTable chain={chain!} />
</> </>
)} )}
</main> </main>

View File

@@ -1,6 +1,7 @@
export const ROUTES = { export const ROUTES = {
HOME: '/', HOME: '/',
WALLET: '/wallet', WALLET: '/wallet',
WALLET_CHAIN: '/wallet/:chain',
SWAP: "/swap", SWAP: "/swap",
BRIDGE: "/bridge", BRIDGE: "/bridge",
LOGIN: '/login', LOGIN: '/login',

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Button } from '@shared/ui' import { Button } from '@shared/ui'
import { useMe, useUploadAvatar } from '@features/auth' import { useMe, useUploadAvatar } from '@features/auth'
import styles from './ProfileAvatar.module.css' import styles from './ProfileAvatar.module.css'
@@ -21,8 +21,14 @@ export function ProfileAvatar() {
const { mutateAsync: upload, isPending } = useUploadAvatar() const { mutateAsync: upload, isPending } = useUploadAvatar()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [imgFailed, setImgFailed] = useState(false)
const avatarLink = data?.avatar_link ?? null const avatarLink = data?.avatar_link ?? null
const showImg = avatarLink && !imgFailed
useEffect(() => {
setImgFailed(false)
}, [avatarLink])
const openPicker = () => { const openPicker = () => {
if (isPending) return if (isPending) return
@@ -46,8 +52,17 @@ export function ProfileAvatar() {
return ( return (
<div className={styles.col}> <div className={styles.col}>
<div className={styles.avatar} onClick={openPicker}> <div className={styles.avatar} onClick={openPicker}>
{avatarLink ? ( {showImg ? (
<img src={avatarLink} alt="avatar" className={styles.avatarImg} /> <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"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" /> <circle cx="12" cy="8" r="4" />

View 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 }
}

View File

@@ -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 }
}

View File

@@ -1,12 +1,16 @@
import { useState } from 'react' 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 { SendModal, TICKER_TO_CHAIN, type Chain } from '@widgets/send-modal'
import { ReceiveModal } from '@widgets/receive-modal' import { ReceiveModal } from '@widgets/receive-modal'
import styles from './TokenTable.module.css' import styles from './TokenTable.module.css'
export function TokenTable() { interface Props {
const { rows, isLoading } = useTokenRows() chain: Chain
const [favs, setFavs] = useState<boolean[]>(() => rows.map((t) => t.fav)) }
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 }>({ const [sendModal, setSendModal] = useState<{ open: boolean; network: Chain }>({
open: false, open: false,
network: 'ETH', network: 'ETH',
@@ -17,7 +21,7 @@ export function TokenTable() {
}) })
function openSend(ticker: string) { function openSend(ticker: string) {
const network = TICKER_TO_CHAIN[ticker] ?? 'ETH' const network = TICKER_TO_CHAIN[ticker] ?? chain
setSendModal({ open: true, network }) setSendModal({ open: true, network })
} }
@@ -26,16 +30,16 @@ export function TokenTable() {
} }
function openReceive(ticker: string) { function openReceive(ticker: string) {
const chain = TICKER_TO_CHAIN[ticker] ?? 'ETH' const target = TICKER_TO_CHAIN[ticker] ?? chain
setReceiveModal({ open: true, chain }) setReceiveModal({ open: true, chain: target })
} }
function closeReceive() { function closeReceive() {
setReceiveModal((s) => ({ ...s, open: false })) setReceiveModal((s) => ({ ...s, open: false }))
} }
function toggleFav(i: number) { function toggleFav(ticker: string) {
setFavs((prev) => prev.map((v, idx) => (idx === i ? !v : v))) setFavs((prev) => ({ ...prev, [ticker]: !prev[ticker] }))
} }
const sendIcon = ( const sendIcon = (
@@ -64,14 +68,14 @@ export function TokenTable() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((t, i) => ( {rows.map((t) => (
<tr key={t.ticker}> <tr key={t.ticker}>
<td> <td>
<button <button
className={`${styles.star} ${favs[i] ? styles.starOn : ''}`} className={`${styles.star} ${favs[t.ticker] ? styles.starOn : ''}`}
onClick={() => toggleFav(i)} onClick={() => toggleFav(t.ticker)}
type="button" type="button"
aria-label={favs[i] ? 'Убрать из избранного' : 'В избранное'} aria-label={favs[t.ticker] ? 'Убрать из избранного' : 'В избранное'}
> >
</button> </button>
@@ -79,7 +83,7 @@ export function TokenTable() {
<td> <td>
<div className={styles.tokId}> <div className={styles.tokId}>
<div className={styles.tokLogo} style={{ background: t.color }}> <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>
<div className={styles.balCol}> <div className={styles.balCol}>
<b className={styles.cardTicker}>{t.ticker}</b> <b className={styles.cardTicker}>{t.ticker}</b>
@@ -123,13 +127,13 @@ export function TokenTable() {
{/* Mobile card list */} {/* Mobile card list */}
<div className={styles.mobileList}> <div className={styles.mobileList}>
{rows.map((t, i) => ( {rows.map((t) => (
<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[t.ticker] ? styles.starOn : ''}`}
onClick={() => toggleFav(i)} onClick={() => toggleFav(t.ticker)}
type="button" type="button"
aria-label={favs[i] ? 'Убрать из избранного' : 'В избранное'} aria-label={favs[t.ticker] ? 'Убрать из избранного' : 'В избранное'}
> >
</button> </button>

View File

@@ -0,0 +1 @@
export { WalletChainTabs } from './ui/WalletChainTabs'

View 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;
}
}

View 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