14.05.2026 rip
This commit is contained in:
@@ -24,6 +24,17 @@ export interface PriceEntry {
|
|||||||
usd: number
|
usd: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendWalletPayload {
|
||||||
|
to: string
|
||||||
|
amount: string
|
||||||
|
token?: string
|
||||||
|
feeTier?: 'slow' | 'normal' | 'fast'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendWalletResponse {
|
||||||
|
data: { txid: string; chain: Chain }
|
||||||
|
}
|
||||||
|
|
||||||
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> {
|
||||||
@@ -53,6 +64,36 @@ async function walletGet<T>(path: string, allowRetry: boolean = true): Promise<T
|
|||||||
return data as T
|
return data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function walletPost<T>(path: string, body: unknown, allowRetry: boolean = true): Promise<T> {
|
||||||
|
const csrf = await getCsrfToken()
|
||||||
|
const bearer = tokenStore.get()
|
||||||
|
|
||||||
|
const res = await fetch(`${WALLET_API_URL}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrf,
|
||||||
|
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401 && allowRetry) {
|
||||||
|
try {
|
||||||
|
await refreshAccessToken()
|
||||||
|
return walletPost<T>(path, body, false)
|
||||||
|
} catch {
|
||||||
|
tokenStore.clear()
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw data
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -64,3 +105,7 @@ export async function getPrices(symbols: string[]): Promise<Record<string, Price
|
|||||||
)
|
)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendWallet(chain: Chain, payload: SendWalletPayload): Promise<SendWalletResponse> {
|
||||||
|
return walletPost<SendWalletResponse>(`/api/wallets/${chain}/send`, payload)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { useAllWalletBalances, usePrices } from './model/useWalletData'
|
export { useAllWalletBalances, usePrices, useSendWallet } from './model/useWalletData'
|
||||||
export type { Chain, FormattedAmount, WalletBalanceData, PriceEntry } from './api/walletApi'
|
export type { Chain, FormattedAmount, WalletBalanceData, PriceEntry, SendWalletPayload, SendWalletResponse } from './api/walletApi'
|
||||||
export { CHAINS } from './api/walletApi'
|
export { CHAINS } from './api/walletApi'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useQueries } from '@tanstack/react-query'
|
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
||||||
import { getWalletBalance, getPrices, CHAINS } from '../api/walletApi'
|
import { getWalletBalance, getPrices, sendWallet, CHAINS, type Chain, type SendWalletPayload } from '../api/walletApi'
|
||||||
|
|
||||||
export function useAllWalletBalances() {
|
export function useAllWalletBalances() {
|
||||||
return useQueries({
|
return useQueries({
|
||||||
@@ -18,3 +18,10 @@ export function usePrices(symbols: string[]) {
|
|||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSendWallet() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ chain, ...payload }: { chain: Chain } & SendWalletPayload) =>
|
||||||
|
sendWallet(chain, payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
export { SendModal } from './ui/SendModal'
|
export { SendModal } from './ui/SendModal'
|
||||||
export type { SendModalToken } from './ui/SendModal'
|
export type { SendModalToken } from './ui/SendModal'
|
||||||
|
export type { Chain, FeeTier, SendRequest, SendResponse, SendEthRequest, SendBscRequest, SendBtcRequest, SendTrxRequest, SendSolRequest } from './model/sendTypes'
|
||||||
|
export { CHAIN_CONFIG, CHAIN_TOKENS, TICKER_TO_CHAIN } from './model/sendTypes'
|
||||||
|
|||||||
150
src/widgets/send-modal/model/sendTypes.ts
Normal file
150
src/widgets/send-modal/model/sendTypes.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// ─── Chain identifiers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type Chain = 'ETH' | 'BSC' | 'BTC' | 'TRX' | 'SOL'
|
||||||
|
export type FeeTier = 'slow' | 'normal' | 'fast'
|
||||||
|
|
||||||
|
// ─── Per-chain token lists (excluding native) ────────────────────────────────
|
||||||
|
|
||||||
|
export type EthToken = 'USDT' | 'USDC' | 'DAI' | 'WBTC' | 'LINK' | 'UNI'
|
||||||
|
export type BscToken = 'USDT' | 'USDC' | 'BUSD' | 'WBNB' | 'DOGE'
|
||||||
|
export type TrxToken = 'USDT' | 'USDC'
|
||||||
|
// SOL has 14 SPL tokens in the registry
|
||||||
|
export type SolToken =
|
||||||
|
| 'USDC' | 'USDT' | 'RAY' | 'BONK' | 'JTO' | 'PYTH'
|
||||||
|
| 'WIF' | 'JUP' | 'ORCA' | 'MNGO' | 'MSOL' | 'STSOL'
|
||||||
|
| 'SAMO' | 'ATLAS'
|
||||||
|
|
||||||
|
export const CHAIN_TOKENS = {
|
||||||
|
ETH: ['USDT', 'USDC', 'DAI', 'WBTC', 'LINK', 'UNI'] as const satisfies readonly EthToken[],
|
||||||
|
BSC: ['USDT', 'USDC', 'BUSD', 'WBNB', 'DOGE'] as const satisfies readonly BscToken[],
|
||||||
|
BTC: [] as const,
|
||||||
|
TRX: ['USDT', 'USDC'] as const satisfies readonly TrxToken[],
|
||||||
|
SOL: ['USDC', 'USDT', 'RAY', 'BONK', 'JTO', 'PYTH', 'WIF', 'JUP', 'ORCA', 'MNGO', 'MSOL', 'STSOL', 'SAMO', 'ATLAS'] as const satisfies readonly SolToken[],
|
||||||
|
} satisfies Record<Chain, readonly string[]>
|
||||||
|
|
||||||
|
// ─── Per-chain UI / behaviour config ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ChainConfig {
|
||||||
|
label: string
|
||||||
|
nativeSymbol: string
|
||||||
|
/** Whether the chain supports token-level sends (ERC20 / BEP20 / TRC20 / SPL) */
|
||||||
|
hasToken: boolean
|
||||||
|
/** Whether the API accepts a feeTier parameter */
|
||||||
|
hasFeeTier: boolean
|
||||||
|
/** Placeholder for the recipient address input */
|
||||||
|
addressPlaceholder: string
|
||||||
|
/** Smallest amount unit name (for tooltip / docs) */
|
||||||
|
unit: string
|
||||||
|
/** Accent color used for the chain dot in dropdowns */
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHAIN_CONFIG: Record<Chain, ChainConfig> = {
|
||||||
|
ETH: {
|
||||||
|
label: 'Ethereum',
|
||||||
|
nativeSymbol: 'ETH',
|
||||||
|
hasToken: true,
|
||||||
|
hasFeeTier: true,
|
||||||
|
addressPlaceholder: '0x…',
|
||||||
|
unit: 'wei',
|
||||||
|
color: '#627EEA',
|
||||||
|
},
|
||||||
|
BSC: {
|
||||||
|
label: 'BNB Chain',
|
||||||
|
nativeSymbol: 'BNB',
|
||||||
|
hasToken: true,
|
||||||
|
hasFeeTier: false,
|
||||||
|
addressPlaceholder: '0x…',
|
||||||
|
unit: 'wei',
|
||||||
|
color: '#F3BA2F',
|
||||||
|
},
|
||||||
|
BTC: {
|
||||||
|
label: 'Bitcoin',
|
||||||
|
nativeSymbol: 'BTC',
|
||||||
|
hasToken: false,
|
||||||
|
hasFeeTier: true,
|
||||||
|
addressPlaceholder: 'bc1q…',
|
||||||
|
unit: 'satoshi',
|
||||||
|
color: '#F7931A',
|
||||||
|
},
|
||||||
|
TRX: {
|
||||||
|
label: 'Tron',
|
||||||
|
nativeSymbol: 'TRX',
|
||||||
|
hasToken: true,
|
||||||
|
hasFeeTier: false,
|
||||||
|
addressPlaceholder: 'T…',
|
||||||
|
unit: 'sun',
|
||||||
|
color: '#FF060A',
|
||||||
|
},
|
||||||
|
SOL: {
|
||||||
|
label: 'Solana',
|
||||||
|
nativeSymbol: 'SOL',
|
||||||
|
hasToken: true,
|
||||||
|
hasFeeTier: false,
|
||||||
|
addressPlaceholder: 'Fg3R…',
|
||||||
|
unit: 'lamport',
|
||||||
|
color: '#9945FF',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wallet ticker → chain mapping (for TokenTable integration) ──────────────
|
||||||
|
// ARB is an ERC-20 on Ethereum, not a separate chain.
|
||||||
|
|
||||||
|
export const TICKER_TO_CHAIN: Record<string, Chain> = {
|
||||||
|
BTC: 'BTC',
|
||||||
|
ETH: 'ETH',
|
||||||
|
SOL: 'SOL',
|
||||||
|
TRX: 'TRX',
|
||||||
|
BNB: 'BSC',
|
||||||
|
ARB: 'ETH',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API request / response types ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SendEthRequest {
|
||||||
|
to: string
|
||||||
|
amount: string // raw wei / token base units (string to avoid JS precision loss)
|
||||||
|
token?: EthToken
|
||||||
|
feeTier?: FeeTier
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendBscRequest {
|
||||||
|
to: string
|
||||||
|
amount: string // raw wei / 18-dec base units (DOGE = 8 dec)
|
||||||
|
token?: BscToken
|
||||||
|
// ⚠️ USDT on BSC = 18 dec, not 6 like on ETH/TRX. DOGE = 8 dec.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendBtcRequest {
|
||||||
|
to: string
|
||||||
|
amount: string // satoshi (8 dec). Min dust = 294 sat. Floor fee = 2 sat/vB.
|
||||||
|
feeTier?: FeeTier // slow = ~144 blocks, normal = ~6, fast = ~1
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendTrxRequest {
|
||||||
|
to: string
|
||||||
|
amount: string // sun (6 dec for TRX; TRC-20 USDT also 6 dec)
|
||||||
|
token?: TrxToken
|
||||||
|
// ⚠️ TRC-20 transfer burns ~15–30 TRX Energy when no frozen Energy available.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendSolRequest {
|
||||||
|
to: string // base58 public key
|
||||||
|
amount: string // lamports (9 dec for SOL)
|
||||||
|
token?: SolToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discriminated union — pick the right one based on `chain`
|
||||||
|
export type SendRequest =
|
||||||
|
| ({ chain: 'ETH' } & SendEthRequest)
|
||||||
|
| ({ chain: 'BSC' } & SendBscRequest)
|
||||||
|
| ({ chain: 'BTC' } & SendBtcRequest)
|
||||||
|
| ({ chain: 'TRX' } & SendTrxRequest)
|
||||||
|
| ({ chain: 'SOL' } & SendSolRequest)
|
||||||
|
|
||||||
|
export interface SendResponse {
|
||||||
|
data: {
|
||||||
|
txid: string
|
||||||
|
chain: Chain
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -328,6 +328,19 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Error message ─────────────────────────── */
|
||||||
|
|
||||||
|
.errorMsg {
|
||||||
|
background: rgba(255, 68, 102, 0.1);
|
||||||
|
border: 1px solid rgba(255, 68, 102, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--error);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Submit button ─────────────────────────── */
|
/* ─── Submit button ─────────────────────────── */
|
||||||
|
|
||||||
.submitBtn {
|
.submitBtn {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSendWallet } from '@features/wallet'
|
||||||
|
import { CHAIN_CONFIG, CHAIN_TOKENS, type Chain, type FeeTier } from '../model/sendTypes'
|
||||||
import styles from './SendModal.module.css'
|
import styles from './SendModal.module.css'
|
||||||
|
|
||||||
export interface SendModalToken {
|
export interface SendModalToken {
|
||||||
@@ -12,61 +14,100 @@ export interface SendModalToken {
|
|||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
tokens: SendModalToken[]
|
network: Chain
|
||||||
initialTicker?: string
|
/** Wallet token rows — used only for balance lookup in the "Макс" hint */
|
||||||
|
tokens?: SendModalToken[]
|
||||||
|
/** Pre-select a token in the network token list (ticker or empty string = native) */
|
||||||
|
initialToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Speed = 'slow' | 'normal' | 'fast'
|
const SPEEDS: { value: FeeTier; label: string }[] = [
|
||||||
|
{ value: 'slow', label: 'Медленно' },
|
||||||
const SPEEDS: { value: Speed; label: string }[] = [
|
|
||||||
{ value: 'slow', label: 'Медленно' },
|
|
||||||
{ value: 'normal', label: 'Нормально' },
|
{ value: 'normal', label: 'Нормально' },
|
||||||
{ value: 'fast', label: 'Быстро' },
|
{ value: 'fast', label: 'Быстро' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function SendModal({ open, onClose, tokens, initialTicker }: Props) {
|
function extractErrorMessage(err: unknown): string {
|
||||||
const [ticker, setTicker] = useState(initialTicker ?? tokens[0]?.ticker ?? '')
|
if (err instanceof Error) return err.message
|
||||||
const [speed, setSpeed] = useState<Speed>('normal')
|
if (err && typeof err === 'object') {
|
||||||
const [address, setAddress] = useState('')
|
const e = err as Record<string, unknown>
|
||||||
const [amount, setAmount] = useState('')
|
if (typeof e.message === 'string') return e.message
|
||||||
const [openDropdown, setOpenDropdown] = useState<'token' | 'speed' | null>(null)
|
if (typeof e.error === 'string') return e.error
|
||||||
|
if (Array.isArray(e.detail) && (e.detail[0] as Record<string, unknown>)?.msg)
|
||||||
|
return String((e.detail[0] as Record<string, unknown>).msg)
|
||||||
|
}
|
||||||
|
return 'Что-то пошло не так. Попробуйте ещё раз.'
|
||||||
|
}
|
||||||
|
|
||||||
const token = tokens.find((t) => t.ticker === ticker) ?? tokens[0]
|
export function SendModal({ open, onClose, network, tokens = [], initialToken = '' }: Props) {
|
||||||
const speedLabel = SPEEDS.find((s) => s.value === speed)?.label ?? 'Нормально'
|
const cfg = CHAIN_CONFIG[network]
|
||||||
|
const networkTokens = CHAIN_TOKENS[network] as readonly string[]
|
||||||
|
|
||||||
|
const [selectedToken, setSelectedToken] = useState(initialToken)
|
||||||
|
const [speed, setSpeed] = useState<FeeTier>('normal')
|
||||||
|
const [address, setAddress] = useState('')
|
||||||
|
const [amount, setAmount] = useState('')
|
||||||
|
const [openDropdown, setOpenDropdown] = useState<'token' | 'speed' | null>(null)
|
||||||
|
|
||||||
|
const mutation = useSendWallet()
|
||||||
|
|
||||||
|
const speedLabel = SPEEDS.find((s) => s.value === speed)?.label ?? 'Нормально'
|
||||||
|
const tokenLabel = selectedToken === '' ? cfg.nativeSymbol : selectedToken
|
||||||
|
const walletMatch = tokens.find(
|
||||||
|
(t) => t.ticker === (selectedToken === '' ? cfg.nativeSymbol : selectedToken),
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => { setSelectedToken(initialToken) }, [initialToken])
|
||||||
|
useEffect(() => { setSelectedToken('') }, [network])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialTicker) setTicker(initialTicker)
|
if (mutation.isSuccess) onClose()
|
||||||
}, [initialTicker])
|
}, [mutation.isSuccess, onClose])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setAddress('')
|
setAddress('')
|
||||||
setAmount('')
|
setAmount('')
|
||||||
setOpenDropdown(null)
|
setOpenDropdown(null)
|
||||||
|
mutation.reset()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||||
if (e.key === 'Escape') onClose()
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handler)
|
document.addEventListener('keydown', handler)
|
||||||
return () => document.removeEventListener('keydown', handler)
|
return () => document.removeEventListener('keydown', handler)
|
||||||
}, [open, onClose])
|
}, [open, onClose]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
|
const showToken = cfg.hasToken
|
||||||
|
const showFeeTier = cfg.hasFeeTier
|
||||||
|
const showSelects = showToken || showFeeTier
|
||||||
|
const selectsCols = showToken && showFeeTier ? 2 : 1
|
||||||
|
|
||||||
function handleOverlayClick() {
|
function handleOverlayClick() {
|
||||||
if (openDropdown) {
|
if (openDropdown) { setOpenDropdown(null) } else { onClose() }
|
||||||
setOpenDropdown(null)
|
}
|
||||||
} else {
|
|
||||||
onClose()
|
function handleSubmit() {
|
||||||
}
|
mutation.mutate({
|
||||||
|
chain: network,
|
||||||
|
to: address,
|
||||||
|
amount,
|
||||||
|
...(selectedToken ? { token: selectedToken } : {}),
|
||||||
|
...(cfg.hasFeeTier ? { feeTier: speed } : {}),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.overlay} onClick={handleOverlayClick}>
|
<div className={styles.overlay} onClick={handleOverlayClick}>
|
||||||
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<span className={styles.title}>Отправить</span>
|
<div className={styles.headerLeft}>
|
||||||
|
<span className={styles.chainDot} style={{ background: cfg.color }} />
|
||||||
|
<span className={styles.title}>Отправить · {cfg.label}</span>
|
||||||
|
</div>
|
||||||
<button className={styles.close} onClick={onClose} type="button" aria-label="Закрыть">
|
<button className={styles.close} onClick={onClose} type="button" aria-label="Закрыть">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
<path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
<path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
@@ -74,93 +115,106 @@ export function SendModal({ open, onClose, tokens, initialTicker }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.selectsRow}>
|
{/* Token + Speed selects */}
|
||||||
<div className={styles.selectGroup}>
|
{showSelects && (
|
||||||
<label className={styles.selectLabel}>Токен</label>
|
<div
|
||||||
<button
|
className={styles.selectsRow}
|
||||||
type="button"
|
style={{ gridTemplateColumns: `repeat(${selectsCols}, 1fr)` }}
|
||||||
className={`${styles.selectTrigger} ${openDropdown === 'token' ? styles.selectTriggerOpen : ''}`}
|
>
|
||||||
onClick={() => setOpenDropdown((d) => (d === 'token' ? null : 'token'))}
|
{showToken && (
|
||||||
>
|
<div className={styles.selectGroup}>
|
||||||
{token && (
|
<label className={styles.selectLabel}>Токен</label>
|
||||||
<span className={styles.tokenDot} style={{ background: token.color }}>
|
<button
|
||||||
{token.logo ? <img src={token.logo} alt={token.ticker} /> : token.ticker[0]}
|
type="button"
|
||||||
</span>
|
className={`${styles.selectTrigger} ${openDropdown === 'token' ? styles.selectTriggerOpen : ''}`}
|
||||||
)}
|
onClick={() => setOpenDropdown((d) => (d === 'token' ? null : 'token'))}
|
||||||
<span className={styles.selectValue}>{token?.ticker ?? '—'}</span>
|
>
|
||||||
<svg
|
<span className={styles.tokenDot} style={{ background: cfg.color }}>
|
||||||
className={`${styles.chevron} ${openDropdown === 'token' ? styles.chevronOpen : ''}`}
|
{tokenLabel[0]}
|
||||||
width="12"
|
</span>
|
||||||
height="12"
|
<span className={styles.selectValue}>{tokenLabel}</span>
|
||||||
viewBox="0 0 12 12"
|
<svg
|
||||||
fill="none"
|
className={`${styles.chevron} ${openDropdown === 'token' ? styles.chevronOpen : ''}`}
|
||||||
>
|
width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||||
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{openDropdown === 'token' && (
|
|
||||||
<div className={styles.dropdown}>
|
|
||||||
{tokens.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.ticker}
|
|
||||||
type="button"
|
|
||||||
className={`${styles.dropdownItem} ${t.ticker === ticker ? styles.dropdownItemActive : ''}`}
|
|
||||||
onClick={() => { setTicker(t.ticker); setOpenDropdown(null) }}
|
|
||||||
>
|
>
|
||||||
<span className={styles.tokenDot} style={{ background: t.color }}>
|
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
{t.logo ? <img src={t.logo} alt={t.ticker} /> : t.ticker[0]}
|
</svg>
|
||||||
</span>
|
</button>
|
||||||
<span className={styles.dropdownTicker}>{t.ticker}</span>
|
{openDropdown === 'token' && (
|
||||||
<span className={styles.dropdownName}>{t.name}</span>
|
<div className={styles.dropdown}>
|
||||||
</button>
|
<button
|
||||||
))}
|
type="button"
|
||||||
|
className={`${styles.dropdownItem} ${selectedToken === '' ? styles.dropdownItemActive : ''}`}
|
||||||
|
onClick={() => { setSelectedToken(''); setOpenDropdown(null) }}
|
||||||
|
>
|
||||||
|
<span className={styles.tokenDot} style={{ background: cfg.color }}>
|
||||||
|
{cfg.nativeSymbol[0]}
|
||||||
|
</span>
|
||||||
|
<span className={styles.dropdownTicker}>{cfg.nativeSymbol}</span>
|
||||||
|
<span className={styles.dropdownName}>Нативный</span>
|
||||||
|
</button>
|
||||||
|
{networkTokens.map((tok) => (
|
||||||
|
<button
|
||||||
|
key={tok}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.dropdownItem} ${selectedToken === tok ? styles.dropdownItemActive : ''}`}
|
||||||
|
onClick={() => { setSelectedToken(tok); setOpenDropdown(null) }}
|
||||||
|
>
|
||||||
|
<span className={styles.tokenDot} style={{ background: 'rgba(255,255,255,0.12)' }}>
|
||||||
|
{tok[0]}
|
||||||
|
</span>
|
||||||
|
<span className={styles.dropdownTicker}>{tok}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFeeTier && (
|
||||||
|
<div className={styles.selectGroup}>
|
||||||
|
<label className={styles.selectLabel}>Скорость</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.selectTrigger} ${openDropdown === 'speed' ? styles.selectTriggerOpen : ''}`}
|
||||||
|
onClick={() => setOpenDropdown((d) => (d === 'speed' ? null : 'speed'))}
|
||||||
|
>
|
||||||
|
<span className={`${styles.speedDot} ${styles[`speedDot_${speed}`]}`} />
|
||||||
|
<span className={styles.selectValue}>{speedLabel}</span>
|
||||||
|
<svg
|
||||||
|
className={`${styles.chevron} ${openDropdown === 'speed' ? styles.chevronOpen : ''}`}
|
||||||
|
width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||||
|
>
|
||||||
|
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{openDropdown === 'speed' && (
|
||||||
|
<div className={styles.dropdown}>
|
||||||
|
{SPEEDS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.dropdownItem} ${s.value === speed ? styles.dropdownItemActive : ''}`}
|
||||||
|
onClick={() => { setSpeed(s.value); setOpenDropdown(null) }}
|
||||||
|
>
|
||||||
|
<span className={`${styles.speedDot} ${styles[`speedDot_${s.value}`]}`} />
|
||||||
|
<span>{s.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.selectGroup}>
|
{/* Address */}
|
||||||
<label className={styles.selectLabel}>Скорость</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.selectTrigger} ${openDropdown === 'speed' ? styles.selectTriggerOpen : ''}`}
|
|
||||||
onClick={() => setOpenDropdown((d) => (d === 'speed' ? null : 'speed'))}
|
|
||||||
>
|
|
||||||
<span className={`${styles.speedDot} ${styles[`speedDot_${speed}`]}`} />
|
|
||||||
<span className={styles.selectValue}>{speedLabel}</span>
|
|
||||||
<svg
|
|
||||||
className={`${styles.chevron} ${openDropdown === 'speed' ? styles.chevronOpen : ''}`}
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 12 12"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{openDropdown === 'speed' && (
|
|
||||||
<div className={styles.dropdown}>
|
|
||||||
{SPEEDS.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s.value}
|
|
||||||
type="button"
|
|
||||||
className={`${styles.dropdownItem} ${s.value === speed ? styles.dropdownItemActive : ''}`}
|
|
||||||
onClick={() => { setSpeed(s.value); setOpenDropdown(null) }}
|
|
||||||
>
|
|
||||||
<span className={`${styles.speedDot} ${styles[`speedDot_${s.value}`]}`} />
|
|
||||||
<span>{s.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label className={styles.fieldLabel}>Адрес кошелька</label>
|
<label className={styles.fieldLabel}>Адрес кошелька</label>
|
||||||
<input
|
<input
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="0x…"
|
placeholder={cfg.addressPlaceholder}
|
||||||
value={address}
|
value={address}
|
||||||
onChange={(e) => setAddress(e.target.value)}
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -168,6 +222,7 @@ export function SendModal({ open, onClose, tokens, initialTicker }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label className={styles.fieldLabel}>Количество</label>
|
<label className={styles.fieldLabel}>Количество</label>
|
||||||
<div className={styles.amountWrap}>
|
<div className={styles.amountWrap}>
|
||||||
@@ -180,24 +235,36 @@ export function SendModal({ open, onClose, tokens, initialTicker }: Props) {
|
|||||||
min="0"
|
min="0"
|
||||||
step="any"
|
step="any"
|
||||||
/>
|
/>
|
||||||
{token && <span className={styles.amountTicker}>{token.ticker}</span>}
|
<span className={styles.amountTicker}>{tokenLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
{token && (
|
{walletMatch && (
|
||||||
<div className={styles.maxHint}>
|
<div className={styles.maxHint}>
|
||||||
Макс:{' '}
|
Макс:{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.maxBtn}
|
className={styles.maxBtn}
|
||||||
onClick={() => setAmount(token.bal)}
|
onClick={() => setAmount(walletMatch.bal)}
|
||||||
>
|
>
|
||||||
{token.bal} {token.ticker}
|
{walletMatch.bal} {tokenLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className={styles.submitBtn} type="button">
|
{/* Error */}
|
||||||
Отправить
|
{mutation.isError && (
|
||||||
|
<div className={styles.errorMsg}>
|
||||||
|
{extractErrorMessage(mutation.error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.submitBtn}
|
||||||
|
type="button"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Отправка…' : 'Отправить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTokenRows } from '../model/useTokenRows'
|
import { useTokenRows } from '../model/useTokenRows'
|
||||||
import { SendModal } from '@widgets/send-modal'
|
import { SendModal, TICKER_TO_CHAIN, type Chain } from '@widgets/send-modal'
|
||||||
import styles from './TokenTable.module.css'
|
import styles from './TokenTable.module.css'
|
||||||
|
|
||||||
export function TokenTable() {
|
export function TokenTable() {
|
||||||
const { rows, isLoading } = useTokenRows()
|
const { rows, isLoading } = useTokenRows()
|
||||||
const [favs, setFavs] = useState<boolean[]>(() => rows.map((t) => t.fav))
|
const [favs, setFavs] = useState<boolean[]>(() => rows.map((t) => t.fav))
|
||||||
const [sendModal, setSendModal] = useState<{ open: boolean; ticker: string }>({ open: false, ticker: '' })
|
const [sendModal, setSendModal] = useState<{ open: boolean; network: Chain }>({
|
||||||
|
open: false,
|
||||||
|
network: 'ETH',
|
||||||
|
})
|
||||||
|
|
||||||
function openSend(ticker: string) {
|
function openSend(ticker: string) {
|
||||||
setSendModal({ open: true, ticker })
|
const network = TICKER_TO_CHAIN[ticker] ?? 'ETH'
|
||||||
|
setSendModal({ open: true, network })
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSend() {
|
function closeSend() {
|
||||||
@@ -143,8 +147,8 @@ export function TokenTable() {
|
|||||||
<SendModal
|
<SendModal
|
||||||
open={sendModal.open}
|
open={sendModal.open}
|
||||||
onClose={closeSend}
|
onClose={closeSend}
|
||||||
|
network={sendModal.network}
|
||||||
tokens={rows}
|
tokens={rows}
|
||||||
initialTicker={sendModal.ticker}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user