This commit is contained in:
2026-06-01 23:28:57 +03:00
parent 895bec2a50
commit b86f3209f5
22 changed files with 1036 additions and 269 deletions

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-lpDOSqEV.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-M5HC-xbs.js"></script> <script type="module" crossorigin src="/assets/index-lpDOSqEV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BE93HmSw.css"> <link rel="stylesheet" crossorigin href="/assets/index-CnYQBevk.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -6,6 +6,8 @@ import { BridgePage } from '@pages/bridge'
import { ProfilePage } from '@pages/profile' import { ProfilePage } from '@pages/profile'
import { LoginPage } from '@pages/login' import { LoginPage } from '@pages/login'
import { RegisterPage } from '@pages/register' import { RegisterPage } from '@pages/register'
import { RegisterTestPage } from '@pages/register-test'
import { ConverterTestPage } from '@pages/converter-test'
import { ConverterPage } from '@pages/converter' import { ConverterPage } from '@pages/converter'
import { SeedPhrasePage } from '@pages/seed-phrase' import { SeedPhrasePage } from '@pages/seed-phrase'
import { KycPage } from '@pages/kyc' import { KycPage } from '@pages/kyc'
@@ -33,6 +35,8 @@ export function RouterProvider() {
<Route path={ROUTES.POLITIKA_COOKIE} element={<PolitikaCookiePage />} /> <Route path={ROUTES.POLITIKA_COOKIE} element={<PolitikaCookiePage />} />
<Route path={ROUTES.SOGLASIE_PERSONALNYH_DANNYH} element={<SoglasiePage />} /> <Route path={ROUTES.SOGLASIE_PERSONALNYH_DANNYH} element={<SoglasiePage />} />
<Route path={ROUTES.REESTR_PD_RKN} element={<ReestryPage />} /> <Route path={ROUTES.REESTR_PD_RKN} element={<ReestryPage />} />
<Route path={ROUTES.REGISTER_TEST} element={<RegisterTestPage />} />
<Route path={ROUTES.CONVERTER_TEST} element={<ConverterTestPage />} />
<Route element={<GuestRoute />}> <Route element={<GuestRoute />}>
<Route path={ROUTES.LOGIN} element={<LoginPage />} /> <Route path={ROUTES.LOGIN} element={<LoginPage />} />

View File

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

View File

@@ -0,0 +1,133 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.wrap {
position: relative;
overflow: hidden;
width: 100%;
max-width: 900px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 40px;
}
.header {
margin-bottom: 40px;
}
.title {
font-size: clamp(32px, 4vw, 48px);
font-weight: 700;
}
.subtitle {
font-size: 15px;
line-height: 1.6;
color: var(--text-secondary);
margin-top: 12px;
max-width: 560px;
}
.body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
}
.formCol {
display: flex;
flex-direction: column;
gap: 20px;
}
.hint {
font-size: 12px;
color: var(--highlight);
margin-top: -12px;
}
/* Панель условий / комиссии */
.infoCol {
display: flex;
flex-direction: column;
}
.infoTitle {
font-size: 16px;
font-weight: 600;
letter-spacing: 1px;
margin-bottom: 24px;
}
.infoRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
border: 1px solid var(--glass-border);
margin-bottom: 12px;
}
.infoRow[data-accent] {
border-color: var(--grad-center);
background: rgba(91, 61, 184, 0.12);
}
.infoLabel {
font-size: 13px;
color: var(--text-secondary);
}
.infoValue {
font-family: var(--font-mono);
font-size: 16px;
font-weight: 600;
}
.note {
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
margin-top: 12px;
}
.submitBtn {
width: 100%;
margin-top: 40px;
padding: 18px;
border-radius: 12px;
background: var(--grad-center);
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
letter-spacing: 1px;
transition: opacity 0.2s;
}
.submitBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 768px) {
.wrap {
padding: 28px 20px;
}
.body {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.header {
margin-bottom: 1.5rem;
}
}

View File

@@ -0,0 +1,105 @@
import { useState } from 'react'
import { FormField } from '@shared/ui'
import styles from './ConverterTestPage.module.css'
const MIN_ORDER = 500_000
const APPROX_RATE = 0.03 // примерная комиссия 3% для крупных заявок
const ru = (n: number) => n.toLocaleString('ru-RU', { maximumFractionDigits: 0 })
export function ConverterTestPage() {
const [amount, setAmount] = useState('')
const [name, setName] = useState('')
const [contact, setContact] = useState('')
const numAmount = Number(amount.replace(/\D/g, '')) || 0
const belowMin = numAmount > 0 && numAmount < MIN_ORDER
const commission = numAmount * APPROX_RATE
const handleAmountChange = (value: string) => {
const digits = value.replace(/\D/g, '')
setAmount(digits ? ru(Number(digits)) : '')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Тестовая страница — заявка никуда не отправляется.
}
return (
<div className={styles.page}>
<form className={styles.wrap} onSubmit={handleSubmit}>
<div className={styles.header}>
<h1 className={styles.title}>Оставить заявку</h1>
<p className={styles.subtitle}>
Конвертация крупных объёмов по индивидуальному курсу. Оставьте заявку
менеджер свяжется с вами, подтвердит актуальный курс и сопроводит сделку.
</p>
</div>
<div className={styles.body}>
<div className={styles.formCol}>
<FormField
label="Объём заявки, ₽"
type="text"
value={amount}
onChange={handleAmountChange}
placeholder="от 500 000"
/>
{belowMin && (
<p className={styles.hint}>
Минимальный объём заявки {ru(MIN_ORDER)}
</p>
)}
<FormField
label="Как к вам обращаться"
type="text"
value={name}
onChange={setName}
placeholder="Имя"
/>
<FormField
label="Email или телефон для связи"
type="text"
value={contact}
onChange={setContact}
placeholder="example@mail.ru / +7 900 000-00-00"
/>
</div>
<div className={styles.infoCol}>
<div className={styles.infoTitle}>УСЛОВИЯ</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Минимальный объём</span>
<span className={styles.infoValue}>{ru(MIN_ORDER)} </span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Примерная комиссия</span>
<span className={styles.infoValue}>{(APPROX_RATE * 100).toFixed(0)} %</span>
</div>
<div className={styles.infoRow} data-accent>
<span className={styles.infoLabel}>Комиссия с объёма</span>
<span className={styles.infoValue}>
{numAmount > 0 ? `${ru(commission)}` : '—'}
</span>
</div>
<p className={styles.note}>
Итоговая комиссия рассчитывается индивидуально и зависит от объёма,
валюты и направления сделки.
</p>
</div>
</div>
<button type="submit" className={styles.submitBtn} disabled={belowMin}>
Оставить заявку
</button>
</form>
</div>
)
}

View File

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

View File

@@ -0,0 +1,227 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 0;
}
.card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 32px;
width: 100%;
max-width: 600px;
}
.logo {
display: flex;
justify-content: center;
margin-bottom: 28px;
}
.logo img {
height: 40px;
}
.title {
text-align: center;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 24px;
line-height: 1.3;
}
/* Переключатель типа регистрации */
.typeSwitch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 14px;
margin-bottom: 24px;
}
.typeOption {
appearance: none;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
padding: 12px 16px;
border-radius: 10px;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease;
}
.typeOption:hover {
color: var(--text-primary);
}
.typeOptionActive {
background: var(--interactive);
color: #fff;
}
.twoCol {
display: grid;
grid-template-columns: 1fr;
gap: 20px 24px;
align-items: start;
}
.leftCol {
display: flex;
flex-direction: column;
gap: 20px;
}
.rightCol {
display: flex;
flex-direction: column;
gap: 8px;
}
.codeHint {
font-size: 12px;
color: var(--text-secondary);
text-decoration: underline;
cursor: pointer;
}
/* Кнопка возврата на шаг ввода данных */
.backButton {
appearance: none;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
padding: 0;
margin-bottom: 16px;
cursor: pointer;
transition: color 0.18s ease;
}
.backButton:hover {
color: var(--text-primary);
}
/* Документы для юридического лица */
.documents {
margin-top: 24px;
padding: 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--glass-border);
border-radius: 16px;
}
.documentsTitle {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.documentsSubtitle {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.documentsList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.documentItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--glass-border);
border-radius: 12px;
}
.documentName {
font-size: 13px;
color: var(--text-primary);
line-height: 1.4;
}
.attachButton {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--interactive);
padding: 8px 14px;
border: 1px solid var(--interactive);
border-radius: 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.18s ease, color 0.18s ease;
}
.attachButton:hover {
background: var(--interactive);
color: #fff;
}
.fileInput {
display: none;
}
.error {
color: #ff5a5a;
font-size: 13px;
margin-top: 12px;
text-align: center;
}
.submitWrapper {
margin-top: 28px;
}
.legal {
text-align: center;
font-size: 11px;
color: var(--text-secondary);
margin-top: 20px;
line-height: 1.6;
}
.legal a {
color: var(--interactive);
text-decoration: none;
}
.legal a:hover {
text-decoration: underline;
}
@media (max-width: 560px) {
.card {
padding: 32px 20px;
border-radius: 0;
}
.twoCol {
grid-template-columns: 1fr;
}
.documentItem {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,155 @@
import { useState } from 'react'
import { FormField, PrimaryButton, Button } from '@shared/ui'
import logo from '@shared/assets/logo-full-white.png'
import styles from './RegisterTestPage.module.css'
type AccountType = 'individual' | 'legal'
type Step = 'info' | 'documents'
const LEGAL_DOCUMENTS = [
'Свидетельство о государственной регистрации (ОГРН)',
'Свидетельство о постановке на учёт в налоговом органе (ИНН)',
'Устав организации (действующая редакция)',
'Решение/протокол о назначении руководителя',
'Документ, подтверждающий полномочия лица, открывающего счёт',
'Карточка с образцами подписей и оттиска печати',
]
export function RegisterTestPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [verificationCode, setVerificationCode] = useState('')
const [accountType, setAccountType] = useState<AccountType>('individual')
const [step, setStep] = useState<Step>('info')
const isLegal = accountType === 'legal'
// Тестовая страница — без проверок и запросов. «Создать» просто ведёт на шаг 2.
const handleInfoSubmit = (e: React.FormEvent) => {
e.preventDefault()
setStep('documents')
}
const handleDocumentsSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Тестовая страница — отправки нет.
}
return (
<div className={styles.page}>
{step === 'info' ? (
<form className={styles.card} onSubmit={handleInfoSubmit}>
<div className={styles.logo}>
<img src={logo} alt="ЭКСА" />
</div>
<h1 className={styles.title}>Создать кошелёк ЭКСА</h1>
{/* Выбор типа регистрации — перед всеми полями */}
<div className={styles.typeSwitch} role="tablist" aria-label="Тип регистрации">
<button
type="button"
role="tab"
aria-selected={!isLegal}
className={`${styles.typeOption} ${!isLegal ? styles.typeOptionActive : ''}`}
onClick={() => setAccountType('individual')}
>
Физическое лицо
</button>
<button
type="button"
role="tab"
aria-selected={isLegal}
className={`${styles.typeOption} ${isLegal ? styles.typeOptionActive : ''}`}
onClick={() => setAccountType('legal')}
>
Юридическое лицо
</button>
</div>
<div className={styles.twoCol}>
<div className={styles.leftCol}>
<FormField
label={isLegal ? 'Введите корпоративный email' : 'Введите адрес электронной почты'}
type="email"
value={email}
onChange={setEmail}
placeholder={isLegal ? 'name@company.ru' : 'example@mail.ru'}
/>
<FormField
label="Придумайте пароль"
type="password"
value={password}
onChange={setPassword}
placeholder="••••••••"
/>
<FormField
label="Повторите пароль"
type="password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="••••••••"
/>
</div>
<div className={styles.rightCol}>
<Button variant="ghost" type="button">
Получить проверочный код
</Button>
<span className={styles.codeHint}>Код не пришёл</span>
<FormField
label="Ввести код"
type="text"
value={verificationCode}
onChange={setVerificationCode}
placeholder="000 000"
/>
</div>
</div>
<div className={styles.submitWrapper}>
<PrimaryButton label="Создать" />
</div>
<p className={styles.legal}>
Нажимая «Создать», вы принимаете<br />
<a href="#">Пользовательское соглашение</a> и <a href="#">Политику конфиденциальности</a>
</p>
</form>
) : (
/* Шаг 2: прикрепление документов (только юр. лицо) */
<form className={styles.card} onSubmit={handleDocumentsSubmit}>
<div className={styles.logo}>
<img src={logo} alt="ЭКСА" />
</div>
<button type="button" className={styles.backButton} onClick={() => setStep('info')}>
Назад к данным
</button>
<h1 className={styles.title}>Прикрепите документы</h1>
<p className={styles.documentsSubtitle}>
Для открытия счёта юридическому лицу прикрепите сканы или фотографии
следующих документов:
</p>
<ul className={styles.documentsList}>
{LEGAL_DOCUMENTS.map((doc) => (
<li key={doc} className={styles.documentItem}>
<span className={styles.documentName}>{doc}</span>
<label className={styles.attachButton}>
Прикрепить
<input type="file" className={styles.fileInput} multiple />
</label>
</li>
))}
</ul>
<div className={styles.submitWrapper}>
<PrimaryButton label="Создать аккаунт" />
</div>
</form>
)}
</div>
)
}

View File

@@ -3,7 +3,7 @@ import { useMe } from '@features/auth'
import { ROUTES } from '@shared/config/routes' import { ROUTES } from '@shared/config/routes'
import { usePortfolio, useCreateWallet, CHAINS, type Chain } 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, AllTokenTable } from '@widgets/token-table'
import { WalletHeader } from '@widgets/wallet-header' import { WalletHeader } from '@widgets/wallet-header'
import { WalletChainTabs } from '@widgets/wallet-chain-tabs' import { WalletChainTabs } from '@widgets/wallet-chain-tabs'
import { Button } from '@shared/ui' import { Button } from '@shared/ui'
@@ -25,8 +25,6 @@ export function WalletPage() {
const upper = chainParam?.toUpperCase() as Chain | undefined const upper = chainParam?.toUpperCase() as Chain | undefined
const chain: Chain | undefined = upper && CHAINS.includes(upper) ? upper : 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 />
@@ -47,7 +45,7 @@ export function WalletPage() {
<> <>
<BalanceCard /> <BalanceCard />
<WalletChainTabs /> <WalletChainTabs />
<TokenTable chain={chain!} /> {chain ? <TokenTable chain={chain} /> : <AllTokenTable />}
</> </>
)} )}
</main> </main>

View File

@@ -1,4 +1,4 @@
export const COUNTDOWN_TARGET = new Date('2026-05-21T00:00:00').getTime() export const COUNTDOWN_TARGET = new Date('2026-05-21T00:00:00').getTime()
export const USDT_RATE = 80.00 export const USDT_RATE = 80.00
export const GAS_PRICE = 21.00 export const GAS_PRICE = 21.00
export const MIN_RUB_AMOUNT = 5000 export const MIN_RUB_AMOUNT = 10000

View File

@@ -6,6 +6,8 @@ export const ROUTES = {
BRIDGE: "/bridge", BRIDGE: "/bridge",
LOGIN: '/login', LOGIN: '/login',
REGISTER: '/register', REGISTER: '/register',
REGISTER_TEST: '/register-test',
CONVERTER_TEST: '/converter-test',
PROFILE: '/profile', PROFILE: '/profile',
SEED_PHRASE: '/seed-phrase', SEED_PHRASE: '/seed-phrase',
CONVERTER: '/converter', CONVERTER: '/converter',

View File

@@ -1,16 +1,25 @@
import { useState } from 'react' import { useState } from 'react'
import { useCurrencyConversion, useCreateOrder } from '@features/payment' import { useCurrencyConversion, useCreateOrder } from '@features/payment'
const ORDER_ERROR_MESSAGE = 'Сумма для конвертации слишком мала. Увеличьте сумму и попробуйте снова.'
type NotificationState = { status: 'error'; message: string }
export function useConverterSection() { export function useConverterSection() {
const conv = useCurrencyConversion() const conv = useCurrencyConversion()
const [agreed, setAgreed] = useState(false) const [agreed, setAgreed] = useState(false)
const [notification, setNotification] = useState<NotificationState | null>(null)
const { mutate: submitOrder, isPending } = useCreateOrder() const { mutate: submitOrder, isPending } = useCreateOrder()
function onPay() { function onPay() {
setNotification(null)
submitOrder( submitOrder(
conv.isUsdtToRub conv.isUsdtToRub
? { usdt_amount: conv.numUsdt, usdt_exchange_rate: 1, gas_fee: 1, total_price: conv.rubTotalNum } ? { usdt_amount: conv.numUsdt, usdt_exchange_rate: 1, gas_fee: 1, total_price: conv.rubTotalNum }
: { usdt_amount: conv.usdtFromRubNum, usdt_exchange_rate: 1, gas_fee: 1, total_price: conv.numRubInput }, : { usdt_amount: conv.usdtFromRubNum, usdt_exchange_rate: 1, gas_fee: 1, total_price: conv.numRubInput },
{
onError: () => setNotification({ status: 'error', message: ORDER_ERROR_MESSAGE }),
},
) )
} }
@@ -18,5 +27,14 @@ export function useConverterSection() {
? (!conv.rubTotal || isPending || !agreed) ? (!conv.rubTotal || isPending || !agreed)
: (!conv.usdtFromRub || isPending || !agreed || conv.rubBelowMin) : (!conv.usdtFromRub || isPending || !agreed || conv.rubBelowMin)
return { ...conv, agreed, setAgreed, onPay, isPending, isPayDisabled } return {
...conv,
agreed,
setAgreed,
onPay,
isPending,
isPayDisabled,
notification,
dismissNotification: () => setNotification(null),
}
} }

View File

@@ -1,4 +1,4 @@
import { ConvertField, DirectionSwapButton } from '@shared/ui' import { ConvertField, DirectionSwapButton, Notification } from '@shared/ui'
import { CommissionTable } from '@entities/commission' import { CommissionTable } from '@entities/commission'
import { useConverterSection } from '../model/useConverterSection' import { useConverterSection } from '../model/useConverterSection'
import { AgreementCheck } from './AgreementCheck' import { AgreementCheck } from './AgreementCheck'
@@ -6,6 +6,7 @@ import styles from './ConverterSection.module.css'
export function ConverterSection() { export function ConverterSection() {
const { const {
isUsdtToRub,
gasPriceRub, gasPriceRub,
configUsdtRate, configUsdtRate,
convert, convert,
@@ -17,6 +18,8 @@ export function ConverterSection() {
onPay, onPay,
isPending, isPending,
isPayDisabled, isPayDisabled,
notification,
dismissNotification,
} = useConverterSection() } = useConverterSection()
return ( return (
@@ -44,9 +47,9 @@ export function ConverterSection() {
</div> </div>
</div> </div>
<ConvertField label="Конвертируете" {...convert} /> <ConvertField label={isUsdtToRub ? 'Конвертируете' : 'Платите'} {...convert} />
<DirectionSwapButton onClick={onSwap} /> <DirectionSwapButton onClick={onSwap} />
<ConvertField label="Платите" {...pay} /> <ConvertField label={isUsdtToRub ? 'Платите' : 'Конвертируете'} {...pay} />
</div> </div>
<CommissionTable {...commission} /> <CommissionTable {...commission} />
@@ -64,6 +67,14 @@ export function ConverterSection() {
> >
{isPending ? 'Обработка...' : 'Оплатить'} {isPending ? 'Обработка...' : 'Оплатить'}
</button> </button>
{notification && (
<Notification
status={notification.status}
message={notification.message}
onClose={dismissNotification}
/>
)}
</div> </div>
) )
} }

View File

@@ -1 +1 @@
export { TokenTable } from './ui/TokenTable' export { TokenTable, AllTokenTable } from './ui/TokenTable'

View File

@@ -1,4 +1,5 @@
import { COIN_ICONS } from '@shared/assets/coins' import { COIN_ICONS } from '@shared/assets/coins'
import type { Chain } from '@features/wallet'
export interface Token { export interface Token {
ticker: string ticker: string
@@ -10,6 +11,10 @@ export interface Token {
bal: string bal: string
usd: string usd: string
fav: boolean 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. */
chain?: Chain
} }
export const TOKENS: readonly Token[] = [ export const TOKENS: readonly Token[] = [

View File

@@ -1,4 +1,4 @@
import { useWalletBalance } from '@features/wallet' import { useWalletBalance, usePortfolio, CHAINS } from '@features/wallet'
import type { Chain } from '@features/wallet' import type { Chain } from '@features/wallet'
import { getCoinIcon } from '@shared/assets/coins' import { getCoinIcon } from '@shared/assets/coins'
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals' import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
@@ -76,3 +76,65 @@ export function useChainTokenRows(chain: Chain) {
return { rows: [nativeRow, ...tokenRows], isLoading } return { rows: [nativeRow, ...tokenRows], isLoading }
} }
function hasBalance(amountFormatted: string): boolean {
return parseFloat(amountFormatted) > 0
}
function unitPrice(usd: number, amountFormatted: string): number | null {
const amount = parseFloat(amountFormatted)
return amount > 0 && usd > 0 ? usd / amount : null
}
export function useAllWalletTokenRows() {
const { data, isLoading } = usePortfolio()
if (!data) return { rows: [] as Token[], isLoading }
const rows: Token[] = []
for (const chain of CHAINS) {
const chainData = data.chains?.[chain]
if (!chainData) continue
const nativeTicker = NATIVE_TICKER[chain]
if (chainData.native && hasBalance(chainData.native.amountFormatted)) {
const nativeStatic = lookupStatic(nativeTicker)
rows.push({
id: `${chain}-${nativeTicker}`,
chain,
ticker: nativeTicker,
name: NATIVE_NAME[chain],
logo: getCoinIcon(nativeTicker) ?? nativeStatic?.logo,
color: nativeStatic?.color ?? DEFAULT_TOKEN_COLOR,
price: formatPrice(unitPrice(chainData.native.usd, chainData.native.amountFormatted)),
change: 0,
bal: truncateDecimals(chainData.native.amountFormatted),
usd: formatUsd(chainData.native.usd),
fav: false,
})
}
for (const token of chainData.tokens ?? []) {
if (!hasBalance(token.amountFormatted)) continue
const staticToken = lookupStatic(token.symbol)
rows.push({
id: `${chain}-${token.symbol}`,
chain,
ticker: token.symbol,
name: staticToken?.name ?? token.symbol,
logo: getCoinIcon(token.symbol) ?? staticToken?.logo,
color: staticToken?.color ?? DEFAULT_TOKEN_COLOR,
price: formatPrice(unitPrice(token.usd, token.amountFormatted)),
change: 0,
bal: truncateDecimals(token.amountFormatted),
usd: formatUsd(token.usd),
fav: false,
})
}
}
rows.sort((a, b) => (parseFloat(b.usd.replace(/[$,—]/g, '')) || 0) - (parseFloat(a.usd.replace(/[$,—]/g, '')) || 0))
return { rows, isLoading }
}

View File

@@ -1,15 +1,22 @@
import { useState } from 'react' import { useState } from 'react'
import { useChainTokenRows } from '../model/useChainTokenRows' import { useChainTokenRows, useAllWalletTokenRows } from '../model/useChainTokenRows'
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 styles from './TokenTable.module.css' import styles from './TokenTable.module.css'
interface Props { interface ViewProps {
chain: Chain rows: Token[]
isLoading: boolean
/** Network used when a row does not carry its own chain. */
fallbackChain?: Chain
} }
export function TokenTable({ chain }: Props) { function rowKey(t: Token): string {
const { rows, isLoading } = useChainTokenRows(chain) return t.id ?? t.ticker
}
function TokenTableView({ rows, isLoading, fallbackChain = 'ETH' }: 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,
@@ -20,26 +27,28 @@ export function TokenTable({ chain }: Props) {
chain: 'ETH', chain: 'ETH',
}) })
function openSend(ticker: string) { function resolveChain(row: Token): Chain {
const network = TICKER_TO_CHAIN[ticker] ?? chain return row.chain ?? TICKER_TO_CHAIN[row.ticker] ?? fallbackChain
setSendModal({ open: true, network }) }
function openSend(row: Token) {
setSendModal({ open: true, network: resolveChain(row) })
} }
function closeSend() { function closeSend() {
setSendModal((s) => ({ ...s, open: false })) setSendModal((s) => ({ ...s, open: false }))
} }
function openReceive(ticker: string) { function openReceive(row: Token) {
const target = TICKER_TO_CHAIN[ticker] ?? chain setReceiveModal({ open: true, chain: resolveChain(row) })
setReceiveModal({ open: true, chain: target })
} }
function closeReceive() { function closeReceive() {
setReceiveModal((s) => ({ ...s, open: false })) setReceiveModal((s) => ({ ...s, open: false }))
} }
function toggleFav(ticker: string) { function toggleFav(key: string) {
setFavs((prev) => ({ ...prev, [ticker]: !prev[ticker] })) setFavs((prev) => ({ ...prev, [key]: !prev[key] }))
} }
const sendIcon = ( const sendIcon = (
@@ -68,104 +77,110 @@ export function TokenTable({ chain }: Props) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((t) => ( {rows.map((t) => {
<tr key={t.ticker}> const key = rowKey(t)
<td> return (
<button <tr key={key}>
className={`${styles.star} ${favs[t.ticker] ? styles.starOn : ''}`} <td>
onClick={() => toggleFav(t.ticker)} <button
type="button" className={`${styles.star} ${favs[key] ? styles.starOn : ''}`}
aria-label={favs[t.ticker] ? 'Убрать из избранного' : 'В избранное'} onClick={() => toggleFav(key)}
> type="button"
aria-label={favs[key] ? 'Убрать из избранного' : 'В избранное'}
</button> >
</td>
<td> </button>
<div className={styles.tokId}> </td>
<div className={styles.tokLogo} style={{ background: t.color }}> <td>
{t.logo ? <img src={t.logo} alt={t.ticker} className={''} /> : t.ticker[0]} <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> </div>
</td>
<td className={styles.right}>
<span className={styles.price}>{t.price}</span>
</td>
<td className={styles.right}>
<div className={styles.balCol}> <div className={styles.balCol}>
<b className={styles.cardTicker}>{t.ticker}</b> <b>{t.bal}</b>
<span className={styles.noFont}>{t.name}</span> <span>{t.usd}</span>
</div> </div>
</div> </td>
</td> <td className={styles.center}>
<td className={styles.right}> <div className={styles.btnGroup}>
<span className={styles.price}>{t.price}</span> <button
</td> className={styles.receiveBtn}
<td className={styles.right}> type="button"
<div className={styles.balCol}> onClick={(e) => { e.stopPropagation(); openReceive(t) }}
<b>{t.bal}</b> >
<span>{t.usd}</span> {receiveIcon}
</div> Получить
</td> </button>
<td className={styles.center}> <button
<div className={styles.btnGroup}> className={styles.sendBtn}
<button type="button"
className={styles.receiveBtn} onClick={(e) => { e.stopPropagation(); openSend(t) }}
type="button" >
onClick={(e) => { e.stopPropagation(); openReceive(t.ticker) }} {sendIcon}
> Отправить
{receiveIcon} </button>
Получить </div>
</button> </td>
<button </tr>
className={styles.sendBtn} )
type="button" })}
onClick={(e) => { e.stopPropagation(); openSend(t.ticker) }}
>
{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) => ( {rows.map((t) => {
<div key={t.ticker} className={styles.card}> const key = rowKey(t)
<button return (
className={`${styles.star} ${favs[t.ticker] ? styles.starOn : ''}`} <div key={key} className={styles.card}>
onClick={() => toggleFav(t.ticker)} <button
type="button" className={`${styles.star} ${favs[key] ? styles.starOn : ''}`}
aria-label={favs[t.ticker] ? 'Убрать из избранного' : 'В избранное'} onClick={() => toggleFav(key)}
> type="button"
aria-label={favs[key] ? 'Убрать из избранного' : 'В избранное'}
</button> >
<div className={styles.tokLogo} style={{ background: t.color }}>
{t.logo </button>
? <img src={t.logo} alt={t.ticker} className={''} /> <div className={styles.tokLogo} style={{ background: t.color }}>
: t.ticker[0]} {t.logo
</div> ? <img src={t.logo} alt={t.ticker} className={''} />
<div className={styles.cardInfo}> : t.ticker[0]}
<div className={styles.cardTop}> </div>
<div> <div className={styles.cardInfo}>
<span className={styles.cardTicker}>{t.ticker}</span> <div className={styles.cardTop}>
<span className={styles.cardName}>{t.name}</span> <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>
<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>
</div> </div>
<div className={styles.mobileActions}> <div className={styles.mobileActions}>
<button className={styles.receiveBtn} type="button" onClick={() => openReceive(rows[0]?.ticker ?? '')}> <button className={styles.receiveBtn} type="button" onClick={() => rows[0] && openReceive(rows[0])}>
{receiveIcon} {receiveIcon}
Получить Получить
</button> </button>
<button className={styles.sendBtn} type="button" onClick={() => openSend(rows[0]?.ticker ?? '')}> <button className={styles.sendBtn} type="button" onClick={() => rows[0] && openSend(rows[0])}>
{sendIcon} {sendIcon}
Отправить Отправить
</button> </button>
@@ -185,3 +200,17 @@ export function TokenTable({ chain }: Props) {
</> </>
) )
} }
interface Props {
chain: Chain
}
export function TokenTable({ chain }: Props) {
const { rows, isLoading } = useChainTokenRows(chain)
return <TokenTableView rows={rows} isLoading={isLoading} fallbackChain={chain} />
}
export function AllTokenTable() {
const { rows, isLoading } = useAllWalletTokenRows()
return <TokenTableView rows={rows} isLoading={isLoading} />
}

View File

@@ -17,9 +17,25 @@ const TABS: TabItem[] = [
{ chain: 'BSC', label: 'BSC', icon: COIN_ICONS.BNB }, { chain: 'BSC', label: 'BSC', icon: COIN_ICONS.BNB },
] ]
const allCoinsIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="6" rx="8" ry="3" />
<path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6" />
<path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6" />
</svg>
)
export function WalletChainTabs() { export function WalletChainTabs() {
return ( return (
<div className={styles.tabs}> <div className={styles.tabs}>
<NavLink
to="/wallet"
end
className={({ isActive }) => `${styles.tab} ${isActive ? styles.active : ''}`}
>
<span className={styles.icon}>{allCoinsIcon}</span>
<span>Все монеты</span>
</NavLink>
{TABS.map((t) => ( {TABS.map((t) => (
<NavLink <NavLink
key={t.chain} key={t.chain}

File diff suppressed because one or more lines are too long