This commit is contained in:
2026-06-01 23:39:28 +03:00
parent 0409d63874
commit 5b915bbc22
9 changed files with 417 additions and 268 deletions

1
dist/assets/index-Bc6Kn5VS.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

161
dist/assets/index-Dz5BojOW.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-C426d72k.js"></script> <script type="module" crossorigin src="/assets/index-Dz5BojOW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CnYQBevk.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bc6Kn5VS.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -13,8 +13,10 @@ export interface Token {
fav: boolean fav: boolean
/** Stable unique key — tickers can repeat across chains (e.g. USDT on ETH/TRX/BSC). */ /** Stable unique key — tickers can repeat across chains (e.g. USDT on ETH/TRX/BSC). */
id?: string id?: string
/** Network this row belongs to — used to target send/receive modals. */ /** Network this row belongs to — used to target send/receive modals and grouping. */
chain?: Chain chain?: Chain
/** Numeric USD value of the holding — used for sorting and per-network subtotals. */
usdValue?: number
} }
export const TOKENS: readonly Token[] = [ export const TOKENS: readonly Token[] = [

View File

@@ -53,6 +53,7 @@ function nativeRowFor(chain: Chain, native: FormattedAmount): Token {
change: 0, change: 0,
bal: truncateDecimals(native.formatted), bal: truncateDecimals(native.formatted),
usd: formatUsd(native.usdValue), usd: formatUsd(native.usdValue),
usdValue: native.usdValue,
fav: false, fav: false,
} }
} }
@@ -70,6 +71,7 @@ function tokenRowFor(chain: Chain, symbol: string, amount: FormattedAmount): Tok
change: 0, change: 0,
bal: truncateDecimals(amount.formatted), bal: truncateDecimals(amount.formatted),
usd: formatUsd(amount.usdValue), usd: formatUsd(amount.usdValue),
usdValue: amount.usdValue,
fav: false, fav: false,
} }
} }
@@ -96,23 +98,28 @@ export function useAllWalletTokenRows() {
if (!data) return { rows: [] as Token[], isLoading } if (!data) return { rows: [] as Token[], isLoading }
const entries: { row: Token; value: number }[] = [] const rows: Token[] = []
for (const chain of CHAINS) { for (const chain of CHAINS) {
const chainData = data.perChain?.[chain] const chainData = data.perChain?.[chain]
if (!chainData) continue if (!chainData) continue
const chainRows: Token[] = []
if (chainData.native && hasBalance(chainData.native)) { if (chainData.native && hasBalance(chainData.native)) {
entries.push({ row: nativeRowFor(chain, chainData.native), value: chainData.native.usdValue }) chainRows.push(nativeRowFor(chain, chainData.native))
} }
for (const [symbol, amount] of Object.entries(chainData.tokens ?? {})) { for (const [symbol, amount] of Object.entries(chainData.tokens ?? {})) {
if (!hasBalance(amount)) continue if (!hasBalance(amount)) continue
entries.push({ row: tokenRowFor(chain, symbol, amount), value: amount.usdValue }) chainRows.push(tokenRowFor(chain, symbol, amount))
}
} }
entries.sort((a, b) => b.value - a.value) // Sort within the network by value; keep networks in CHAINS order so rows
// stay contiguous per chain for grouping in the table.
chainRows.sort((a, b) => (b.usdValue ?? 0) - (a.usdValue ?? 0))
rows.push(...chainRows)
}
return { rows: entries.map((e) => e.row), isLoading } return { rows, isLoading }
} }

View File

@@ -45,6 +45,57 @@
width: 48px; width: 48px;
} }
/* ─── Per-network group headers (all-coins view) ───────────────────────────── */
.groupHeader td {
padding: 0;
height: auto;
}
.groupHeader:hover {
background: transparent;
}
.groupHeaderInner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: rgba(255, 255, 255, 0.03);
}
.groupLabel {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.groupIcon {
width: 18px;
height: 18px;
border-radius: 50%;
}
.groupTotal {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.mobileGroupHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.right { .right {
text-align: right !important; text-align: right !important;
} }

View File

@@ -1,22 +1,61 @@
import { useState } from 'react' import { Fragment, useState } from 'react'
import { useChainTokenRows, useAllWalletTokenRows } from '../model/useChainTokenRows' import { useChainTokenRows, useAllWalletTokenRows } from '../model/useChainTokenRows'
import type { Token } from '../model/tokens' import type { Token } from '../model/tokens'
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 { COIN_ICONS } from '@shared/assets/coins'
import styles from './TokenTable.module.css' import styles from './TokenTable.module.css'
interface ViewProps { const CHAIN_META: Record<Chain, { label: string; icon: string }> = {
BTC: { label: 'Bitcoin', icon: COIN_ICONS.BTC },
ETH: { label: 'Ethereum', icon: COIN_ICONS.ETH },
SOL: { label: 'Solana', icon: COIN_ICONS.SOL },
TRX: { label: 'Tron', icon: COIN_ICONS.TRX },
BSC: { label: 'BNB Chain', icon: COIN_ICONS.BNB },
}
function formatUsd(value: number): string {
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
interface ChainGroup {
chain: Chain
rows: Token[] rows: Token[]
isLoading: boolean totalUsd: number
/** Network used when a row does not carry its own chain. */ }
fallbackChain?: Chain
/** Group rows by chain, preserving first-seen order (= CHAINS order from the hook). */
function groupByChainOrder(rows: Token[]): ChainGroup[] {
const groups: ChainGroup[] = []
const index = new Map<Chain, ChainGroup>()
for (const row of rows) {
const chain = (row.chain ?? 'ETH') as Chain
let group = index.get(chain)
if (!group) {
group = { chain, rows: [], totalUsd: 0 }
index.set(chain, group)
groups.push(group)
}
group.rows.push(row)
group.totalUsd += row.usdValue ?? 0
}
return groups
} }
function rowKey(t: Token): string { function rowKey(t: Token): string {
return t.id ?? t.ticker return t.id ?? t.ticker
} }
function TokenTableView({ rows, isLoading, fallbackChain = 'ETH' }: ViewProps) { interface ViewProps {
rows: Token[]
isLoading: boolean
/** Network used when a row does not carry its own chain. */
fallbackChain?: Chain
/** Group rows under per-network headers (used by the "all coins" view). */
groupByChain?: boolean
}
function TokenTableView({ rows, isLoading, fallbackChain = 'ETH', groupByChain = false }: ViewProps) {
const [favs, setFavs] = useState<Record<string, boolean>>({}) 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,
@@ -63,21 +102,7 @@ function TokenTableView({ rows, isLoading, fallbackChain = 'ETH' }: ViewProps) {
</svg> </svg>
) )
return ( function renderRow(t: Token) {
<>
<div className={`${styles.wrap} ${isLoading ? styles.loading : ''}`}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.thStar}></th>
<th>Токены</th>
<th className={styles.right}>Цена</th>
<th className={styles.right}>Баланс</th>
<th className={styles.center}></th>
</tr>
</thead>
<tbody>
{rows.map((t) => {
const key = rowKey(t) const key = rowKey(t)
return ( return (
<tr key={key}> <tr key={key}>
@@ -133,13 +158,9 @@ function TokenTableView({ rows, isLoading, fallbackChain = 'ETH' }: ViewProps) {
</td> </td>
</tr> </tr>
) )
})} }
</tbody>
</table>
{/* Mobile card list */} function renderCard(t: Token) {
<div className={styles.mobileList}>
{rows.map((t) => {
const key = rowKey(t) const key = rowKey(t)
return ( return (
<div key={key} className={styles.card}> <div key={key} className={styles.card}>
@@ -171,7 +192,75 @@ function TokenTableView({ rows, isLoading, fallbackChain = 'ETH' }: ViewProps) {
</div> </div>
</div> </div>
) )
})} }
function tableGroupHeader(group: ChainGroup) {
const meta = CHAIN_META[group.chain]
return (
<tr key={`h-${group.chain}`} className={styles.groupHeader}>
<td colSpan={5}>
<div className={styles.groupHeaderInner}>
<span className={styles.groupLabel}>
<img src={meta.icon} alt="" className={styles.groupIcon} />
{meta.label}
</span>
<span className={styles.groupTotal}>{formatUsd(group.totalUsd)}</span>
</div>
</td>
</tr>
)
}
function mobileGroupHeader(group: ChainGroup) {
const meta = CHAIN_META[group.chain]
return (
<div key={`mh-${group.chain}`} className={styles.mobileGroupHeader}>
<span className={styles.groupLabel}>
<img src={meta.icon} alt="" className={styles.groupIcon} />
{meta.label}
</span>
<span className={styles.groupTotal}>{formatUsd(group.totalUsd)}</span>
</div>
)
}
const groups = groupByChain ? groupByChainOrder(rows) : null
return (
<>
<div className={`${styles.wrap} ${isLoading ? styles.loading : ''}`}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.thStar}></th>
<th>Токены</th>
<th className={styles.right}>Цена</th>
<th className={styles.right}>Баланс</th>
<th className={styles.center}></th>
</tr>
</thead>
<tbody>
{groups
? groups.map((g) => (
<Fragment key={g.chain}>
{tableGroupHeader(g)}
{g.rows.map(renderRow)}
</Fragment>
))
: rows.map(renderRow)}
</tbody>
</table>
{/* Mobile card list */}
<div className={styles.mobileList}>
{groups
? groups.map((g) => (
<Fragment key={g.chain}>
{mobileGroupHeader(g)}
{g.rows.map(renderCard)}
</Fragment>
))
: rows.map(renderCard)}
</div> </div>
</div> </div>
@@ -212,5 +301,5 @@ export function TokenTable({ chain }: Props) {
export function AllTokenTable() { export function AllTokenTable() {
const { rows, isLoading } = useAllWalletTokenRows() const { rows, isLoading } = useAllWalletTokenRows()
return <TokenTableView rows={rows} isLoading={isLoading} /> return <TokenTableView rows={rows} isLoading={isLoading} groupByChain />
} }