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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЭКСА — Ваш мост в мир цифровых активов</title>
<script type="module" crossorigin src="/assets/index-C426d72k.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CnYQBevk.css">
<script type="module" crossorigin src="/assets/index-Dz5BojOW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bc6Kn5VS.css">
</head>
<body>
<div id="root"></div>

View File

@@ -13,8 +13,10 @@ export interface Token {
fav: boolean
/** Stable unique key — tickers can repeat across chains (e.g. USDT on ETH/TRX/BSC). */
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
/** Numeric USD value of the holding — used for sorting and per-network subtotals. */
usdValue?: number
}
export const TOKENS: readonly Token[] = [

View File

@@ -53,6 +53,7 @@ function nativeRowFor(chain: Chain, native: FormattedAmount): Token {
change: 0,
bal: truncateDecimals(native.formatted),
usd: formatUsd(native.usdValue),
usdValue: native.usdValue,
fav: false,
}
}
@@ -70,6 +71,7 @@ function tokenRowFor(chain: Chain, symbol: string, amount: FormattedAmount): Tok
change: 0,
bal: truncateDecimals(amount.formatted),
usd: formatUsd(amount.usdValue),
usdValue: amount.usdValue,
fav: false,
}
}
@@ -96,23 +98,28 @@ export function useAllWalletTokenRows() {
if (!data) return { rows: [] as Token[], isLoading }
const entries: { row: Token; value: number }[] = []
const rows: Token[] = []
for (const chain of CHAINS) {
const chainData = data.perChain?.[chain]
if (!chainData) continue
const chainRows: Token[] = []
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 ?? {})) {
if (!hasBalance(amount)) continue
entries.push({ row: tokenRowFor(chain, symbol, amount), value: amount.usdValue })
chainRows.push(tokenRowFor(chain, symbol, amount))
}
// 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)
}
entries.sort((a, b) => b.value - a.value)
return { rows: entries.map((e) => e.row), isLoading }
return { rows, isLoading }
}

View File

@@ -45,6 +45,57 @@
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 {
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 type { Token } from '../model/tokens'
import { SendModal, TICKER_TO_CHAIN, type Chain } from '@widgets/send-modal'
import { ReceiveModal } from '@widgets/receive-modal'
import { COIN_ICONS } from '@shared/assets/coins'
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[]
isLoading: boolean
/** Network used when a row does not carry its own chain. */
fallbackChain?: Chain
totalUsd: number
}
/** 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 {
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 [sendModal, setSendModal] = useState<{ open: boolean; network: Chain }>({
open: false,
@@ -63,6 +102,130 @@ function TokenTableView({ rows, isLoading, fallbackChain = 'ETH' }: ViewProps) {
</svg>
)
function renderRow(t: Token) {
const key = rowKey(t)
return (
<tr key={key}>
<td>
<button
className={`${styles.star} ${favs[key] ? styles.starOn : ''}`}
onClick={() => toggleFav(key)}
type="button"
aria-label={favs[key] ? 'Убрать из избранного' : 'В избранное'}
>
</button>
</td>
<td>
<div className={styles.tokId}>
<div className={styles.tokLogo} style={{ background: t.color }}>
{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>
<span className={styles.noFont}>{t.name}</span>
</div>
</div>
</td>
<td className={styles.right}>
<span className={styles.price}>{t.price}</span>
</td>
<td className={styles.right}>
<div className={styles.balCol}>
<b>{t.bal}</b>
<span>{t.usd}</span>
</div>
</td>
<td className={styles.center}>
<div className={styles.btnGroup}>
<button
className={styles.receiveBtn}
type="button"
onClick={(e) => { e.stopPropagation(); openReceive(t) }}
>
{receiveIcon}
Получить
</button>
<button
className={styles.sendBtn}
type="button"
onClick={(e) => { e.stopPropagation(); openSend(t) }}
>
{sendIcon}
Отправить
</button>
</div>
</td>
</tr>
)
}
function renderCard(t: Token) {
const key = rowKey(t)
return (
<div key={key} className={styles.card}>
<button
className={`${styles.star} ${favs[key] ? styles.starOn : ''}`}
onClick={() => toggleFav(key)}
type="button"
aria-label={favs[key] ? 'Убрать из избранного' : 'В избранное'}
>
</button>
<div className={styles.tokLogo} style={{ background: t.color }}>
{t.logo
? <img src={t.logo} alt={t.ticker} className={''} />
: t.ticker[0]}
</div>
<div className={styles.cardInfo}>
<div className={styles.cardTop}>
<div>
<span className={styles.cardTicker}>{t.ticker}</span>
<span className={styles.cardName}>{t.name}</span>
</div>
<span className={styles.cardBalCrypto}>{t.bal}</span>
</div>
<div className={styles.cardBot}>
<span className={styles.cardPrice}>{t.price}</span>
<span className={styles.cardBalUsd}>{t.usd}</span>
</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 : ''}`}>
@@ -77,101 +240,27 @@ function TokenTableView({ rows, isLoading, fallbackChain = 'ETH' }: ViewProps) {
</tr>
</thead>
<tbody>
{rows.map((t) => {
const key = rowKey(t)
return (
<tr key={key}>
<td>
<button
className={`${styles.star} ${favs[key] ? styles.starOn : ''}`}
onClick={() => toggleFav(key)}
type="button"
aria-label={favs[key] ? 'Убрать из избранного' : 'В избранное'}
>
</button>
</td>
<td>
<div className={styles.tokId}>
<div className={styles.tokLogo} style={{ background: t.color }}>
{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>
<span className={styles.noFont}>{t.name}</span>
</div>
</div>
</td>
<td className={styles.right}>
<span className={styles.price}>{t.price}</span>
</td>
<td className={styles.right}>
<div className={styles.balCol}>
<b>{t.bal}</b>
<span>{t.usd}</span>
</div>
</td>
<td className={styles.center}>
<div className={styles.btnGroup}>
<button
className={styles.receiveBtn}
type="button"
onClick={(e) => { e.stopPropagation(); openReceive(t) }}
>
{receiveIcon}
Получить
</button>
<button
className={styles.sendBtn}
type="button"
onClick={(e) => { e.stopPropagation(); openSend(t) }}
>
{sendIcon}
Отправить
</button>
</div>
</td>
</tr>
)
})}
{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}>
{rows.map((t) => {
const key = rowKey(t)
return (
<div key={key} className={styles.card}>
<button
className={`${styles.star} ${favs[key] ? styles.starOn : ''}`}
onClick={() => toggleFav(key)}
type="button"
aria-label={favs[key] ? 'Убрать из избранного' : 'В избранное'}
>
</button>
<div className={styles.tokLogo} style={{ background: t.color }}>
{t.logo
? <img src={t.logo} alt={t.ticker} className={''} />
: t.ticker[0]}
</div>
<div className={styles.cardInfo}>
<div className={styles.cardTop}>
<div>
<span className={styles.cardTicker}>{t.ticker}</span>
<span className={styles.cardName}>{t.name}</span>
</div>
<span className={styles.cardBalCrypto}>{t.bal}</span>
</div>
<div className={styles.cardBot}>
<span className={styles.cardPrice}>{t.price}</span>
<span className={styles.cardBalUsd}>{t.usd}</span>
</div>
</div>
</div>
)
})}
{groups
? groups.map((g) => (
<Fragment key={g.chain}>
{mobileGroupHeader(g)}
{g.rows.map(renderCard)}
</Fragment>
))
: rows.map(renderCard)}
</div>
</div>
@@ -212,5 +301,5 @@ export function TokenTable({ chain }: Props) {
export function AllTokenTable() {
const { rows, isLoading } = useAllWalletTokenRows()
return <TokenTableView rows={rows} isLoading={isLoading} />
return <TokenTableView rows={rows} isLoading={isLoading} groupByChain />
}