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

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