profile refactor
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useOrders } from '@features/payment'
|
||||
import { Spinner } from '@shared/ui'
|
||||
import { OrderAccordion } from './OrderAccordion'
|
||||
import styles from './TransactionsList.module.css'
|
||||
|
||||
@@ -7,7 +8,7 @@ export function TransactionsList() {
|
||||
|
||||
const items = data?.pages.flatMap(p => p.orders) ?? []
|
||||
|
||||
if (isLoading) return <p className={styles.status}>Загрузка...</p>
|
||||
if (isLoading) return <Spinner fullscreen label="Загрузка" />
|
||||
if (isError) return <p className={styles.statusError}>Не удалось загрузить транзакции. Попробуйте обновить страницу.</p>
|
||||
if (items.length === 0) return <p className={styles.empty}>У вас пока нет транзакций.</p>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -13,30 +14,53 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ticker {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tick {
|
||||
/* ── Центральное меню (desktop) ───────────────────────────── */
|
||||
.menu {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tick b {
|
||||
.menuLink {
|
||||
position: relative;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.menuLink:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--glass-bg);
|
||||
}
|
||||
|
||||
.menuLinkActive {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.up {
|
||||
color: var(--success);
|
||||
.menuLinkActive::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--interactive);
|
||||
}
|
||||
|
||||
.dn {
|
||||
color: #ff4d4d;
|
||||
/* ── Правая часть ─────────────────────────────────────────── */
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.accountWrapper {
|
||||
@@ -53,6 +77,20 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.account span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #3d2a8e, #5b3db8);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 12px);
|
||||
@@ -61,10 +99,22 @@
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
min-width: 180px;
|
||||
min-width: 160px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: dropdownIn 0.16s ease;
|
||||
}
|
||||
|
||||
@keyframes dropdownIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
@@ -90,32 +140,153 @@
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #3d2a8e, #5b3db8);
|
||||
flex-shrink: 0;
|
||||
/* ── Бургер ───────────────────────────────────────────────── */
|
||||
.burger {
|
||||
display: none;
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.account span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
.burger span {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--text-primary);
|
||||
transition: transform 0.3s ease, opacity 0.2s ease, top 0.3s ease;
|
||||
}
|
||||
|
||||
.burger span:nth-child(1) {
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.burger span:nth-child(2) {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.burger span:nth-child(3) {
|
||||
top: 18px;
|
||||
}
|
||||
|
||||
.burgerOpen span:nth-child(1) {
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.burgerOpen span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.burgerOpen span:nth-child(3) {
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
/* ── Мобильное меню ───────────────────────────────────────── */
|
||||
.mobileOverlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 90;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.mobileOverlayOpen {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mobileMenu {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(78vw, 320px);
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 24px 20px;
|
||||
background: var(--bg-mid, #1b1547);
|
||||
border-left: 1px solid var(--glass-border);
|
||||
z-index: 95;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.mobileMenuOpen {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobileLink {
|
||||
display: block;
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease, background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.mobileMenuOpen .mobileLink {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobileLink:hover {
|
||||
background: var(--glass-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mobileLinkActive {
|
||||
color: var(--text-primary);
|
||||
background: var(--glass-bg);
|
||||
}
|
||||
|
||||
.mobileLink.danger {
|
||||
margin-top: 8px;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* ── Адаптив ──────────────────────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.nav {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.ticker {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ticker {
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.account span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.burger {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobileOverlay,
|
||||
.mobileMenu {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
import logo from '@shared/assets/logo-full-white.png'
|
||||
import styles from './WalletHeader.module.css'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { ROUTES } from '@shared/config/routes'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { logout } from '@features/auth/api/registrationApi'
|
||||
import { AUTH_QUERY_KEY, useMe } from '@features/auth'
|
||||
import { usePrices } from '@features/wallet'
|
||||
import { tokenStore } from '@shared/api/tokenStore'
|
||||
import { Notification } from '@shared/ui'
|
||||
|
||||
const TICKER_SYMBOLS = ['BTC', 'ETH', 'SOL']
|
||||
|
||||
const capitalize = (s: string | null) => (s ? s[0].toUpperCase() + s.slice(1).toLowerCase() : '')
|
||||
|
||||
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 })}`
|
||||
}
|
||||
const NAV_ITEMS = [
|
||||
{ to: ROUTES.PROFILE, label: 'Личный кабинет' },
|
||||
{ to: ROUTES.WALLET, label: 'Кошелёк' },
|
||||
{ to: ROUTES.STAKING, label: 'Стейкинг' },
|
||||
{ to: ROUTES.POOLS, label: 'Пулы' },
|
||||
]
|
||||
|
||||
export function WalletHeader() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: me } = useMe()
|
||||
const { data: prices } = usePrices(TICKER_SYMBOLS)
|
||||
const fullName = me
|
||||
? [me.first_name, me.middle_name].filter(Boolean).map(capitalize).join(' ')
|
||||
: ''
|
||||
@@ -56,9 +52,18 @@ export function WalletHeader() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Блокируем прокрутку фона, когда открыто мобильное меню
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = menuOpen ? 'hidden' : ''
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [menuOpen])
|
||||
|
||||
function handleLogout() {
|
||||
logoutMutate()
|
||||
setOpen(false)
|
||||
setMenuOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -67,47 +72,83 @@ export function WalletHeader() {
|
||||
<a href="/" className={styles.logo}>
|
||||
<img src={logo} alt="ЭКСА" />
|
||||
</a>
|
||||
<div className={styles.ticker}>
|
||||
{TICKER_SYMBOLS.map((symbol) => (
|
||||
<div key={symbol} className={styles.tick}>
|
||||
<b>{symbol}</b>
|
||||
<span>{formatPrice(prices?.[symbol]?.usd)}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.menu}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`${styles.menuLink} ${isActive ? styles.menuLinkActive : ''}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.accountWrapper} ref={ref}>
|
||||
<button className={styles.account} onClick={() => setOpen(v => !v)}>
|
||||
{me?.avatar_link ? (
|
||||
<img src={me.avatar_link} alt="" className={styles.avatar} />
|
||||
) : (
|
||||
<div className={styles.avatar} />
|
||||
|
||||
<div className={styles.right}>
|
||||
<div className={styles.accountWrapper} ref={ref}>
|
||||
<button className={styles.account} onClick={() => setOpen((v) => !v)}>
|
||||
{me?.avatar_link ? (
|
||||
<img src={me.avatar_link} alt="" className={styles.avatar} />
|
||||
) : (
|
||||
<div className={styles.avatar} />
|
||||
)}
|
||||
<span>{fullName || me?.legal_entity?.name || 'Test account'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className={styles.dropdown}>
|
||||
<button
|
||||
className={`${styles.dropdownItem} ${styles.danger}`}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span>{fullName || me?.legal_entity?.name || 'Test account'}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`${styles.burger} ${menuOpen ? styles.burgerOpen : ''}`}
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-label="Меню"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
{open && (
|
||||
<div className={styles.dropdown}>
|
||||
<Link to={ROUTES.PROFILE} className={styles.dropdownItem} onClick={() => setOpen(false)}>
|
||||
Личный кабинет
|
||||
</Link>
|
||||
<Link to={ROUTES.WALLET} className={styles.dropdownItem} onClick={() => setOpen(false)}>
|
||||
Кошелёк
|
||||
</Link>
|
||||
<Link to={ROUTES.STAKING} className={styles.dropdownItem} onClick={() => setOpen(false)}>
|
||||
Стейкинг
|
||||
</Link>
|
||||
<Link to={ROUTES.POOLS} className={styles.dropdownItem} onClick={() => setOpen(false)}>
|
||||
Пулы
|
||||
</Link>
|
||||
<Link to={ROUTES.PROFILE_TRANSACTIONS} className={styles.dropdownItem} onClick={() => setOpen(false)}>
|
||||
Транзакции
|
||||
</Link>
|
||||
<button className={`${styles.dropdownItem} ${styles.danger}`} onClick={handleLogout}>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={`${styles.mobileOverlay} ${menuOpen ? styles.mobileOverlayOpen : ''}`}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className={`${styles.mobileMenu} ${menuOpen ? styles.mobileMenuOpen : ''}`}>
|
||||
{NAV_ITEMS.map((item, i) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
style={{ transitionDelay: menuOpen ? `${0.08 + i * 0.05}s` : '0s' }}
|
||||
className={({ isActive }) =>
|
||||
`${styles.mobileLink} ${isActive ? styles.mobileLinkActive : ''}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<button
|
||||
className={`${styles.mobileLink} ${styles.danger}`}
|
||||
style={{ transitionDelay: menuOpen ? `${0.08 + NAV_ITEMS.length * 0.05}s` : '0s' }}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
status="error"
|
||||
|
||||
Reference in New Issue
Block a user