profile refactor
This commit is contained in:
1
dist/assets/index-BWwkWEN0.css
vendored
1
dist/assets/index-BWwkWEN0.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-CxvesZpU.css
vendored
Normal file
1
dist/assets/index-CxvesZpU.css
vendored
Normal file
File diff suppressed because one or more lines are too long
161
dist/assets/index-D69DAe6P.js
vendored
161
dist/assets/index-D69DAe6P.js
vendored
File diff suppressed because one or more lines are too long
161
dist/assets/index-Dr-hNfiH.js
vendored
Normal file
161
dist/assets/index-Dr-hNfiH.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ЭКСА — Ваш мост в мир цифровых активов</title>
|
||||
<script type="module" crossorigin src="/assets/index-D69DAe6P.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BWwkWEN0.css">
|
||||
<script type="module" crossorigin src="/assets/index-Dr-hNfiH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CxvesZpU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export function ConverterSection() {
|
||||
status={notification.status}
|
||||
message={notification.message}
|
||||
onClose={dismissNotification}
|
||||
duration={5000}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -129,6 +129,7 @@ export function LegalConverterSection() {
|
||||
status={notice}
|
||||
message={notice === 'success' ? 'Заявка отправлена' : 'Не удалось отправить заявку'}
|
||||
onClose={dismissNotice}
|
||||
duration={5000}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -131,6 +131,7 @@ export function RestorePasswordForm() {
|
||||
status={notification.status}
|
||||
message={notification.message}
|
||||
onClose={() => setNotification(null)}
|
||||
duration={5000}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ export function SwapForm() {
|
||||
status="error"
|
||||
message={errorMessage}
|
||||
onClose={() => setErrorMessage(null)}
|
||||
duration={5000}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user