profile refactor

This commit is contained in:
2026-06-29 19:11:45 +03:00
parent f372ef6fb5
commit 7af549fb1d
11 changed files with 416 additions and 96 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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"