profile refactor

This commit is contained in:
2026-07-01 16:42:37 +03:00
parent 8789963a7b
commit 66fdbdd964
23 changed files with 377 additions and 244 deletions

View File

@@ -4,9 +4,9 @@
right: 24px;
z-index: 1000;
display: flex;
align-items: center;
align-items: flex-start;
gap: 12px;
padding: 16px 18px;
padding: 16px 40px 16px 18px;
min-width: 280px;
max-width: 360px;
border-radius: 12px;
@@ -21,6 +21,12 @@
gap: 12px;
}
.content {
display: flex;
flex-direction: column;
gap: 2px;
}
.notification.closing {
animation: slideOut 0.25s cubic-bezier(0.55, 0, 1, 0.45) forwards;
}
@@ -82,6 +88,14 @@
color: #fff;
}
.title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.4;
margin: 0;
}
.message {
flex: 1;
font-size: 14px;
@@ -91,6 +105,9 @@
}
.close {
position: absolute;
top: 10px;
right: 12px;
flex-shrink: 0;
background: none;
border: none;
@@ -99,7 +116,6 @@
cursor: pointer;
padding: 0;
line-height: 1;
margin-top: 2px;
transition: color 0.15s;
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import styles from './Notification.module.css'
type Status = 'success' | 'error' | 'info' | 'warning'
@@ -7,6 +7,8 @@ interface Props {
message: string
status: Status
onClose: () => void
title?: string
duration?: number
}
const ICONS: Record<Status, string> = {
@@ -16,8 +18,16 @@ const ICONS: Record<Status, string> = {
warning: '!',
}
export function Notification({ message, status, onClose }: Props) {
const DEFAULT_TITLES: Record<Status, string> = {
success: 'Успех',
error: 'Ошибка',
info: 'Информация',
warning: 'Внимание',
}
export function Notification({ message, status, onClose, title, duration }: Props) {
const [closing, setClosing] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
function handleClose() {
setClosing(true)
@@ -27,16 +37,56 @@ export function Notification({ message, status, onClose }: Props) {
if (closing) onClose()
}
function clearTimer() {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
function startTimer() {
if (duration) {
clearTimer()
timerRef.current = setTimeout(() => setClosing(true), duration)
}
}
useEffect(() => {
startTimer()
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setClosing(true)
}
window.addEventListener('keydown', handleKeyDown)
return () => {
clearTimer()
window.removeEventListener('keydown', handleKeyDown)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [duration])
const heading = title ?? DEFAULT_TITLES[status]
const ariaLive = status === 'error' || status === 'warning' ? 'assertive' : 'polite'
return (
<div
className={`${styles.notification} ${styles[status]} ${closing ? styles.closing : ''}`}
onAnimationEnd={handleAnimationEnd}
onMouseEnter={clearTimer}
onMouseLeave={startTimer}
role="alert"
aria-live={ariaLive}
>
<div className={styles.notificationWrapper}>
<span className={styles.icon}>{ICONS[status]}</span>
<p className={styles.message}>{message}</p>
<div className={styles.content}>
<p className={styles.title}>{heading}</p>
<p className={styles.message}>{message}</p>
</div>
</div>
<button className={styles.close} onClick={handleClose}></button>
<button className={styles.close} onClick={handleClose} aria-label="Закрыть"></button>
</div>
)
}

View File

@@ -24,6 +24,12 @@
margin-bottom: 6px;
}
.amountRow {
display: flex;
align-items: center;
gap: 14px;
}
.amount {
font-size: 48px;
font-weight: 800;
@@ -31,6 +37,28 @@
font-family: var(--font-mono);
}
.toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 12px;
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
font-family: var(--font-mono);
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.toggle:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.25);
}
.rub {
font-size: 18px;
color: var(--text-secondary);

View File

@@ -1,22 +1,51 @@
import { useState } from 'react'
import styles from './BalanceCard.module.css'
import topup from '@shared/assets/topup.svg'
import swap from '@shared/assets/swap.svg'
import { Link } from 'react-router-dom'
import { ROUTES } from '@shared/config/routes'
import { usePortfolio } from '@features/wallet'
import { usePaymentQuoteByRub } from '@features/payment'
import { MIN_RUB_AMOUNT } from '@shared/config/constants'
export function BalanceCard() {
const { data, isLoading } = usePortfolio()
const display =
isLoading || !data || data.totalUsd == null
? '$—'
: `$${data.totalUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
const [currency, setCurrency] = useState<'usd' | 'rub'>('usd')
// Курс тянем из quote/rub только когда переключились в рубли (в режиме USD передаём 0 → хук не стреляет).
const rateQuery = usePaymentQuoteByRub(currency === 'rub' ? MIN_RUB_AMOUNT : 0)
const rate = Number(rateQuery.data?.usdt_exchange_rate) || 0
const totalUsd = isLoading || !data || data.totalUsd == null ? null : data.totalUsd
let display: string
if (currency === 'usd') {
display =
totalUsd == null
? '$—'
: `$${totalUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
} else {
display =
totalUsd == null || rate <= 0
? '— ₽'
: `${(totalUsd * rate).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽`
}
return (
<div className={styles.card}>
<div className={styles.left}>
<div className={styles.label}>Общий баланс</div>
<div className={styles.amount}>{display}</div>
<div className={styles.amountRow}>
<div className={styles.amount}>{display}</div>
<button
type="button"
className={styles.toggle}
onClick={() => setCurrency(c => (c === 'usd' ? 'rub' : 'usd'))}
aria-label="Переключить валюту"
>
{currency === 'usd' ? '₽' : '$'}
</button>
</div>
</div>
<div className={styles.actions}>
<Link to={ROUTES.CONVERTER} className={styles.btn} type="button">

View File

@@ -7,6 +7,11 @@
align-items: center;
justify-content: center;
z-index: 100;
animation: overlayIn 0.2s ease;
}
.overlay.closing {
animation: overlayOut 0.2s ease forwards;
}
.card {
@@ -19,6 +24,31 @@
display: flex;
flex-direction: column;
gap: 24px;
animation: cardIn 0.24s cubic-bezier(0.22, 1, 0.36, 1);
}
.card.closing {
animation: cardOut 0.2s cubic-bezier(0.55, 0, 1, 0.45) forwards;
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(16px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes cardOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(16px) scale(0.96); }
}
.header {

View File

@@ -1,3 +1,4 @@
import { useState } from 'react'
import type { JumperQuote } from '@features/wallet'
import { fromBaseUnits } from '@shared/lib/utils/baseUnits'
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
@@ -17,6 +18,18 @@ export function BridgeConfirmModal({ quote, fromAmountHuman, insufficientBalance
const toSymbol = action.toToken.symbol
const fromSymbol = action.fromToken.symbol
const [closing, setClosing] = useState(false)
// Trigger the exit animation; the real close fires on animation end so the ×
// and backdrop paths play the same closing transition.
function requestClose() {
setClosing(true)
}
function handleAnimationEnd() {
if (closing) onClose()
}
const toAmount = truncateDecimals(fromBaseUnits(estimate.toAmount, action.toToken.decimals), 8)
const toMin = truncateDecimals(fromBaseUnits(estimate.toAmountMin, action.toToken.decimals), 8)
@@ -25,11 +38,15 @@ export function BridgeConfirmModal({ quote, fromAmountHuman, insufficientBalance
.toFixed(2)
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.card} onClick={e => e.stopPropagation()}>
<div className={`${styles.overlay} ${closing ? styles.closing : ''}`} onClick={requestClose}>
<div
className={`${styles.card} ${closing ? styles.closing : ''}`}
onClick={e => e.stopPropagation()}
onAnimationEnd={handleAnimationEnd}
>
<div className={styles.header}>
<span className={styles.title}>Подтвердить бридж</span>
<button className={styles.closeBtn} onClick={onClose}>×</button>
<button className={styles.closeBtn} onClick={requestClose}>×</button>
</div>
<div className={styles.flow}>

View File

@@ -7,7 +7,7 @@ import {
type Chain, type JumperToken, type WalletBalanceData, type JumperQuote, type JumperQuotePayload,
type RelayQuotePayload,
} from '@features/wallet'
import { Notification, PrimaryButton } from '@shared/ui'
import { Notification, PrimaryButton, Spinner } from '@shared/ui'
import { ROUTES } from '@shared/config/routes'
import { useDebounce } from '@shared/lib/hooks/useDebounce'
import { toBaseUnits, fromBaseUnits } from '@shared/lib/utils/baseUnits'
@@ -80,7 +80,7 @@ export function BridgeForm() {
const [quote, setQuote] = useState<JumperQuote | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { data: jumperData } = useJumperTokens()
const { data: jumperData, isLoading: isTokensLoading } = useJumperTokens()
const { data: fromWalletData } = useWalletBalance(fromNetwork as Chain)
const { data: toWalletData } = useWalletBalance(toNetwork as Chain)
const { data: addresses } = useWalletAddresses()
@@ -200,15 +200,19 @@ export function BridgeForm() {
queryClient.invalidateQueries({ queryKey: ['wallet', 'balance', toNetwork] })
queryClient.invalidateQueries({ queryKey: ['wallet', 'portfolio'] })
setQuote(null)
navigate(ROUTES.WALLET)
navigate(ROUTES.WALLET_CHAIN.replace(':chain', toNetwork.toLowerCase()))
},
onError: (err) => setErrorMessage(err instanceof Error ? err.message : 'Не удалось выполнить бридж'),
},
)
}
if (!jumperData) {
return <div className={styles.form} />
if (isTokensLoading || !jumperData) {
return (
<div className={styles.form}>
<Spinner fullscreen label="Загрузка" />
</div>
)
}
return (
@@ -266,6 +270,7 @@ export function BridgeForm() {
status="error"
message={errorMessage}
onClose={() => setErrorMessage(null)}
duration={5000}
/>
)}
</div>

View File

@@ -1,42 +0,0 @@
.wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 0 4px;
margin-bottom: 8px;
}
.label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1.2px;
font-weight: 700;
min-width: 20px;
}
.select {
appearance: none;
background: rgba(255, 255, 255, 0.07);
border: 1px solid var(--glass-border);
border-radius: 8px;
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
padding: 5px 28px 5px 12px;
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
transition: border-color 0.2s;
}
.select:focus {
outline: none;
border-color: var(--grad-center);
}
.select option {
background: #1a1a2e;
}

View File

@@ -1,4 +1,4 @@
import styles from './NetworkSelect.module.css'
import { Select } from '@shared/ui'
interface Props {
label: string
@@ -9,17 +9,11 @@ interface Props {
export function NetworkSelect({ label, value, onChange, options }: Props) {
return (
<div className={styles.wrap}>
<span className={styles.label}>{label}</span>
<select
className={styles.select}
value={value}
onChange={e => onChange(e.target.value)}
>
{options.map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
<Select
label={label}
value={value}
onChange={onChange}
options={options.map(o => ({ value: o, label: o }))}
/>
)
}

View File

@@ -73,6 +73,7 @@ export function ConverterSection() {
status={notification.status}
message={notification.message}
onClose={dismissNotification}
duration={5000}
/>
)}
</div>

View File

@@ -129,6 +129,7 @@ export function LegalConverterSection() {
status={notice}
message={notice === 'success' ? 'Заявка отправлена' : 'Не удалось отправить заявку'}
onClose={dismissNotice}
duration={5000}
/>
)}
</form>

View File

@@ -172,7 +172,7 @@ export function PoolsWidget() {
/>
)}
{toast && <Notification status={toast.status} message={toast.message} onClose={() => setToast(null)} />}
{toast && <Notification status={toast.status} message={toast.message} onClose={() => setToast(null)} duration={5000} />}
</div>
)
}

View File

@@ -31,7 +31,7 @@ export function IndividualFields({ data }: Props) {
</ProfileSection>
{notification && (
<Notification status={notification.status} message={notification.message} onClose={dismissNotification} />
<Notification status={notification.status} message={notification.message} onClose={dismissNotification} duration={5000} />
)}
</>
)

View File

@@ -131,6 +131,7 @@ export function RestorePasswordForm() {
status={notification.status}
message={notification.message}
onClose={() => setNotification(null)}
duration={5000}
/>
)}
</>

View File

@@ -138,7 +138,7 @@ export function StakingWidget() {
/>
)}
{toast && <Notification status={toast.status} message={toast.message} onClose={() => setToast(null)} />}
{toast && <Notification status={toast.status} message={toast.message} onClose={() => setToast(null)} duration={5000} />}
</div>
)
}

View File

@@ -230,6 +230,7 @@ export function SwapForm() {
status="error"
message={errorMessage}
onClose={() => setErrorMessage(null)}
duration={5000}
/>
)}
</div>

View File

@@ -95,7 +95,7 @@ export function WalletHeader() {
) : (
<div className={styles.avatar} />
)}
<span>{fullName || me?.legal_entity?.name || 'Test account'}</span>
<span>{fullName || me?.legal_entity?.name || ''}</span>
</button>
{open && (
<div className={styles.dropdown}>
@@ -148,8 +148,10 @@ export function WalletHeader() {
{error && (
<Notification
status="error"
title="Ошибка сервера"
message="Произошла ошибка сервера"
onClose={() => setError(false)}
duration={5000}
/>
)}
</>