17.05.2026 funny

This commit is contained in:
2026-05-17 14:03:33 +03:00
parent b5791a0871
commit b0dc637b8f
16 changed files with 286 additions and 159 deletions

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@shared/ui'
import { useMe, useUploadAvatar } from '@features/auth'
import styles from './ProfileAvatar.module.css'
@@ -21,8 +21,14 @@ export function ProfileAvatar() {
const { mutateAsync: upload, isPending } = useUploadAvatar()
const inputRef = useRef<HTMLInputElement>(null)
const [error, setError] = useState<string | null>(null)
const [imgFailed, setImgFailed] = useState(false)
const avatarLink = data?.avatar_link ?? null
const showImg = avatarLink && !imgFailed
useEffect(() => {
setImgFailed(false)
}, [avatarLink])
const openPicker = () => {
if (isPending) return
@@ -46,8 +52,17 @@ export function ProfileAvatar() {
return (
<div className={styles.col}>
<div className={styles.avatar} onClick={openPicker}>
{avatarLink ? (
<img src={avatarLink} alt="avatar" className={styles.avatarImg} />
{showImg ? (
<img
src={avatarLink}
alt="avatar"
className={styles.avatarImg}
referrerPolicy="no-referrer"
onError={() => {
console.warn('[avatar] failed to load', avatarLink)
setImgFailed(true)
}}
/>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" />

View File

@@ -0,0 +1,76 @@
import { useWalletBalance } from '@features/wallet'
import type { Chain } from '@features/wallet'
import { TOKENS, type Token } from './tokens'
const NATIVE_TICKER: Record<Chain, string> = {
BTC: 'BTC',
ETH: 'ETH',
SOL: 'SOL',
TRX: 'TRX',
BSC: 'BNB',
}
const NATIVE_NAME: Record<Chain, string> = {
BTC: 'Bitcoin',
ETH: 'Ethereum',
SOL: 'Solana',
TRX: 'Tron',
BSC: 'BNB',
}
const DEFAULT_TOKEN_COLOR = '#2A2D3A'
function formatUsd(value: number | null | undefined): string {
if (value == null) return '$—'
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatPrice(value: number | null | undefined): string {
if (value == null) return '$—'
if (value >= 1) {
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 })}`
}
function lookupStatic(ticker: string): Token | undefined {
return TOKENS.find((t) => t.ticker === ticker)
}
export function useChainTokenRows(chain: Chain) {
const { data, isLoading } = useWalletBalance(chain)
if (!data) return { rows: [] as Token[], isLoading }
const nativeTicker = NATIVE_TICKER[chain]
const nativeStatic = lookupStatic(nativeTicker)
const nativeRow: Token = {
ticker: nativeTicker,
name: NATIVE_NAME[chain],
logo: nativeStatic?.logo,
color: nativeStatic?.color ?? DEFAULT_TOKEN_COLOR,
price: formatPrice(data.native.usdPrice),
change: 0,
bal: data.native.formatted,
usd: formatUsd(data.native.usdValue),
fav: false,
}
const tokenRows: Token[] = Object.entries(data.tokens).map(([symbol, amount]) => {
const staticToken = lookupStatic(symbol)
return {
ticker: symbol,
name: staticToken?.name ?? symbol,
logo: staticToken?.logo,
color: staticToken?.color ?? DEFAULT_TOKEN_COLOR,
price: formatPrice(amount.usdPrice),
change: 0,
bal: amount.formatted,
usd: formatUsd(amount.usdValue),
fav: false,
}
})
return { rows: [nativeRow, ...tokenRows], isLoading }
}

View File

@@ -1,71 +0,0 @@
import { useAllWalletBalances, usePrices } from '@features/wallet'
import { CHAINS } from '@features/wallet'
import { TOKENS } from './tokens'
import type { WalletBalanceData } from '@features/wallet'
const PRICE_SYMBOLS = ['BTC', 'ETH', 'SOL', 'TRX', 'BSC']
type ChainSource =
| { chain: 'BTC' | 'ETH' | 'SOL' | 'TRX' | 'BSC'; type: 'native' }
| { chain: 'ETH'; type: 'token'; symbol: string }
const CHAIN_MAP: Record<string, ChainSource> = {
BTC: { chain: 'BTC', type: 'native' },
ETH: { chain: 'ETH', type: 'native' },
SOL: { chain: 'SOL', type: 'native' },
TRX: { chain: 'TRX', type: 'native' },
BSC: { chain: 'BSC', type: 'native' },
}
function formatUsd(value: number | null | undefined): string {
if (value == null) return '$—'
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatPrice(value: number | null | undefined): string {
if (value == null) return '$—'
if (value >= 1) {
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 })}`
}
export function useTokenRows() {
const balanceResults = useAllWalletBalances()
const pricesQuery = usePrices(PRICE_SYMBOLS)
const isLoading = balanceResults.some(r => r.isLoading) || pricesQuery.isLoading
const balanceByChain: Record<string, WalletBalanceData> = {}
CHAINS.forEach((chain, i) => {
const data = balanceResults[i].data
if (data) balanceByChain[chain] = data
})
const rows = TOKENS.map(t => {
const src = CHAIN_MAP[t.ticker]
const chainData = src ? balanceByChain[src.chain] : undefined
let bal = t.bal
let usd = t.usd
if (chainData) {
const amount =
src.type === 'native'
? chainData.native
: chainData.tokens[src.type === 'token' ? src.symbol : t.ticker]
if (amount) {
bal = amount.formatted ?? t.bal
usd = formatUsd(amount.usdValue)
}
}
const priceEntry = pricesQuery.data?.[t.ticker]
const price = priceEntry ? formatPrice(priceEntry.usd) : t.price
return { ...t, price, bal, usd }
})
return { rows, isLoading }
}

View File

@@ -1,12 +1,16 @@
import { useState } from 'react'
import { useTokenRows } from '../model/useTokenRows'
import { useChainTokenRows } from '../model/useChainTokenRows'
import { SendModal, TICKER_TO_CHAIN, type Chain } from '@widgets/send-modal'
import { ReceiveModal } from '@widgets/receive-modal'
import styles from './TokenTable.module.css'
export function TokenTable() {
const { rows, isLoading } = useTokenRows()
const [favs, setFavs] = useState<boolean[]>(() => rows.map((t) => t.fav))
interface Props {
chain: Chain
}
export function TokenTable({ chain }: Props) {
const { rows, isLoading } = useChainTokenRows(chain)
const [favs, setFavs] = useState<Record<string, boolean>>({})
const [sendModal, setSendModal] = useState<{ open: boolean; network: Chain }>({
open: false,
network: 'ETH',
@@ -17,7 +21,7 @@ export function TokenTable() {
})
function openSend(ticker: string) {
const network = TICKER_TO_CHAIN[ticker] ?? 'ETH'
const network = TICKER_TO_CHAIN[ticker] ?? chain
setSendModal({ open: true, network })
}
@@ -26,16 +30,16 @@ export function TokenTable() {
}
function openReceive(ticker: string) {
const chain = TICKER_TO_CHAIN[ticker] ?? 'ETH'
setReceiveModal({ open: true, chain })
const target = TICKER_TO_CHAIN[ticker] ?? chain
setReceiveModal({ open: true, chain: target })
}
function closeReceive() {
setReceiveModal((s) => ({ ...s, open: false }))
}
function toggleFav(i: number) {
setFavs((prev) => prev.map((v, idx) => (idx === i ? !v : v)))
function toggleFav(ticker: string) {
setFavs((prev) => ({ ...prev, [ticker]: !prev[ticker] }))
}
const sendIcon = (
@@ -64,14 +68,14 @@ export function TokenTable() {
</tr>
</thead>
<tbody>
{rows.map((t, i) => (
{rows.map((t) => (
<tr key={t.ticker}>
<td>
<button
className={`${styles.star} ${favs[i] ? styles.starOn : ''}`}
onClick={() => toggleFav(i)}
className={`${styles.star} ${favs[t.ticker] ? styles.starOn : ''}`}
onClick={() => toggleFav(t.ticker)}
type="button"
aria-label={favs[i] ? 'Убрать из избранного' : 'В избранное'}
aria-label={favs[t.ticker] ? 'Убрать из избранного' : 'В избранное'}
>
</button>
@@ -79,7 +83,7 @@ export function TokenTable() {
<td>
<div className={styles.tokId}>
<div className={styles.tokLogo} style={{ background: t.color }}>
{t.logo && <img src={t.logo} alt={t.ticker} className={''} />}
{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>
@@ -123,13 +127,13 @@ export function TokenTable() {
{/* Mobile card list */}
<div className={styles.mobileList}>
{rows.map((t, i) => (
{rows.map((t) => (
<div key={t.ticker} className={styles.card}>
<button
className={`${styles.star} ${favs[i] ? styles.starOn : ''}`}
onClick={() => toggleFav(i)}
className={`${styles.star} ${favs[t.ticker] ? styles.starOn : ''}`}
onClick={() => toggleFav(t.ticker)}
type="button"
aria-label={favs[i] ? 'Убрать из избранного' : 'В избранное'}
aria-label={favs[t.ticker] ? 'Убрать из избранного' : 'В избранное'}
>
</button>

View File

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

View File

@@ -0,0 +1,53 @@
.tabs {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin: 24px 0 16px;
position: relative;
z-index: 1;
}
.tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.08));
color: var(--text-secondary, rgba(255, 255, 255, 0.65));
font-size: 14px;
font-weight: 600;
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
cursor: pointer;
}
.tab:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--text-primary, #fff);
}
.active {
background: rgba(74, 109, 255, 0.18);
border-color: rgba(74, 109, 255, 0.55);
color: var(--text-primary, #fff);
}
.icon {
width: 18px;
height: 18px;
display: block;
}
@media (max-width: 900px) {
.tab {
padding: 8px 14px;
font-size: 13px;
}
.icon {
width: 16px;
height: 16px;
}
}

View File

@@ -0,0 +1,39 @@
import { NavLink } from 'react-router-dom'
import type { Chain } from '@features/wallet'
import btc from '@shared/assets/btc.svg'
import eth from '@shared/assets/eth.svg'
import sol from '@shared/assets/sol.svg'
import trx from '@shared/assets/trx.svg'
import bnb from '@shared/assets/bnb.svg'
import styles from './WalletChainTabs.module.css'
interface TabItem {
chain: Chain
label: string
icon: string
}
const TABS: TabItem[] = [
{ chain: 'BTC', label: 'BTC', icon: btc },
{ chain: 'ETH', label: 'ETH', icon: eth },
{ chain: 'SOL', label: 'SOL', icon: sol },
{ chain: 'TRX', label: 'TRX', icon: trx },
{ chain: 'BSC', label: 'BSC', icon: bnb },
]
export function WalletChainTabs() {
return (
<div className={styles.tabs}>
{TABS.map((t) => (
<NavLink
key={t.chain}
to={`/wallet/${t.chain.toLowerCase()}`}
className={({ isActive }) => `${styles.tab} ${isActive ? styles.active : ''}`}
>
<img src={t.icon} alt={t.label} className={styles.icon} />
<span>{t.label}</span>
</NavLink>
))}
</div>
)
}