refactor(converter): shared page layout + reusable conversion logic/UI

Pages:
- add WalletLayout route (WalletHeader + main + Footer via <Outlet/>),
  wrap converter/swap/bridge/transactions; thin pages, drop duplicated shell CSS
- extract SwapBridgeTabs shared between swap/bridge pages

Converter reuse (FSD layers, no widget->widget imports):
- move commission tiers to entities/commission (+ CommissionTable ui)
- shared calc hook features/payment/model/useCurrencyConversion;
  useConverterSection becomes thin wrapper; HomePage Converter reuses it
- move ConvertField/DirectionSwapButton to shared/ui; delete dead useConverter

Tooling:
- add eslint.config.js (ESLint 9 flat config); fix no-explicit-any in WalletPage

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 14:39:53 +03:00
parent bdc8bd3d93
commit 9b1d6ffb5d
45 changed files with 684 additions and 1129 deletions

1
dist/assets/index-BE93HmSw.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

161
dist/assets/index-M5HC-xbs.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -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-woqmDgbc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BrS4UPZI.css">
<script type="module" crossorigin src="/assets/index-M5HC-xbs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BE93HmSw.css">
</head>
<body>
<div id="root"></div>

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@@ -16,6 +16,7 @@ import { PolitikaCookiePage } from '@pages/politika-cookie'
import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh'
import { ReestryPage } from '@pages/reestr-pd-rkn'
import { TransactionsPage } from '@pages/transactions'
import { WalletLayout } from '@widgets/wallet-layout'
import { ROUTES } from '@shared/config/routes'
import { ScrollToTop } from './ScrollToTop'
import { ProtectedRoute } from './ProtectedRoute'
@@ -40,15 +41,21 @@ export function RouterProvider() {
</Route>
<Route element={<ProtectedRoute />}>
<Route path={ROUTES.CONVERTER} element={<ConverterPage />} />
<Route element={<WalletLayout footer center />}>
<Route path={ROUTES.CONVERTER} element={<ConverterPage />} />
</Route>
<Route element={<WalletLayout footer />}>
<Route path={ROUTES.SWAP} element={<SwapPage />} />
<Route path={ROUTES.BRIDGE} element={<BridgePage />} />
<Route path={ROUTES.TRANSACTIONS} element={<TransactionsPage />} />
</Route>
<Route path={ROUTES.WALLET} element={<WalletPage />} />
<Route path={ROUTES.WALLET_CHAIN} element={<WalletPage />} />
<Route path={ROUTES.SWAP} element={<SwapPage />} />
<Route path={ROUTES.BRIDGE} element={<BridgePage />} />
<Route path={ROUTES.PROFILE} element={<ProfilePage />} />
<Route path={ROUTES.SEED_PHRASE} element={<SeedPhrasePage />} />
<Route path={ROUTES.KYC} element={<KycPage />} />
<Route path={ROUTES.TRANSACTIONS} element={<TransactionsPage />} />
</Route>
</Routes>
</BrowserRouter>

View File

@@ -0,0 +1,3 @@
export { TIERS, TIER_MIN, TIER_MAX, findTier, progressPercent } from './model/tiers'
export type { Tier } from './model/tiers'
export { CommissionTable } from './ui/CommissionTable'

View File

@@ -1,5 +1,5 @@
import { TIERS } from '@widgets/currency-converter'
import styles from './CommissionPanel.module.css'
import { TIERS } from '../model/tiers'
import styles from './CommissionTable.module.css'
const ru = (n: number) => n.toLocaleString('ru-RU')
@@ -10,7 +10,7 @@ interface Props {
effectiveRate: number
}
export function CommissionPanel({ amount, progress, commission, effectiveRate }: Props) {
export function CommissionTable({ amount, progress, commission, effectiveRate }: Props) {
return (
<div>
<div className={styles.title}>КОМИССИЯ СЕРВИСА</div>

View File

@@ -3,4 +3,5 @@ export { usePaymentQuote } from './hooks/usePaymentQuote'
export { usePaymentQuoteByRub } from './hooks/usePaymentQuoteByRub'
export { useCreateOrder } from './hooks/useCreateOrder'
export { useOrders } from './hooks/useOrders'
export { useCurrencyConversion } from './model/useCurrencyConversion'
export type { PaymentConfig, PaymentQuote, CreateOrderPayload, OrderResult, Order, Payment, OrderWithPayment, OrderStatus, PaymentStatus } from './api/paymentApi'

View File

@@ -0,0 +1,102 @@
import { useState } from 'react'
import { useDebounce } from '@shared/lib/hooks/useDebounce'
import { progressPercent } from '@entities/commission'
import { GAS_PRICE, MIN_RUB_AMOUNT } from '@shared/config/constants'
import type { ConvertFieldData } from '@shared/ui'
import { usePaymentConfig } from '../hooks/usePaymentConfig'
import { usePaymentQuote } from '../hooks/usePaymentQuote'
import { usePaymentQuoteByRub } from '../hooks/usePaymentQuoteByRub'
const TOO_LARGE_ERROR = 'Сумма слишком большая и превышает 600 000 ₽'
const sanitize = (raw: string) => raw.replace(/[^0-9.]/g, '')
interface Options {
/** Значение курса USDT/RUB до загрузки конфига (пилюля). */
rateFallback?: number
}
export function useCurrencyConversion({ rateFallback = 0 }: Options = {}) {
const [direction, setDirection] = useState<'usdt_to_rub' | 'rub_to_usdt'>('usdt_to_rub')
const [usdtInput, setUsdtInput] = useState('1000')
const [rubInput, setRubInput] = useState(String(MIN_RUB_AMOUNT))
const { data: config } = usePaymentConfig()
const configUsdtRate = Number(config?.usdt_exchange_rate) || rateFallback
const gasPriceRub = Number(config?.gas_fee) || GAS_PRICE
const isUsdtToRub = direction === 'usdt_to_rub'
const numUsdt = Number.parseFloat(usdtInput) || 0
const debouncedUsdt = useDebounce(numUsdt, 400)
const { data: quoteUsdtToRub, isError: quoteError } = usePaymentQuote(isUsdtToRub ? debouncedUsdt : 0)
const numRubInput = Number.parseFloat(rubInput) || 0
const debouncedRub = useDebounce(numRubInput, 400)
const { data: quoteRubToUsdt, isError: quoteRubError } = usePaymentQuoteByRub(!isUsdtToRub ? debouncedRub : 0)
const rubBelowMin = !isUsdtToRub && numRubInput > 0 && numRubInput < MIN_RUB_AMOUNT
const rubTotal = quoteUsdtToRub?.total_price ?? ''
const rubTotalNum = Number(rubTotal) || 0
const usdtFromRub = quoteRubToUsdt?.usdt_amount ?? ''
const usdtFromRubNum = Number(usdtFromRub) || 0
const commission = isUsdtToRub
? Number(quoteUsdtToRub?.service_fee) || 0
: Number(quoteRubToUsdt?.service_fee) || 0
const displayRubAmount = isUsdtToRub ? rubTotalNum : numRubInput
const effectiveRate = isUsdtToRub
? (numUsdt > 0 ? rubTotalNum / numUsdt : 0)
: (usdtFromRubNum > 0 ? numRubInput / usdtFromRubNum : 0)
function onSwap() {
setDirection(d => (d === 'usdt_to_rub' ? 'rub_to_usdt' : 'usdt_to_rub'))
}
const convert: ConvertFieldData = isUsdtToRub
? {
value: usdtInput,
currency: 'USDT',
onChange: (raw) => setUsdtInput(sanitize(raw)),
error: quoteError ? TOO_LARGE_ERROR : undefined,
}
: {
value: rubInput,
currency: 'RUB',
onChange: (raw) => setRubInput(sanitize(raw)),
error: rubBelowMin
? `Минимальная сумма: ${MIN_RUB_AMOUNT.toLocaleString('ru-RU')}`
: quoteRubError
? TOO_LARGE_ERROR
: undefined,
}
const pay: ConvertFieldData = isUsdtToRub
? { value: rubTotal, currency: 'RUB' }
: { value: usdtFromRub, currency: 'USDT' }
return {
isUsdtToRub,
gasPriceRub,
configUsdtRate,
convert,
pay,
onSwap,
commission: {
amount: displayRubAmount,
progress: progressPercent(displayRubAmount),
commission,
effectiveRate,
},
// сырые значения для создания ордера и валидации в обёртках
numUsdt,
usdtFromRubNum,
rubTotal,
rubTotalNum,
numRubInput,
usdtFromRub,
rubBelowMin,
}
}

View File

@@ -1,52 +1,12 @@
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg-deep);
}
.tabs {
display: flex;
gap: 8px;
padding: 24px 28px 0;
}
.tab {
padding: 10px 24px;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
border: none;
font-family: var(--font-sans);
letter-spacing: 0.5px;
transition: all 0.2s;
}
.active {
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
color: var(--text-primary);
}
.inactive {
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
}
.inactive:hover {
color: var(--text-primary);
}
.main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 20px 48px;
.content {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 20px 48px;
}
@media (max-width: 650px) {
.main {
padding: 32px 20px;
}
.content {
padding: 32px 20px;
}
}

View File

@@ -1,37 +1,14 @@
import { useNavigate } from 'react-router-dom'
import { Footer } from '@widgets/footer'
import { BridgeForm } from '@widgets/bridge-form'
import { WalletHeader } from '@widgets/wallet-header'
import { ROUTES } from '@shared/config/routes'
import { SwapBridgeTabs } from '@widgets/swap-bridge-tabs'
import styles from './BridgePage.module.css'
export function BridgePage() {
const navigate = useNavigate()
return (
<div className={styles.page}>
<WalletHeader />
<div className={styles.tabs}>
<button
className={`${styles.tab} ${styles.inactive}`}
onClick={() => navigate(ROUTES.SWAP)}
>
СВОП
</button>
<button
className={`${styles.tab} ${styles.active}`}
onClick={() => navigate(ROUTES.BRIDGE)}
>
БРИДЖ
</button>
</div>
<main className={styles.main}>
<>
<SwapBridgeTabs active="bridge" />
<div className={styles.content}>
<BridgeForm />
</main>
<Footer />
</div>
</div>
</>
)
}

View File

@@ -1,16 +1,5 @@
import { ConverterSection } from '@widgets/converter-page'
import { Footer } from '@widgets/footer'
import { WalletHeader } from '@widgets/wallet-header'
import styles from './ConverterPage.module.css'
export function ConverterPage() {
return (
<div className={styles.page}>
<WalletHeader />
<main className={styles.main}>
<ConverterSection />
</main>
<Footer />
</div>
)
return <ConverterSection />
}

View File

@@ -1,52 +1,12 @@
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg-deep);
}
.tabs {
display: flex;
gap: 8px;
padding: 24px 28px 0;
}
.tab {
padding: 10px 24px;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
border: none;
font-family: var(--font-sans);
letter-spacing: 0.5px;
transition: all 0.2s;
}
.active {
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
color: var(--text-primary);
}
.inactive {
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
}
.inactive:hover {
color: var(--text-primary);
}
.main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 20px 48px;
.content {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 20px 48px;
}
@media (max-width: 650px) {
.main {
padding: 32px 20px;
}
}
.content {
padding: 32px 20px;
}
}

View File

@@ -1,37 +1,14 @@
import { useNavigate } from 'react-router-dom'
import { Footer } from '@widgets/footer'
import { SwapForm } from '@widgets/swap-form'
import { WalletHeader } from '@widgets/wallet-header'
import { ROUTES } from '@shared/config/routes'
import { SwapBridgeTabs } from '@widgets/swap-bridge-tabs'
import styles from './SwapPage.module.css'
export function SwapPage() {
const navigate = useNavigate()
return (
<div className={styles.page}>
<WalletHeader />
<div className={styles.tabs}>
<button
className={`${styles.tab} ${styles.active}`}
onClick={() => navigate(ROUTES.SWAP)}
>
СВОП
</button>
<button
className={`${styles.tab} ${styles.inactive}`}
onClick={() => navigate(ROUTES.BRIDGE)}
>
БРИДЖ
</button>
</div>
<main className={styles.main}>
<>
<SwapBridgeTabs active="swap" />
<div className={styles.content}>
<SwapForm />
</main>
<Footer />
</div>
</div>
</>
)
}

View File

@@ -1,12 +1,4 @@
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-deep);
}
.main {
flex: 1;
.inner {
padding: 28px 32px 40px;
max-width: 1200px;
width: 100%;
@@ -36,7 +28,7 @@
}
@media (max-width: 900px) {
.main {
.inner {
padding: 20px 16px 32px;
}

View File

@@ -1,18 +1,12 @@
import { WalletHeader } from '@widgets/wallet-header'
import { Footer } from '@widgets/footer'
import { TransactionsList } from '@widgets/transactions-list'
import styles from './TransactionsPage.module.css'
export function TransactionsPage() {
return (
<div className={styles.page}>
<WalletHeader />
<main className={styles.main}>
<div className={styles.glow} />
<h1 className={styles.title}>Транзакции</h1>
<TransactionsList />
</main>
<Footer />
<div className={styles.inner}>
<div className={styles.glow} />
<h1 className={styles.title}>Транзакции</h1>
<TransactionsList />
</div>
)
}

View File

@@ -16,7 +16,7 @@ export function WalletPage() {
const navigate = useNavigate()
const { chain: chainParam } = useParams<{ chain?: string }>()
const noWallet = (portfolioError as any)?.error?.includes('No wallets')
const noWallet = (portfolioError as { error?: string } | null)?.error?.includes('No wallets')
if (isLoading) return null
if (isError) return <div className={styles.error}>Произошла ошибка. Попробуйте обновить страницу.</div>

View File

@@ -0,0 +1,82 @@
.field {
margin-bottom: 24px;
}
.compact {
margin-bottom: 12px;
}
.fieldError {
margin-top: 8px;
font-size: 13px;
color: var(--error);
}
.fieldLabel {
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 8px;
}
.fieldInput {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 0 16px;
transition: border-color 0.3s;
}
.fieldInput:focus-within {
border-color: var(--interactive);
}
.fieldInput input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 18px;
padding: 16px 0;
width: 100%;
}
.currency {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.currencyIcon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #fff;
}
.currencyRub {
background: var(--interactive);
}
.currencyUsdt {
background: var(--success);
}
@media (max-width: 1024px) {
.field {
margin-bottom: 1rem;
}
}

View File

@@ -0,0 +1,44 @@
import styles from './ConvertField.module.css'
export type Currency = 'USDT' | 'RUB'
export interface ConvertFieldData {
value: string
currency: Currency
onChange?: (raw: string) => void
error?: string
}
interface Props extends ConvertFieldData {
label?: string
compact?: boolean
}
export function ConvertField({ label, value, currency, onChange, error, compact }: Props) {
const readOnly = !onChange
return (
<div className={compact ? `${styles.field} ${styles.compact}` : styles.field}>
{label && <div className={styles.fieldLabel}>{label}</div>}
<div className={styles.fieldInput}>
<input
type="text"
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
readOnly={readOnly}
placeholder="0"
inputMode={readOnly ? undefined : 'decimal'}
/>
<div className={styles.currency}>
<span
className={`${styles.currencyIcon} ${currency === 'USDT' ? styles.currencyUsdt : styles.currencyRub}`}
>
{currency === 'USDT' ? '₮' : '₽'}
</span>
{currency}
</div>
</div>
{error && <div className={styles.fieldError}>{error}</div>}
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { ConvertField } from './ConvertField'
export type { ConvertFieldData, Currency } from './ConvertField'

View File

@@ -0,0 +1,27 @@
.swapWrap {
display: flex;
justify-content: center;
}
.compact {
margin-bottom: 12px;
}
.swapBtn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
color: var(--text-secondary);
}
.swapBtn:hover {
border-color: var(--highlight);
color: var(--highlight);
transform: rotate(180deg);
}

View File

@@ -0,0 +1,29 @@
import styles from './DirectionSwapButton.module.css'
interface Props {
onClick: () => void
compact?: boolean
}
export function DirectionSwapButton({ onClick, compact }: Props) {
return (
<div className={compact ? `${styles.swapWrap} ${styles.compact}` : styles.swapWrap}>
<button
type="button"
className={styles.swapBtn}
onClick={onClick}
aria-label="Поменять направление"
>
<svg width={16} height={16} viewBox="0 0 16 16" fill="none">
<path
d="M8 2v12M4 10l4 4 4-4"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
)
}

View File

@@ -0,0 +1 @@
export { DirectionSwapButton } from './DirectionSwapButton'

View File

@@ -1,4 +1,7 @@
export { Button } from './Button'
export { ConvertField } from './ConvertField'
export type { ConvertFieldData, Currency } from './ConvertField'
export { DirectionSwapButton } from './DirectionSwapButton'
export { FormField } from './FormField'
export { Notification } from './Notification'
export { Pill } from './Pill'

View File

@@ -0,0 +1,22 @@
import { useState } from 'react'
import { useCurrencyConversion, useCreateOrder } from '@features/payment'
export function useConverterSection() {
const conv = useCurrencyConversion()
const [agreed, setAgreed] = useState(false)
const { mutate: submitOrder, isPending } = useCreateOrder()
function onPay() {
submitOrder(
conv.isUsdtToRub
? { usdt_amount: conv.numUsdt, usdt_exchange_rate: 1, gas_fee: 1, total_price: conv.rubTotalNum }
: { usdt_amount: conv.usdtFromRubNum, usdt_exchange_rate: 1, gas_fee: 1, total_price: conv.numRubInput },
)
}
const isPayDisabled = conv.isUsdtToRub
? (!conv.rubTotal || isPending || !agreed)
: (!conv.usdtFromRub || isPending || !agreed || conv.rubBelowMin)
return { ...conv, agreed, setAgreed, onPay, isPending, isPayDisabled }
}

View File

@@ -1,82 +0,0 @@
.title {
font-size: 16px;
font-weight: 600;
letter-spacing: 1px;
margin-bottom: 24px;
}
.table {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
border: 1px solid var(--glass-border);
transition: all 0.3s ease;
}
.row[data-active] {
border-color: var(--grad-center);
background: rgba(91, 61, 184, 0.12);
}
.range {
font-size: 14px;
color: var(--text-secondary);
}
.pct {
font-family: var(--font-mono);
font-size: 16px;
font-weight: 700;
color: var(--highlight);
}
.progressBar {
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
margin-bottom: 32px;
overflow: hidden;
}
.progressFill {
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, var(--grad-center), var(--highlight));
transition: width 0.5s ease;
}
.summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
border: 1px solid var(--glass-border);
margin-bottom: 16px;
}
.summary:last-child {
margin-bottom: 0;
}
.summaryLabel {
font-size: 13px;
color: var(--text-secondary);
}
.summaryValue {
font-family: var(--font-mono);
font-size: 16px;
font-weight: 600;
}

View File

@@ -88,103 +88,6 @@
background: rgba(255, 255, 255, 0.04);
}
.field {
margin-bottom: 24px;
}
.fieldError {
margin-top: 8px;
font-size: 13px;
color: var(--error);
}
.fieldLabel {
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 8px;
}
.fieldInput {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 0 16px;
transition: border-color 0.3s;
}
.fieldInput:focus-within {
border-color: var(--interactive);
}
.fieldInput input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 18px;
padding: 16px 0;
width: 100%;
}
.currency {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.currencyIcon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #fff;
}
.currencyRub {
background: var(--interactive);
}
.currencyUsdt {
background: var(--success);
}
.swapWrap {
display: flex;
justify-content: center;
}
.swapBtn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
color: var(--text-secondary);
}
.swapBtn:hover {
border-color: var(--highlight);
color: var(--highlight);
transform: rotate(180deg);
}
.bottom {
display: flex;
justify-content: center;
@@ -225,10 +128,6 @@
margin-bottom: 1.5rem;
}
.field {
margin-bottom: 1rem;
}
.bottom {
margin-top: 1.5rem;
padding-top: 1rem;
@@ -249,4 +148,4 @@
.wrap {
padding: 0;
}
}
}

View File

@@ -1,78 +1,23 @@
import { useState } from 'react'
import { useConverter, progressPercent } from '@widgets/currency-converter'
import { useDebounce } from '@shared/lib/hooks/useDebounce'
import { usePaymentQuote, usePaymentQuoteByRub, usePaymentConfig, useCreateOrder } from '@features/payment'
import { CommissionPanel } from './CommissionPanel'
import { ConvertField, DirectionSwapButton } from '@shared/ui'
import { CommissionTable } from '@entities/commission'
import { useConverterSection } from '../model/useConverterSection'
import { AgreementCheck } from './AgreementCheck'
import styles from './ConverterSection.module.css'
import { GAS_PRICE, MIN_RUB_AMOUNT } from '@shared/config/constants'
export function ConverterSection() {
const c = useConverter({ usdtRate: 0 })
const [direction, setDirection] = useState<'usdt_to_rub' | 'rub_to_usdt'>('usdt_to_rub')
const [rubInputVal, setRubInputVal] = useState(String(MIN_RUB_AMOUNT))
const { data: config } = usePaymentConfig()
const configUsdtRate = Number(config?.usdt_exchange_rate) || 0
const gasPriceRub = Number(config?.gas_fee) || GAS_PRICE
const isUsdtToRub = direction === 'usdt_to_rub'
const debouncedUsdt = useDebounce(c.numRub, 400)
const { data: quoteUsdtToRub, isError: quoteError } = usePaymentQuote(isUsdtToRub ? debouncedUsdt : 0)
const numRubInput = Number.parseFloat(rubInputVal) || 0
const debouncedRub = useDebounce(numRubInput, 400)
const { data: quoteRubToUsdt, isError: quoteRubError } = usePaymentQuoteByRub(!isUsdtToRub ? debouncedRub : 0)
const rubBelowMin = !isUsdtToRub && numRubInput > 0 && numRubInput < MIN_RUB_AMOUNT
function updateRubInput(raw: string) {
setRubInputVal(raw.replace(/[^0-9.]/g, ''))
}
function handleSwap() {
setDirection(d => d === 'usdt_to_rub' ? 'rub_to_usdt' : 'usdt_to_rub')
}
const rubTotal = quoteUsdtToRub?.total_price ?? ''
const rubTotalNum = Number(rubTotal) || 0
const usdtFromRub = quoteRubToUsdt?.usdt_amount ?? ''
const usdtFromRubNum = Number(usdtFromRub) || 0
const commission = isUsdtToRub
? Number(quoteUsdtToRub?.service_fee) || 0
: Number(quoteRubToUsdt?.service_fee) || 0
const displayRubAmount = isUsdtToRub ? rubTotalNum : numRubInput
const effectiveRate = isUsdtToRub
? (c.numRub > 0 ? rubTotalNum / c.numRub : 0)
: (usdtFromRubNum > 0 ? numRubInput / usdtFromRubNum : 0)
const { mutate: submitOrder, isPending } = useCreateOrder()
function handlePay() {
if (isUsdtToRub) {
submitOrder({
usdt_amount: c.numRub,
usdt_exchange_rate: 1,
gas_fee: 1,
total_price: Number(rubTotal) || 0,
})
} else {
submitOrder({
usdt_amount: usdtFromRubNum,
usdt_exchange_rate: 1,
gas_fee: 1,
total_price: numRubInput,
})
}
}
const isPayDisabled = isUsdtToRub
? (!rubTotal || isPending || !c.agreed)
: (!usdtFromRub || isPending || !c.agreed || rubBelowMin)
const {
gasPriceRub,
configUsdtRate,
convert,
pay,
onSwap,
commission,
agreed,
setAgreed,
onPay,
isPending,
isPayDisabled,
} = useConverterSection()
return (
<div className={styles.wrap}>
@@ -99,138 +44,22 @@ export function ConverterSection() {
</div>
</div>
{isUsdtToRub ? (
<>
<div className={styles.field}>
<div className={styles.fieldLabel}>Конвертируете</div>
<div className={styles.fieldInput}>
<input
type="text"
value={c.rubVal}
onChange={(e) => c.updateRub(e.target.value)}
placeholder="0"
inputMode="decimal"
/>
<div className={styles.currency}>
<span className={`${styles.currencyIcon} ${styles.currencyUsdt}`}></span>
USDT
</div>
</div>
{quoteError && (
<div className={styles.fieldError}>
Сумма слишком большая и превышает 600 000
</div>
)}
</div>
<div className={styles.swapWrap}>
<button
type="button"
className={styles.swapBtn}
onClick={handleSwap}
aria-label="Поменять направление"
>
<svg width={16} height={16} viewBox="0 0 16 16" fill="none">
<path
d="M8 2v12M4 10l4 4 4-4"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<div className={styles.field}>
<div className={styles.fieldLabel}>Платите</div>
<div className={styles.fieldInput}>
<input type="text" value={rubTotal} readOnly placeholder="0" />
<div className={styles.currency}>
<span className={`${styles.currencyIcon} ${styles.currencyRub}`}></span>
RUB
</div>
</div>
</div>
</>
) : (
<>
<div className={styles.field}>
<div className={styles.fieldLabel}>Конвертируете</div>
<div className={styles.fieldInput}>
<input
type="text"
value={rubInputVal}
onChange={(e) => updateRubInput(e.target.value)}
placeholder="0"
inputMode="decimal"
/>
<div className={styles.currency}>
<span className={`${styles.currencyIcon} ${styles.currencyRub}`}></span>
RUB
</div>
</div>
{rubBelowMin && (
<div className={styles.fieldError}>
Минимальная сумма: {MIN_RUB_AMOUNT.toLocaleString('ru-RU')}
</div>
)}
{quoteRubError && !rubBelowMin && (
<div className={styles.fieldError}>
Сумма слишком большая и превышает 600 000
</div>
)}
</div>
<div className={styles.swapWrap}>
<button
type="button"
className={styles.swapBtn}
onClick={handleSwap}
aria-label="Поменять направление"
>
<svg width={16} height={16} viewBox="0 0 16 16" fill="none">
<path
d="M8 2v12M4 10l4 4 4-4"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<div className={styles.field}>
<div className={styles.fieldLabel}>Платите</div>
<div className={styles.fieldInput}>
<input type="text" value={usdtFromRub} readOnly placeholder="0" />
<div className={styles.currency}>
<span className={`${styles.currencyIcon} ${styles.currencyUsdt}`}></span>
USDT
</div>
</div>
</div>
</>
)}
<ConvertField label="Конвертируете" {...convert} />
<DirectionSwapButton onClick={onSwap} />
<ConvertField label="Платите" {...pay} />
</div>
<CommissionPanel
amount={displayRubAmount}
progress={progressPercent(displayRubAmount)}
commission={commission}
effectiveRate={effectiveRate}
/>
<CommissionTable {...commission} />
</div>
<div className={styles.bottom}>
<AgreementCheck checked={c.agreed} onToggle={() => c.setAgreed(!c.agreed)} />
<AgreementCheck checked={agreed} onToggle={() => setAgreed(!agreed)} />
</div>
<button
type="button"
className={styles.payBtn}
onClick={handlePay}
onClick={onPay}
disabled={isPayDisabled}
>
{isPending ? 'Обработка...' : 'Оплатить'}

View File

@@ -1,5 +1 @@
export { Converter } from './ui/Converter'
export { useConverter } from './model/useConverter'
export type { ConverterMode } from './model/useConverter'
export { TIERS, findTier, progressPercent } from './model/tiers'
export type { Tier } from './model/tiers'

View File

@@ -1,51 +0,0 @@
import { useMemo, useState } from 'react'
import { findTier, progressPercent } from './tiers'
export type ConverterMode = 'buy' | 'sell'
interface UseConverterArgs {
usdtRate: number
}
export function useConverter({ usdtRate }: UseConverterArgs) {
const [mode, setMode] = useState<ConverterMode>('buy')
const [rubVal, setRubVal] = useState<string>('1000')
const [agreed, setAgreed] = useState<boolean>(false)
const numRub = Number.parseFloat(rubVal) || 0
const result = useMemo(() => {
const tier = findTier(numRub)
const commission = (numRub * tier.pct) / 100
const sign = mode === 'buy' ? 1 : -1
const effectiveRate = usdtRate * (1 + (sign * tier.pct) / 100)
const usdtVal = numRub > 0 ? (numRub / effectiveRate).toFixed(2) : '0.00'
return {
tierPct: tier.pct,
commission,
effectiveRate,
usdtVal,
progress: progressPercent(numRub),
}
}, [numRub, mode, usdtRate])
function updateRub(raw: string) {
setRubVal(raw.replace(/[^0-9.]/g, ''))
}
function toggleMode() {
setMode((m) => (m === 'buy' ? 'sell' : 'buy'))
}
return {
mode,
setMode,
rubVal,
updateRub,
numRub,
agreed,
setAgreed,
toggleMode,
...result,
}
}

View File

@@ -1,34 +0,0 @@
import { TIERS } from '../model/tiers'
import styles from './CommissionTable.module.css'
import { Tiers } from './Tiers'
interface Props {
amount: number
progress: number
commission: number
effectiveRate: number
}
export function CommissionTable({ amount, progress, commission, effectiveRate }: Props) {
return (
<div>
<div className={styles.title}>КОМИССИЯ СЕРВИСА</div>
<div className={styles.table}>
<Tiers tiers={TIERS} amount={amount} />
</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{ width: `${progress}%` }} />
</div>
<div className={styles.summary}>
<span className={styles.summaryLabel}>Комиссия</span>
<span className={styles.summaryValue}>
{commission.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}
</span>
</div>
<div className={styles.summary}>
<span className={styles.summaryLabel}>Курс с комиссией</span>
<span className={styles.summaryValue}>{effectiveRate.toFixed(2)} </span>
</div>
</div>
)
}

View File

@@ -23,11 +23,6 @@
margin-bottom: 40px;
}
.title {
font-size: clamp(36px, 4vw, 52px);
font-weight: 700;
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
@@ -97,112 +92,6 @@
background: rgba(255, 255, 255, 0.04);
}
.field {
margin-bottom: 12px;
}
.fieldError {
margin-top: 8px;
font-size: 13px;
color: var(--error);
}
.fieldLabel {
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 8px;
}
.fieldInput {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 0 16px;
transition: border-color 0.3s;
}
.fieldInput:focus-within {
border-color: var(--interactive);
}
.fieldInput input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 18px;
padding: 16px 0;
width: 100%;
}
.currency {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.currencyIcon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #fff;
}
.currencyRub {
background: var(--interactive);
}
.currencyUsdt {
background: var(--success);
}
.swapWrap {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.swapBtn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
color: var(--text-secondary);
}
.swapBtn:hover {
border-color: var(--highlight);
color: var(--highlight);
transform: rotate(180deg);
}
.bottom {
display: flex;
justify-content: center;
margin-top: 40px;
padding-top: 32px;
border-top: 1px solid var(--glass-border);
}
.payBtn {
display: flex;
align-items: center;
@@ -242,10 +131,6 @@
margin-bottom: 1.5rem;
}
.field {
margin-bottom: 1rem;
}
.bottom {
margin-top: 1.5rem;
padding-top: 1rem;
@@ -265,4 +150,4 @@
.wrap {
padding: 28px;
}
}
}

View File

@@ -1,57 +1,15 @@
import { useState } from 'react'
import { GAS_PRICE, USDT_RATE, MIN_RUB_AMOUNT } from '@shared/config/constants'
import { useConverter } from '../model/useConverter'
import { progressPercent } from '../model/tiers'
import { usePaymentConfig, usePaymentQuote, usePaymentQuoteByRub } from '@features/payment'
import { useDebounce } from '@shared/lib/hooks/useDebounce'
import { CommissionTable } from './CommissionTable'
import styles from './Converter.module.css'
import { Title } from '@shared/ui/Title/Title'
import { Link } from 'react-router-dom'
import { ConvertField, DirectionSwapButton } from '@shared/ui'
import { CommissionTable } from '@entities/commission'
import { useCurrencyConversion } from '@features/payment'
import { Title } from '@shared/ui/Title/Title'
import { ROUTES } from '@shared/config/routes'
import { USDT_RATE } from '@shared/config/constants'
import styles from './Converter.module.css'
export function Converter() {
const { data: config } = usePaymentConfig()
const [direction, setDirection] = useState<'usdt_to_rub' | 'rub_to_usdt'>('usdt_to_rub')
const [rubInputVal, setRubInputVal] = useState(String(MIN_RUB_AMOUNT))
const configUsdtRate = Number(config?.usdt_exchange_rate) || USDT_RATE
const gasPriceRub = Number(config?.gas_fee) || GAS_PRICE
const c = useConverter({ usdtRate: configUsdtRate })
const isUsdtToRub = direction === 'usdt_to_rub'
const debouncedUsdt = useDebounce(c.numRub, 400)
const { data: quoteUsdtToRub } = usePaymentQuote(isUsdtToRub ? debouncedUsdt : 0)
const numRubInput = Number.parseFloat(rubInputVal) || 0
const debouncedRub = useDebounce(numRubInput, 400)
const { data: quoteRubToUsdt, isError: quoteRubError } = usePaymentQuoteByRub(!isUsdtToRub ? debouncedRub : 0)
const rubBelowMin = !isUsdtToRub && numRubInput > 0 && numRubInput < MIN_RUB_AMOUNT
function updateRubInput(raw: string) {
setRubInputVal(raw.replace(/[^0-9.]/g, ''))
}
function handleSwap() {
setDirection(d => d === 'usdt_to_rub' ? 'rub_to_usdt' : 'usdt_to_rub')
}
const rubTotal = quoteUsdtToRub?.total_price ?? ''
const rubTotalNum = Number(rubTotal) || 0
const usdtFromRub = quoteRubToUsdt?.usdt_amount ?? ''
const usdtFromRubNum = Number(usdtFromRub) || 0
const commission = isUsdtToRub
? Number(quoteUsdtToRub?.service_fee) || 0
: Number(quoteRubToUsdt?.service_fee) || 0
const displayRubAmount = isUsdtToRub ? rubTotalNum : numRubInput
const effectiveRate = isUsdtToRub
? (c.numRub > 0 ? rubTotalNum / c.numRub : 0)
: (usdtFromRubNum > 0 ? numRubInput / usdtFromRubNum : 0)
const { gasPriceRub, configUsdtRate, convert, pay, onSwap, commission } =
useCurrencyConversion({ rateFallback: USDT_RATE })
return (
<section className={styles.section} id="converter">
@@ -79,115 +37,12 @@ export function Converter() {
</div>
</div>
{isUsdtToRub ? (
<>
<div className={styles.field}>
<div className={styles.fieldInput}>
<input
type="text"
value={c.rubVal}
onChange={(e) => c.updateRub(e.target.value)}
placeholder="0"
inputMode="decimal"
/>
<div className={styles.currency}>
<span className={`${styles.currencyIcon} ${styles.currencyUsdt}`}></span> USDT
</div>
</div>
</div>
<div className={styles.swapWrap}>
<button
type="button"
className={styles.swapBtn}
onClick={handleSwap}
aria-label="Поменять направление"
>
<svg width={16} height={16} viewBox="0 0 16 16" fill="none">
<path
d="M8 2v12M4 10l4 4 4-4"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<div className={styles.field}>
<div className={styles.fieldInput}>
<input type="text" value={rubTotal} readOnly placeholder="0" />
<div className={styles.currency}>
<span className={`${styles.currencyIcon} ${styles.currencyRub}`}></span> RUB
</div>
</div>
</div>
</>
) : (
<>
<div className={styles.field}>
<div className={styles.fieldInput}>
<input
type="text"
value={rubInputVal}
onChange={(e) => updateRubInput(e.target.value)}
placeholder="0"
inputMode="decimal"
/>
<div className={styles.currency}>
<span className={`${styles.currencyIcon} ${styles.currencyRub}`}></span> RUB
</div>
</div>
{rubBelowMin && (
<div className={styles.fieldError}>
Минимальная сумма: {MIN_RUB_AMOUNT.toLocaleString('ru-RU')}
</div>
)}
{quoteRubError && !rubBelowMin && (
<div className={styles.fieldError}>
Сумма слишком большая и превышает 600 000
</div>
)}
</div>
<div className={styles.swapWrap}>
<button
type="button"
className={styles.swapBtn}
onClick={handleSwap}
aria-label="Поменять направление"
>
<svg width={16} height={16} viewBox="0 0 16 16" fill="none">
<path
d="M8 2v12M4 10l4 4 4-4"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<div className={styles.field}>
<div className={styles.fieldInput}>
<input type="text" value={usdtFromRub} readOnly placeholder="0" />
<div className={styles.currency}>
<span className={`${styles.currencyIcon} ${styles.currencyUsdt}`}></span> USDT
</div>
</div>
</div>
</>
)}
<ConvertField compact {...convert} />
<DirectionSwapButton compact onClick={onSwap} />
<ConvertField compact {...pay} />
</div>
<CommissionTable
amount={displayRubAmount}
progress={progressPercent(displayRubAmount)}
commission={commission}
effectiveRate={effectiveRate}
/>
<CommissionTable {...commission} />
</div>
<Link to={ROUTES.CONVERTER} className={styles.payBtn}>

View File

@@ -1,27 +0,0 @@
import { TIERS } from "../model/tiers"
import styles from './CommissionTable.module.css'
const ru = (n: number) => n.toLocaleString('ru-RU')
type TiersProps = {
tiers: typeof TIERS
amount: number
}
export const Tiers = ({ tiers, amount }: TiersProps) => {
return (
<>
{tiers.map((tier, i) => {
const active = amount >= tier.min && amount <= tier.max
return (
<div key={i} className={styles.row} data-active={active || undefined}>
<span className={styles.range}>
{ru(tier.min)} {ru(tier.max)}
</span>
<span className={styles.pct}>{tier.pct}%</span>
</div>
)
})}
</>
)
}

View File

@@ -0,0 +1 @@
export { SwapBridgeTabs } from './ui/SwapBridgeTabs'

View File

@@ -0,0 +1,31 @@
.tabs {
display: flex;
gap: 8px;
padding: 24px 28px 0;
}
.tab {
padding: 10px 24px;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
border: none;
font-family: var(--font-sans);
letter-spacing: 0.5px;
transition: all 0.2s;
}
.active {
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
color: var(--text-primary);
}
.inactive {
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
}
.inactive:hover {
color: var(--text-primary);
}

View File

@@ -0,0 +1,28 @@
import { useNavigate } from 'react-router-dom'
import { ROUTES } from '@shared/config/routes'
import styles from './SwapBridgeTabs.module.css'
interface Props {
active: 'swap' | 'bridge'
}
export function SwapBridgeTabs({ active }: Props) {
const navigate = useNavigate()
return (
<div className={styles.tabs}>
<button
className={`${styles.tab} ${active === 'swap' ? styles.active : styles.inactive}`}
onClick={() => navigate(ROUTES.SWAP)}
>
СВОП
</button>
<button
className={`${styles.tab} ${active === 'bridge' ? styles.active : styles.inactive}`}
onClick={() => navigate(ROUTES.BRIDGE)}
>
БРИДЖ
</button>
</div>
)
}

View File

@@ -0,0 +1 @@
export { WalletLayout } from './ui/WalletLayout'

View File

@@ -7,15 +7,18 @@
.main {
flex: 1;
padding: 28px 32px 40px;
width: 100%;
}
.center {
display: flex;
justify-content: center;
align-items: center;
padding: 28px 32px 40px;
}
@media (max-width: 900px) {
.main {
.center {
padding: 20px 16px 32px;
}
}
}

View File

@@ -0,0 +1,21 @@
import { Outlet } from 'react-router-dom'
import { WalletHeader } from '@widgets/wallet-header'
import { Footer } from '@widgets/footer'
import styles from './WalletLayout.module.css'
interface Props {
footer?: boolean
center?: boolean
}
export function WalletLayout({ footer = false, center = false }: Props) {
return (
<div className={styles.page}>
<WalletHeader />
<main className={center ? `${styles.main} ${styles.center}` : styles.main}>
<Outlet />
</main>
{footer && <Footer />}
</div>
)
}

File diff suppressed because one or more lines are too long