This commit is contained in:
2026-06-10 15:27:12 +03:00
parent b3752585ca
commit 493ba4ccef
17 changed files with 898 additions and 4756 deletions

File diff suppressed because one or more lines are too long

161
dist/assets/index-BwMrNKcv.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-D4qEPrTa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CneFMUxK.css">
<script type="module" crossorigin src="/assets/index-BwMrNKcv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BGYsKcOB.css">
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
import { getCsrfToken } from '@shared/api/csrf'
import { refreshAccessToken } from '@shared/api/tokenStore'
const B2B_API_URL = 'https://app.b2b.elcsa.ru/api'
async function doB2bRequest<T>(
path: string,
options: RequestInit,
allowRetry: boolean,
): Promise<T> {
const csrf = await getCsrfToken()
const res = await fetch(`${B2B_API_URL}${path}`, {
...options,
credentials: 'include',
headers: {
'X-CSRF-Token': csrf,
...options.headers,
},
})
if (res.status === 401 && allowRetry) {
try {
await refreshAccessToken()
return doB2bRequest<T>(path, options, false)
} catch {
throw new Error('Unauthorized')
}
}
const data = await res.json()
if (!res.ok) throw data
return data as T
}
export interface CreatePurchaseRequestBody {
usdt_amount: number | string
comment?: string | null
target_wallet_chain?: string | null
target_wallet_address?: string | null
}
export interface B2bPurchaseRequest {
id: string
organization_id: string
status: string
usdt_amount: string
rub_amount: string | null
exchange_rate: string | null
service_fee_percent: string | null
comment: string | null
admin_comment: string | null
target_wallet_chain: string | null
target_wallet_address: string | null
tx_hash: string | null
created_at: string | null
updated_at: string | null
completed_at: string | null
}
export interface B2bPurchaseRequestListResponse {
items: B2bPurchaseRequest[]
total: number
}
export function createPurchaseRequest(
body: CreatePurchaseRequestBody,
): Promise<B2bPurchaseRequest> {
return doB2bRequest('/purchase-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}, true)
}
export function getMyPurchaseRequests(
limit = 50,
offset = 0,
): Promise<B2bPurchaseRequestListResponse> {
return doB2bRequest(`/purchase-requests?limit=${limit}&offset=${offset}`, {}, true)
}

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createPurchaseRequest } from '../api/b2bApi'
export function useCreatePurchaseRequest() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createPurchaseRequest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['b2b', 'purchase-requests'] })
},
})
}

View File

@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { getMyPurchaseRequests } from '../api/b2bApi'
export function useMyPurchaseRequests() {
return useQuery({
queryKey: ['b2b', 'purchase-requests'],
queryFn: () => getMyPurchaseRequests(),
staleTime: 30_000,
})
}

View File

@@ -0,0 +1,7 @@
export { useCreatePurchaseRequest } from './hooks/useCreatePurchaseRequest'
export { useMyPurchaseRequests } from './hooks/useMyPurchaseRequests'
export type {
CreatePurchaseRequestBody,
B2bPurchaseRequest,
B2bPurchaseRequestListResponse,
} from './api/b2bApi'

View File

@@ -581,6 +581,24 @@
"default": 0,
"title": "Offset"
}
},
{
"name": "q",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 255
},
{
"type": "null"
}
],
"title": "Q"
}
}
],
"responses": {
@@ -806,6 +824,153 @@
}
}
},
"/v1/organizations/search": {
"get": {
"tags": [
"organizations"
],
"summary": "Search Parties",
"operationId": "search_parties_v1_organizations_search_get",
"parameters": [
{
"name": "q",
"in": "query",
"required": true,
"schema": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"title": "Q"
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"maximum": 200,
"minimum": 1,
"default": 50,
"title": "Limit"
}
},
{
"name": "offset",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0,
"title": "Offset"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PartySearchListResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"429": {
"description": "Too Many Requests",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/organizations/{organization_id}": {
"get": {
"tags": [
@@ -4833,6 +4998,134 @@
],
"title": "OrganizationResponse"
},
"PartySearchListResponse": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/PartySearchResponse"
},
"type": "array",
"title": "Items"
},
"total": {
"type": "integer",
"title": "Total"
}
},
"type": "object",
"required": [
"items",
"total"
],
"title": "PartySearchListResponse"
},
"PartySearchResponse": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"account_type": {
"type": "string",
"title": "Account Type"
},
"user_id": {
"type": "string",
"title": "User Id"
},
"email": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Email"
},
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Name"
},
"inn": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Inn"
},
"phone": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Phone"
},
"status": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Status"
},
"kyc_verified": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Kyc Verified"
},
"created_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Created At"
}
},
"type": "object",
"required": [
"id",
"account_type",
"user_id",
"email",
"name",
"inn",
"phone",
"status",
"kyc_verified",
"created_at"
],
"title": "PartySearchResponse"
},
"PurchaseRequestListResponse": {
"properties": {
"items": {

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { FormField, Select } from '@shared/ui'
import { useCreatePurchaseRequest } from '@features/b2b'
import { FormField, Notification, Select } from '@shared/ui'
import styles from './LegalConverterPage.module.css'
const MIN_ORDER = 500_000
@@ -35,6 +36,9 @@ export function LegalConverterPage() {
const [name, setName] = useState('')
const [contact, setContact] = useState('')
const [days, setDays] = useState<number>(TERM_OPTIONS[0].days)
const [notice, setNotice] = useState<'success' | 'error' | null>(null)
const { mutate: submitRequest, isPending } = useCreatePurchaseRequest()
const numAmount = Number(amount.replace(/\D/g, '')) || 0
const belowMin = numAmount > 0 && numAmount < MIN_ORDER
@@ -50,7 +54,31 @@ export function LegalConverterPage() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Бэкенд пока не подключён — заявка никуда не отправляется.
if (numAmount === 0 || belowMin || isPending) return
// Отдельных полей под имя/контакт/срок/сумму в ₽ у API нет — всё уходит в comment.
const comment = [
name && `Имя: ${name}`,
contact && `Контакт: ${contact}`,
`Срок ожидания: ${dayLabel(days)}`,
`Сумма: ${ru(numAmount)}`,
`Комиссия: ≈${ru(commission)}`,
`Итого: ≈${ru(total)}`,
].filter(Boolean).join('; ')
submitRequest(
{ usdt_amount: numAmount, comment },
{
onSuccess: () => {
setNotice('success')
setAmount('')
setName('')
setContact('')
setDays(TERM_OPTIONS[0].days)
},
onError: () => setNotice('error'),
},
)
}
return (
@@ -145,9 +173,21 @@ export function LegalConverterPage() {
</div>
</div>
<button type="submit" className={styles.submitBtn} disabled={belowMin}>
Оставить заявку
<button
type="submit"
className={styles.submitBtn}
disabled={belowMin || numAmount === 0 || isPending}
>
{isPending ? 'Отправляем...' : 'Оставить заявку'}
</button>
{notice && (
<Notification
status={notice}
message={notice === 'success' ? 'Заявка отправлена' : 'Не удалось отправить заявку'}
onClose={() => setNotice(null)}
/>
)}
</form>
)
}

View File

@@ -27,6 +27,41 @@
z-index: 1;
}
.tabs {
display: flex;
gap: 8px;
margin: 0 0 20px;
position: relative;
z-index: 1;
}
.tab {
display: inline-flex;
align-items: center;
height: 38px;
padding: 0 20px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.tab:hover {
border-color: rgba(74, 109, 255, 0.4);
color: var(--text-primary);
}
.tab[data-active] {
background: rgba(74, 109, 255, 0.12);
border-color: rgba(74, 109, 255, 0.5);
color: var(--interactive, #4a6dff);
}
@media (max-width: 900px) {
.inner {
padding: 20px 16px 32px;

View File

@@ -1,12 +1,45 @@
import { useState } from 'react'
import { useMe } from '@features/auth'
import { TransactionsList } from '@widgets/transactions-list'
import { PurchaseRequestsList } from '@widgets/purchase-requests-list'
import styles from './TransactionsPage.module.css'
type Tab = 'transactions' | 'requests'
export function TransactionsPage() {
const { data } = useMe()
const [tab, setTab] = useState<Tab>('transactions')
const isLegal = !!data && data.account_type !== 'individual'
const activeTab: Tab = isLegal ? tab : 'transactions'
return (
<div className={styles.inner}>
<div className={styles.glow} />
<h1 className={styles.title}>Транзакции</h1>
<TransactionsList />
{isLegal && (
<div className={styles.tabs}>
<button
type="button"
className={styles.tab}
data-active={activeTab === 'transactions' || undefined}
onClick={() => setTab('transactions')}
>
Транзакции
</button>
<button
type="button"
className={styles.tab}
data-active={activeTab === 'requests' || undefined}
onClick={() => setTab('requests')}
>
Заявки
</button>
</div>
)}
{activeTab === 'transactions' ? <TransactionsList /> : <PurchaseRequestsList />}
</div>
)
}

View File

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

View File

@@ -0,0 +1,65 @@
.status {
color: var(--text-secondary);
font-size: 14px;
position: relative;
z-index: 1;
}
.statusError {
color: var(--error, #ff4466);
font-size: 14px;
position: relative;
z-index: 1;
}
.empty {
color: var(--text-secondary);
font-size: 15px;
position: relative;
z-index: 1;
}
.tableWrap {
overflow-x: auto;
position: relative;
z-index: 1;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th {
text-align: left;
padding: 10px 16px;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
white-space: nowrap;
}
.table td {
padding: 14px 16px;
color: var(--text-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
white-space: nowrap;
}
.mono {
font-variant-numeric: tabular-nums;
}
.statusBadge {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
background: rgba(74, 109, 255, 0.12);
color: var(--interactive, #4a6dff);
font-size: 12px;
font-weight: 600;
}

View File

@@ -0,0 +1,51 @@
import { useMyPurchaseRequests } from '@features/b2b'
import styles from './PurchaseRequestsList.module.css'
function formatAmount(value: string | null, suffix: string): string {
if (!value) return '—'
return `${value} ${suffix}`
}
function formatDate(value: string | null): string {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleString('ru-RU')
}
export function PurchaseRequestsList() {
const { data, isLoading, isError } = useMyPurchaseRequests()
if (isLoading) return <p className={styles.status}>Загрузка...</p>
if (isError) return <p className={styles.statusError}>Не удалось загрузить заявки. Попробуйте обновить страницу.</p>
if (!data || data.items.length === 0) return <p className={styles.empty}>У вас пока нет заявок.</p>
return (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>USDT</th>
<th>Сумма </th>
<th>Курс</th>
<th>Статус</th>
<th>Создана</th>
</tr>
</thead>
<tbody>
{data.items.map((req) => (
<tr key={req.id}>
<td className={styles.mono}>{formatAmount(req.usdt_amount, 'USDT')}</td>
<td className={styles.mono}>{formatAmount(req.rub_amount, '₽')}</td>
<td className={styles.mono}>{req.exchange_rate ?? '—'}</td>
<td>
<span className={styles.statusBadge}>{req.status}</span>
</td>
<td>{formatDate(req.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

File diff suppressed because one or more lines are too long