14.05.2026 rip

This commit is contained in:
2026-05-14 18:30:57 +03:00
parent 22bb446309
commit 2de30fbde6
14 changed files with 415 additions and 78 deletions

File diff suppressed because one or more lines are too long

60
dist/assets/index-CHC6dclK.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-DdoCSUef.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

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-Hdj79d7b.js"></script> <script type="module" crossorigin src="/assets/index-CHC6dclK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-3J8Zo9Sf.css"> <link rel="stylesheet" crossorigin href="/assets/index-DdoCSUef.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -35,6 +35,12 @@ export interface SendWalletResponse {
data: { txid: string; chain: Chain } data: { txid: string; chain: Chain }
} }
export interface WalletAddress {
chain: Chain
address: string
derivationPath: string
}
export const CHAINS: Chain[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'] export const CHAINS: Chain[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL']
async function walletGet<T>(path: string, allowRetry: boolean = true): Promise<T> { async function walletGet<T>(path: string, allowRetry: boolean = true): Promise<T> {
@@ -94,6 +100,11 @@ async function walletPost<T>(path: string, body: unknown, allowRetry: boolean =
return data as T return data as T
} }
export async function getWalletAddresses(): Promise<WalletAddress[]> {
const res = await walletGet<{ success: boolean; data: WalletAddress[] }>('/api/wallets')
return res.data
}
export async function getWalletBalance(chain: Chain): Promise<WalletBalanceData> { export async function getWalletBalance(chain: Chain): Promise<WalletBalanceData> {
const res = await walletGet<{ success: boolean; data: WalletBalanceData }>(`/api/wallets/${chain}/balance`) const res = await walletGet<{ success: boolean; data: WalletBalanceData }>(`/api/wallets/${chain}/balance`)
return res.data return res.data

View File

@@ -1,3 +1,3 @@
export { useAllWalletBalances, usePrices, useSendWallet } from './model/useWalletData' export { useAllWalletBalances, usePrices, useSendWallet, useWalletAddresses } from './model/useWalletData'
export type { Chain, FormattedAmount, WalletBalanceData, PriceEntry, SendWalletPayload, SendWalletResponse } from './api/walletApi' export type { Chain, FormattedAmount, WalletBalanceData, PriceEntry, SendWalletPayload, SendWalletResponse, WalletAddress } from './api/walletApi'
export { CHAINS } from './api/walletApi' export { CHAINS } from './api/walletApi'

View File

@@ -1,5 +1,5 @@
import { useQuery, useQueries, useMutation } from '@tanstack/react-query' import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
import { getWalletBalance, getPrices, sendWallet, CHAINS, type Chain, type SendWalletPayload } from '../api/walletApi' import { getWalletBalance, getPrices, sendWallet, getWalletAddresses, CHAINS, type Chain, type SendWalletPayload } from '../api/walletApi'
export function useAllWalletBalances() { export function useAllWalletBalances() {
return useQueries({ return useQueries({
@@ -25,3 +25,11 @@ export function useSendWallet() {
sendWallet(chain, payload), sendWallet(chain, payload),
}) })
} }
export function useWalletAddresses() {
return useQuery({
queryKey: ['wallet', 'addresses'],
queryFn: getWalletAddresses,
staleTime: 10 * 60 * 1000,
})
}

View File

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

View File

@@ -0,0 +1,151 @@
@keyframes dialogIn {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
}
.dialog {
background: var(--bg-mid, #151520);
border: 1px solid var(--glass-border, rgba(255,255,255,0.1));
border-radius: 20px;
width: 100%;
max-width: 480px;
animation: dialogIn 0.18s ease;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 0;
}
.title {
font-size: 17px;
font-weight: 700;
color: var(--text-primary, #fff);
}
.closeBtn {
background: none;
border: none;
color: var(--text-secondary, rgba(255,255,255,0.4));
font-size: 16px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.15s;
}
.closeBtn:hover {
color: var(--text-primary, #fff);
}
.body {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.label {
font-size: 13px;
color: var(--text-secondary, rgba(255,255,255,0.5));
font-weight: 500;
}
.fieldRow {
display: flex;
gap: 8px;
}
.addressInput {
flex: 1;
min-width: 0;
background: var(--glass-bg, rgba(255,255,255,0.05));
border: 1px solid var(--glass-border, rgba(255,255,255,0.1));
border-radius: 10px;
color: var(--text-primary, #fff);
font-family: var(--font-mono, monospace);
font-size: 13px;
padding: 10px 14px;
outline: none;
cursor: text;
transition: border-color 0.15s;
}
.addressInput:focus {
border-color: var(--interactive, #4a6dff);
}
.copyBtn {
flex-shrink: 0;
height: 40px;
padding: 0 16px;
background: rgba(0, 196, 140, 0.12);
border: 1px solid rgba(0, 196, 140, 0.3);
color: #00c48c;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
white-space: nowrap;
}
.copyBtn:hover:not(:disabled) {
background: rgba(0, 196, 140, 0.25);
border-color: #00c48c;
}
.copyBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.copyBtnDone {
background: rgba(0, 196, 140, 0.25);
border-color: #00c48c;
}
.skeleton {
height: 40px;
border-radius: 10px;
background: linear-gradient(90deg, rgba(255,255,255,0.05) 25%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.error {
font-size: 13px;
color: #ff4d4d;
margin: 0;
}
@media (max-width: 520px) {
.fieldRow {
flex-direction: column;
}
.copyBtn {
width: 100%;
height: 40px;
}
}

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { useWalletAddresses, type Chain } from '@features/wallet'
import styles from './ReceiveModal.module.css'
const CHAIN_LABEL: Record<Chain, string> = {
ETH: 'Ethereum',
BSC: 'BNB Smart Chain',
BTC: 'Bitcoin',
TRX: 'Tron',
SOL: 'Solana',
}
interface ReceiveModalProps {
open: boolean
onClose: () => void
chain: Chain
}
export function ReceiveModal({ open, onClose, chain }: ReceiveModalProps) {
const { data: addresses, isLoading, isError } = useWalletAddresses()
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!open) return
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
useEffect(() => {
if (!open) setCopied(false)
}, [open])
if (!open) return null
const entry = addresses?.find(a => a.chain === chain)
const address = entry?.address ?? ''
function handleCopy() {
if (!address) return
navigator.clipboard.writeText(address).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
function handleOverlay(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose()
}
return (
<div className={styles.overlay} onMouseDown={handleOverlay}>
<div className={styles.dialog}>
<div className={styles.header}>
<span className={styles.title}>Получить {CHAIN_LABEL[chain]}</span>
<button className={styles.closeBtn} type="button" onClick={onClose} aria-label="Закрыть"></button>
</div>
<div className={styles.body}>
{isLoading && (
<div className={styles.skeleton} />
)}
{isError && (
<p className={styles.error}>Не удалось загрузить адрес. Попробуйте позже.</p>
)}
{!isLoading && !isError && (
<>
<label className={styles.label}>Ваш {chain}-адрес</label>
<div className={styles.fieldRow}>
<input
className={styles.addressInput}
type="text"
readOnly
value={address}
onFocus={e => e.target.select()}
/>
<button
className={`${styles.copyBtn} ${copied ? styles.copyBtnDone : ''}`}
type="button"
onClick={handleCopy}
disabled={!address}
>
{copied ? 'Скопировано!' : 'Копировать'}
</button>
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -51,7 +51,13 @@
.center { .center {
text-align: center !important; text-align: center !important;
width: 140px; width: 260px;
}
.btnGroup {
display: inline-flex;
align-items: center;
gap: 8px;
} }
.star { .star {
@@ -177,6 +183,29 @@
border-color: #4a6dff; border-color: #4a6dff;
} }
.receiveBtn {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(0, 196, 140, 0.12);
border: 1px solid rgba(0, 196, 140, 0.3);
color: #00c48c;
border-radius: 10px;
height: 36px;
min-width: 120px;
justify-content: center;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.receiveBtn:hover {
background: rgba(0, 196, 140, 0.25);
border-color: #00c48c;
}
.noFont { .noFont {
font-family: inherit !important; font-family: inherit !important;
} }
@@ -276,6 +305,7 @@
.mobileActions { .mobileActions {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 10px;
padding: 16px 0; padding: 16px 0;
} }
@@ -293,8 +323,9 @@
border-radius: 4px; border-radius: 4px;
} }
.sendBtn { .sendBtn,
width: 100%; .receiveBtn {
flex: 1;
font-size: 14px; font-size: 14px;
} }
} }

View File

@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useTokenRows } from '../model/useTokenRows' import { useTokenRows } from '../model/useTokenRows'
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 styles from './TokenTable.module.css' import styles from './TokenTable.module.css'
export function TokenTable() { export function TokenTable() {
@@ -10,6 +11,10 @@ export function TokenTable() {
open: false, open: false,
network: 'ETH', network: 'ETH',
}) })
const [receiveModal, setReceiveModal] = useState<{ open: boolean; chain: Chain }>({
open: false,
chain: 'ETH',
})
function openSend(ticker: string) { function openSend(ticker: string) {
const network = TICKER_TO_CHAIN[ticker] ?? 'ETH' const network = TICKER_TO_CHAIN[ticker] ?? 'ETH'
@@ -20,6 +25,15 @@ export function TokenTable() {
setSendModal((s) => ({ ...s, open: false })) setSendModal((s) => ({ ...s, open: false }))
} }
function openReceive(ticker: string) {
const chain = TICKER_TO_CHAIN[ticker] ?? 'ETH'
setReceiveModal({ open: true, chain })
}
function closeReceive() {
setReceiveModal((s) => ({ ...s, open: false }))
}
function toggleFav(i: number) { function toggleFav(i: number) {
setFavs((prev) => prev.map((v, idx) => (idx === i ? !v : v))) setFavs((prev) => prev.map((v, idx) => (idx === i ? !v : v)))
} }
@@ -30,6 +44,12 @@ export function TokenTable() {
</svg> </svg>
) )
const receiveIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#00C48C" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 7L7 17M7 17H17M7 17V7" />
</svg>
)
return ( return (
<> <>
<div className={`${styles.wrap} ${isLoading ? styles.loading : ''}`}> <div className={`${styles.wrap} ${isLoading ? styles.loading : ''}`}>
@@ -83,14 +103,24 @@ export function TokenTable() {
</div> </div>
</td> </td>
<td className={styles.center}> <td className={styles.center}>
<button <div className={styles.btnGroup}>
className={styles.sendBtn} <button
type="button" className={styles.receiveBtn}
onClick={(e) => { e.stopPropagation(); openSend(t.ticker) }} type="button"
> onClick={(e) => { e.stopPropagation(); openReceive(t.ticker) }}
{sendIcon} >
Отправить {receiveIcon}
</button> Получить
</button>
<button
className={styles.sendBtn}
type="button"
onClick={(e) => { e.stopPropagation(); openSend(t.ticker) }}
>
{sendIcon}
Отправить
</button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -138,6 +168,10 @@ export function TokenTable() {
</div> </div>
<div className={styles.mobileActions}> <div className={styles.mobileActions}>
<button className={styles.receiveBtn} type="button" onClick={() => openReceive(rows[0]?.ticker ?? '')}>
{receiveIcon}
Получить
</button>
<button className={styles.sendBtn} type="button" onClick={() => openSend(rows[0]?.ticker ?? '')}> <button className={styles.sendBtn} type="button" onClick={() => openSend(rows[0]?.ticker ?? '')}>
{sendIcon} {sendIcon}
Отправить Отправить
@@ -150,6 +184,11 @@ export function TokenTable() {
network={sendModal.network} network={sendModal.network}
tokens={rows} tokens={rows}
/> />
<ReceiveModal
open={receiveModal.open}
onClose={closeReceive}
chain={receiveModal.chain}
/>
</> </>
) )
} }

File diff suppressed because one or more lines are too long