f
This commit is contained in:
1
dist/assets/index-Bc6Kn5VS.css
vendored
Normal file
1
dist/assets/index-Bc6Kn5VS.css
vendored
Normal file
File diff suppressed because one or more lines are too long
161
dist/assets/index-C426d72k.js
vendored
161
dist/assets/index-C426d72k.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-CnYQBevk.css
vendored
1
dist/assets/index-CnYQBevk.css
vendored
File diff suppressed because one or more lines are too long
161
dist/assets/index-Dz5BojOW.js
vendored
Normal file
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
4
dist/index.html
vendored
@@ -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>
|
||||||
|
|||||||
@@ -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[] = [
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, isLoading }
|
||||||
|
|
||||||
return { rows: entries.map((e) => e.row), isLoading }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +102,130 @@ function TokenTableView({ rows, isLoading, fallbackChain = 'ETH' }: ViewProps) {
|
|||||||
</svg>
|
</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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`${styles.wrap} ${isLoading ? styles.loading : ''}`}>
|
<div className={`${styles.wrap} ${isLoading ? styles.loading : ''}`}>
|
||||||
@@ -77,101 +240,27 @@ function TokenTableView({ rows, isLoading, fallbackChain = 'ETH' }: ViewProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((t) => {
|
{groups
|
||||||
const key = rowKey(t)
|
? groups.map((g) => (
|
||||||
return (
|
<Fragment key={g.chain}>
|
||||||
<tr key={key}>
|
{tableGroupHeader(g)}
|
||||||
<td>
|
{g.rows.map(renderRow)}
|
||||||
<button
|
</Fragment>
|
||||||
className={`${styles.star} ${favs[key] ? styles.starOn : ''}`}
|
))
|
||||||
onClick={() => toggleFav(key)}
|
: rows.map(renderRow)}
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{/* Mobile card list */}
|
{/* Mobile card list */}
|
||||||
<div className={styles.mobileList}>
|
<div className={styles.mobileList}>
|
||||||
{rows.map((t) => {
|
{groups
|
||||||
const key = rowKey(t)
|
? groups.map((g) => (
|
||||||
return (
|
<Fragment key={g.chain}>
|
||||||
<div key={key} className={styles.card}>
|
{mobileGroupHeader(g)}
|
||||||
<button
|
{g.rows.map(renderCard)}
|
||||||
className={`${styles.star} ${favs[key] ? styles.starOn : ''}`}
|
</Fragment>
|
||||||
onClick={() => toggleFav(key)}
|
))
|
||||||
type="button"
|
: rows.map(renderCard)}
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</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 />
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user