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:
1
dist/assets/index-BE93HmSw.css
vendored
Normal file
1
dist/assets/index-BE93HmSw.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-BrS4UPZI.css
vendored
1
dist/assets/index-BrS4UPZI.css
vendored
File diff suppressed because one or more lines are too long
161
dist/assets/index-M5HC-xbs.js
vendored
Normal file
161
dist/assets/index-M5HC-xbs.js
vendored
Normal file
File diff suppressed because one or more lines are too long
161
dist/assets/index-woqmDgbc.js
vendored
161
dist/assets/index-woqmDgbc.js
vendored
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-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
28
eslint.config.js
Normal 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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
3
src/entities/commission/index.ts
Normal file
3
src/entities/commission/index.ts
Normal 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'
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
102
src/features/payment/model/useCurrencyConversion.ts
Normal file
102
src/features/payment/model/useCurrencyConversion.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
82
src/shared/ui/ConvertField/ConvertField.module.css
Normal file
82
src/shared/ui/ConvertField/ConvertField.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/shared/ui/ConvertField/ConvertField.tsx
Normal file
44
src/shared/ui/ConvertField/ConvertField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
src/shared/ui/ConvertField/index.ts
Normal file
2
src/shared/ui/ConvertField/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ConvertField } from './ConvertField'
|
||||
export type { ConvertFieldData, Currency } from './ConvertField'
|
||||
@@ -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);
|
||||
}
|
||||
29
src/shared/ui/DirectionSwapButton/DirectionSwapButton.tsx
Normal file
29
src/shared/ui/DirectionSwapButton/DirectionSwapButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/shared/ui/DirectionSwapButton/index.ts
Normal file
1
src/shared/ui/DirectionSwapButton/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DirectionSwapButton } from './DirectionSwapButton'
|
||||
@@ -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'
|
||||
|
||||
22
src/widgets/converter-page/model/useConverterSection.ts
Normal file
22
src/widgets/converter-page/model/useConverterSection.ts
Normal 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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? 'Обработка...' : 'Оплатить'}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
src/widgets/swap-bridge-tabs/index.ts
Normal file
1
src/widgets/swap-bridge-tabs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SwapBridgeTabs } from './ui/SwapBridgeTabs'
|
||||
31
src/widgets/swap-bridge-tabs/ui/SwapBridgeTabs.module.css
Normal file
31
src/widgets/swap-bridge-tabs/ui/SwapBridgeTabs.module.css
Normal 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);
|
||||
}
|
||||
28
src/widgets/swap-bridge-tabs/ui/SwapBridgeTabs.tsx
Normal file
28
src/widgets/swap-bridge-tabs/ui/SwapBridgeTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/widgets/wallet-layout/index.ts
Normal file
1
src/widgets/wallet-layout/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WalletLayout } from './ui/WalletLayout'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/widgets/wallet-layout/ui/WalletLayout.tsx
Normal file
21
src/widgets/wallet-layout/ui/WalletLayout.tsx
Normal 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
Reference in New Issue
Block a user