profile refactor
This commit is contained in:
File diff suppressed because one or more lines are too long
2
dist/index.html
vendored
2
dist/index.html
vendored
@@ -5,7 +5,7 @@
|
|||||||
<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-Cyy52NAv.js"></script>
|
<script type="module" crossorigin src="/assets/index-0SPPJZ-S.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CsmeBXqB.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CsmeBXqB.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
107
openspec/specs/profile-dashboard/spec.md
Normal file
107
openspec/specs/profile-dashboard/spec.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# profile-dashboard
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Единый раздел «личного кабинета» на `/profile`: боковое меню (25%) и контент (75%) с вложенной маршрутизацией по разделам. Объединяет блоки профиля (данные, безопасность, мнемоника) и транзакции в одну точку входа, с закреплённой сводкой профиля и состоянием загрузки контента.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Two-column dashboard layout
|
||||||
|
|
||||||
|
The profile section SHALL be presented as a two-column layout: a left menu column occupying approximately 25% of the available width and a right content column occupying approximately 75%. On viewports narrower than 1024px the two columns SHALL collapse into a single stacked column.
|
||||||
|
|
||||||
|
#### Scenario: Wide viewport shows two columns
|
||||||
|
- **WHEN** an authenticated user opens the profile section on a viewport ≥ 1024px wide
|
||||||
|
- **THEN** the left menu column (~25%) and the right content column (~75%) are displayed side by side
|
||||||
|
|
||||||
|
#### Scenario: Narrow viewport stacks columns
|
||||||
|
- **WHEN** an authenticated user opens the profile section on a viewport < 1024px wide
|
||||||
|
- **THEN** the menu and the content are stacked vertically in a single column
|
||||||
|
|
||||||
|
### Requirement: Pinned profile summary
|
||||||
|
|
||||||
|
The left menu column SHALL display the profile summary (`ProfileSummary` — avatar and identity) pinned at the top, above the menu navigation, and it SHALL remain visible regardless of which menu item is active. On desktop the avatar and the "Добавить фото" button SHALL span the full width of the menu column.
|
||||||
|
|
||||||
|
#### Scenario: Summary visible on every item
|
||||||
|
- **WHEN** the user switches between any menu items
|
||||||
|
- **THEN** the profile summary remains displayed at the top of the left column
|
||||||
|
|
||||||
|
### Requirement: Menu items for profile sections and transactions
|
||||||
|
|
||||||
|
The left menu SHALL list selectable items for: Данные профиля (`ProfileDetails`), Безопасность (`SecuritySection`), Транзакции (transactions content), and Мнемоническая фраза (last item). Selecting an item SHALL render its content in the right column, and the currently active item SHALL be visually highlighted.
|
||||||
|
|
||||||
|
#### Scenario: Selecting a menu item shows its content
|
||||||
|
- **WHEN** the user clicks a menu item
|
||||||
|
- **THEN** the right column renders that item's content and the clicked item is marked as active
|
||||||
|
|
||||||
|
#### Scenario: Transactions appears as a menu item
|
||||||
|
- **WHEN** the user views the profile menu
|
||||||
|
- **THEN** a «Транзакции» item is present alongside the profile section items, and selecting it renders the transactions content (list plus the «Транзакции / Заявки» tabs for legal-entity accounts)
|
||||||
|
|
||||||
|
### Requirement: Mobile navigation via select
|
||||||
|
|
||||||
|
On viewports narrower than 1024px the left menu navigation SHALL be presented as a single select control (the shared `Select` component) instead of the full list, so only the chosen section is shown. Changing the select SHALL navigate to the corresponding section.
|
||||||
|
|
||||||
|
#### Scenario: Mobile uses a select
|
||||||
|
- **WHEN** an authenticated user opens the profile section on a viewport < 1024px wide
|
||||||
|
- **THEN** the section navigation is a select control reflecting the active section
|
||||||
|
|
||||||
|
#### Scenario: Changing the select navigates
|
||||||
|
- **WHEN** the user picks another section in the select
|
||||||
|
- **THEN** the app navigates to that section's URL and renders its content
|
||||||
|
|
||||||
|
### Requirement: Mnemonic reveal requires explicit action
|
||||||
|
|
||||||
|
The «Мнемоническая фраза» item SHALL show the seed-phrase content inline (12-word grid with show/hide and copy), but the mnemonic SHALL be fetched only after the user explicitly requests it via a "Показать мнемонику" button. Until then no mnemonic request is made.
|
||||||
|
|
||||||
|
#### Scenario: Mnemonic gated behind a button
|
||||||
|
- **WHEN** the user opens the «Мнемоническая фраза» item
|
||||||
|
- **THEN** a warning and a "Показать мнемонику" button are shown, and no mnemonic request has been sent
|
||||||
|
|
||||||
|
#### Scenario: Revealing fetches and shows the seed phrase
|
||||||
|
- **WHEN** the user clicks "Показать мнемонику"
|
||||||
|
- **THEN** the mnemonic is fetched (a loading indicator is shown) and the seed-phrase content is rendered inline
|
||||||
|
|
||||||
|
### Requirement: Per-item routing with default redirect
|
||||||
|
|
||||||
|
Each menu item SHALL have its own URL under `/profile` (`/profile/details`, `/profile/security`, `/profile/mnemonic`, `/profile/transactions`) so that the browser Back button and direct links select the corresponding item. Navigating to `/profile` SHALL redirect to the default item `/profile/details`.
|
||||||
|
|
||||||
|
#### Scenario: Direct link opens the matching item
|
||||||
|
- **WHEN** the user navigates directly to `/profile/transactions`
|
||||||
|
- **THEN** the transactions item is active and its content is shown in the right column
|
||||||
|
|
||||||
|
#### Scenario: Bare profile path redirects to default
|
||||||
|
- **WHEN** the user navigates to `/profile`
|
||||||
|
- **THEN** the app redirects to `/profile/details` and shows the profile data item
|
||||||
|
|
||||||
|
#### Scenario: Back button restores previous item
|
||||||
|
- **WHEN** the user selects one item and then another, and then presses the browser Back button
|
||||||
|
- **THEN** the previously active item is restored
|
||||||
|
|
||||||
|
### Requirement: Legacy transactions route redirect
|
||||||
|
|
||||||
|
The legacy route `/transactions` SHALL redirect to `/profile/transactions` so existing links and bookmarks continue to work.
|
||||||
|
|
||||||
|
#### Scenario: Old transactions URL redirects
|
||||||
|
- **WHEN** the user navigates to `/transactions`
|
||||||
|
- **THEN** the app redirects to `/profile/transactions`
|
||||||
|
|
||||||
|
### Requirement: KYC banner placement
|
||||||
|
|
||||||
|
The KYC banner (`KycBanner`) SHALL be displayed above the content in the right column.
|
||||||
|
|
||||||
|
#### Scenario: KYC banner shown above content
|
||||||
|
- **WHEN** the profile section is displayed and the KYC banner is applicable
|
||||||
|
- **THEN** the banner appears above the active item's content in the right column
|
||||||
|
|
||||||
|
### Requirement: Loading state for content
|
||||||
|
|
||||||
|
While the data for the active menu item is loading, the right column SHALL show a loading indicator (the shared `Spinner`) while the left menu remains interactive.
|
||||||
|
|
||||||
|
#### Scenario: Spinner while data loads
|
||||||
|
- **WHEN** the active item's data is still loading
|
||||||
|
- **THEN** a loading indicator is shown in the right column and the user can still navigate to other menu items
|
||||||
|
|
||||||
|
#### Scenario: Content replaces spinner when ready
|
||||||
|
- **WHEN** the active item's data finishes loading
|
||||||
|
- **THEN** the loading indicator is replaced by the item's content
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useOrders } from '@features/payment'
|
import { useOrders } from '@features/payment'
|
||||||
|
import { Spinner } from '@shared/ui'
|
||||||
import { OrderAccordion } from './OrderAccordion'
|
import { OrderAccordion } from './OrderAccordion'
|
||||||
import styles from './TransactionsList.module.css'
|
import styles from './TransactionsList.module.css'
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ export function TransactionsList() {
|
|||||||
|
|
||||||
const items = data?.pages.flatMap(p => p.orders) ?? []
|
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 (isError) return <p className={styles.statusError}>Не удалось загрузить транзакции. Попробуйте обновить страницу.</p>
|
||||||
if (items.length === 0) return <p className={styles.empty}>У вас пока нет транзакций.</p>
|
if (items.length === 0) return <p className={styles.empty}>У вас пока нет транзакций.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.nav {
|
.nav {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -13,30 +14,53 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticker {
|
/* ── Центральное меню (desktop) ───────────────────────────── */
|
||||||
display: flex;
|
.menu {
|
||||||
gap: 24px;
|
position: absolute;
|
||||||
font-size: 13px;
|
left: 50%;
|
||||||
font-family: var(--font-mono);
|
top: 50%;
|
||||||
}
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
.tick {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.up {
|
.menuLinkActive::after {
|
||||||
color: var(--success);
|
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 {
|
.accountWrapper {
|
||||||
@@ -53,6 +77,20 @@
|
|||||||
padding: 0;
|
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 {
|
.dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 12px);
|
top: calc(100% + 12px);
|
||||||
@@ -61,10 +99,22 @@
|
|||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 180px;
|
min-width: 160px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
animation: dropdownIn 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdownIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownItem {
|
.dropdownItem {
|
||||||
@@ -90,32 +140,153 @@
|
|||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
/* ── Бургер ───────────────────────────────────────────────── */
|
||||||
width: 34px;
|
.burger {
|
||||||
height: 34px;
|
display: none;
|
||||||
border-radius: 50%;
|
position: relative;
|
||||||
background: linear-gradient(135deg, #3d2a8e, #5b3db8);
|
width: 28px;
|
||||||
flex-shrink: 0;
|
height: 22px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account span {
|
.burger span {
|
||||||
color: var(--text-secondary);
|
position: absolute;
|
||||||
font-size: 14px;
|
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;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.nav {
|
.nav {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticker {
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.ticker {
|
.menu {
|
||||||
display: none;
|
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 logo from '@shared/assets/logo-full-white.png'
|
||||||
import styles from './WalletHeader.module.css'
|
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 { ROUTES } from '@shared/config/routes'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { logout } from '@features/auth/api/registrationApi'
|
import { logout } from '@features/auth/api/registrationApi'
|
||||||
import { AUTH_QUERY_KEY, useMe } from '@features/auth'
|
import { AUTH_QUERY_KEY, useMe } from '@features/auth'
|
||||||
import { usePrices } from '@features/wallet'
|
|
||||||
import { tokenStore } from '@shared/api/tokenStore'
|
import { tokenStore } from '@shared/api/tokenStore'
|
||||||
import { Notification } from '@shared/ui'
|
import { Notification } from '@shared/ui'
|
||||||
|
|
||||||
const TICKER_SYMBOLS = ['BTC', 'ETH', 'SOL']
|
|
||||||
|
|
||||||
const capitalize = (s: string | null) => (s ? s[0].toUpperCase() + s.slice(1).toLowerCase() : '')
|
const capitalize = (s: string | null) => (s ? s[0].toUpperCase() + s.slice(1).toLowerCase() : '')
|
||||||
|
|
||||||
function formatPrice(value: number | null | undefined): string {
|
const NAV_ITEMS = [
|
||||||
if (value == null) return '$—'
|
{ to: ROUTES.PROFILE, label: 'Личный кабинет' },
|
||||||
if (value >= 1) {
|
{ to: ROUTES.WALLET, label: 'Кошелёк' },
|
||||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
{ to: ROUTES.STAKING, label: 'Стейкинг' },
|
||||||
}
|
{ to: ROUTES.POOLS, label: 'Пулы' },
|
||||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 })}`
|
]
|
||||||
}
|
|
||||||
|
|
||||||
export function WalletHeader() {
|
export function WalletHeader() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: me } = useMe()
|
const { data: me } = useMe()
|
||||||
const { data: prices } = usePrices(TICKER_SYMBOLS)
|
|
||||||
const fullName = me
|
const fullName = me
|
||||||
? [me.first_name, me.middle_name].filter(Boolean).map(capitalize).join(' ')
|
? [me.first_name, me.middle_name].filter(Boolean).map(capitalize).join(' ')
|
||||||
: ''
|
: ''
|
||||||
@@ -56,9 +52,18 @@ export function WalletHeader() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Блокируем прокрутку фона, когда открыто мобильное меню
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = menuOpen ? 'hidden' : ''
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [menuOpen])
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
logoutMutate()
|
logoutMutate()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
setMenuOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,16 +72,24 @@ export function WalletHeader() {
|
|||||||
<a href="/" className={styles.logo}>
|
<a href="/" className={styles.logo}>
|
||||||
<img src={logo} alt="ЭКСА" />
|
<img src={logo} alt="ЭКСА" />
|
||||||
</a>
|
</a>
|
||||||
<div className={styles.ticker}>
|
|
||||||
{TICKER_SYMBOLS.map((symbol) => (
|
<div className={styles.menu}>
|
||||||
<div key={symbol} className={styles.tick}>
|
{NAV_ITEMS.map((item) => (
|
||||||
<b>{symbol}</b>
|
<NavLink
|
||||||
<span>{formatPrice(prices?.[symbol]?.usd)}</span>
|
key={item.to}
|
||||||
</div>
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${styles.menuLink} ${isActive ? styles.menuLinkActive : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.right}>
|
||||||
<div className={styles.accountWrapper} ref={ref}>
|
<div className={styles.accountWrapper} ref={ref}>
|
||||||
<button className={styles.account} onClick={() => setOpen(v => !v)}>
|
<button className={styles.account} onClick={() => setOpen((v) => !v)}>
|
||||||
{me?.avatar_link ? (
|
{me?.avatar_link ? (
|
||||||
<img src={me.avatar_link} alt="" className={styles.avatar} />
|
<img src={me.avatar_link} alt="" className={styles.avatar} />
|
||||||
) : (
|
) : (
|
||||||
@@ -86,28 +99,56 @@ export function WalletHeader() {
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className={styles.dropdown}>
|
<div className={styles.dropdown}>
|
||||||
<Link to={ROUTES.PROFILE} className={styles.dropdownItem} onClick={() => setOpen(false)}>
|
<button
|
||||||
Личный кабинет
|
className={`${styles.dropdownItem} ${styles.danger}`}
|
||||||
</Link>
|
onClick={handleLogout}
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`${styles.burger} ${menuOpen ? styles.burgerOpen : ''}`}
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
aria-label="Меню"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</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 && (
|
{error && (
|
||||||
<Notification
|
<Notification
|
||||||
status="error"
|
status="error"
|
||||||
|
|||||||
Reference in New Issue
Block a user