This commit is contained in:
2026-05-22 22:34:00 +03:00
parent fac5e2ea5e
commit 52a0b7f3c7
14 changed files with 823 additions and 158 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

153
dist/assets/index-htxNfuya.js vendored Normal file

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" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЭКСА — Ваш мост в мир цифровых активов</title> <title>ЭКСА — Ваш мост в мир цифровых активов</title>
<script type="module" crossorigin src="/assets/index-DGfl_oNm.js"></script> <script type="module" crossorigin src="/assets/index-htxNfuya.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-fOMxenQH.css"> <link rel="stylesheet" crossorigin href="/assets/index-D6a7E682.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -15,6 +15,7 @@ import { PolitikaPage } from '@pages/politika-personalnyh-dannyh'
import { PolitikaCookiePage } from '@pages/politika-cookie' import { PolitikaCookiePage } from '@pages/politika-cookie'
import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh' import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh'
import { ReestryPage } from '@pages/reestr-pd-rkn' import { ReestryPage } from '@pages/reestr-pd-rkn'
import { TransactionsPage } from '@pages/transactions'
import { ROUTES } from '@shared/config/routes' import { ROUTES } from '@shared/config/routes'
import { ScrollToTop } from './ScrollToTop' import { ScrollToTop } from './ScrollToTop'
import { ProtectedRoute } from './ProtectedRoute' import { ProtectedRoute } from './ProtectedRoute'
@@ -47,6 +48,7 @@ export function RouterProvider() {
<Route path={ROUTES.PROFILE} element={<ProfilePage />} /> <Route path={ROUTES.PROFILE} element={<ProfilePage />} />
<Route path={ROUTES.SEED_PHRASE} element={<SeedPhrasePage />} /> <Route path={ROUTES.SEED_PHRASE} element={<SeedPhrasePage />} />
<Route path={ROUTES.KYC} element={<KycPage />} /> <Route path={ROUTES.KYC} element={<KycPage />} />
<Route path={ROUTES.TRANSACTIONS} element={<TransactionsPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -107,3 +107,61 @@ export function createOrder(payload: CreateOrderPayload): Promise<OrderResult> {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, true) }, true)
} }
export type OrderStatus = 'pending' | 'completed' | 'cancelled' | 'failed'
export interface Order {
id: string
created_at: string
updated_at: string
user_id: string
usdt_amount: string
usdt_exchange_rate: string
gas_fee: string
total_price: string
service_fee: string
status: OrderStatus
client_payment_id: string
itpay_payment_qr_url_desktop: string
itpay_payment_qr_url_android: string
itpay_payment_qr_url_ios: string
itpay_payment_qr_image_desktop: string
itpay_payment_qr_image_android: string
itpay_payment_qr_image_ios: string
itpay_id: string
itpay_qr_id: string
itpay_amount: string
itpay_created_at: string
}
export interface Payment {
id: string
created_at: string
updated_at: string
user_id: string
order_id: string
status: OrderStatus
receipt_cloudekassir_id: string
receipt_cloudekassir_link: string
itpay_payment_id: string
itpay_paid_amount: string
transaction_id: string
web3_transaction_hash: string
paid_at: string
expired_date: string
}
export interface OrderWithPayment {
order: Order
payment: Payment
}
export interface OrdersResponse {
orders: OrderWithPayment[]
}
export const ORDERS_LIMIT = 20
export function getOrders(offset: number, limit: number = ORDERS_LIMIT): Promise<OrdersResponse> {
return doPaymentRequest(`/payment/orders?offset=${offset}&limit=${limit}`, {}, true)
}

View File

@@ -0,0 +1,15 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { getOrders, ORDERS_LIMIT } from '../api/paymentApi'
export function useOrders() {
return useInfiniteQuery({
queryKey: ['payment', 'orders'],
queryFn: ({ pageParam }) => getOrders(pageParam as number),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.orders.length < ORDERS_LIMIT) return undefined
return allPages.length * ORDERS_LIMIT
},
staleTime: 30_000,
})
}

View File

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

View File

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

View File

@@ -0,0 +1,357 @@
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-deep);
}
.main {
flex: 1;
padding: 28px 32px 40px;
max-width: 1200px;
width: 100%;
margin: 0 auto;
position: relative;
}
.glow {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 320px;
background: radial-gradient(ellipse, rgba(61, 42, 142, 0.15), transparent 70%);
pointer-events: none;
z-index: 0;
}
.title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 24px;
position: relative;
z-index: 1;
}
.list {
display: flex;
flex-direction: column;
gap: 12px;
position: relative;
z-index: 1;
}
.empty {
color: var(--text-secondary);
font-size: 15px;
position: relative;
z-index: 1;
}
.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;
}
/* ── Load more ── */
.loadMore {
display: flex;
justify-content: center;
padding: 24px 0 0;
position: relative;
z-index: 1;
}
.loadMoreBtn {
display: inline-flex;
align-items: center;
height: 44px;
padding: 0 28px;
background: rgba(74, 109, 255, 0.08);
border: 1px solid rgba(74, 109, 255, 0.3);
border-radius: 12px;
color: var(--interactive, #4a6dff);
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.loadMoreBtn:hover:not(:disabled) {
background: rgba(74, 109, 255, 0.18);
border-color: var(--interactive, #4a6dff);
}
.loadMoreBtn:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Status badge ── */
.statusBadge {
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
}
.status_pending {
background: rgba(243, 186, 47, 0.15);
color: #f3ba2f;
}
.status_completed {
background: rgba(0, 196, 140, 0.15);
color: var(--success, #00c48c);
}
.status_cancelled {
background: rgba(255, 77, 77, 0.15);
color: #ff4d4d;
}
.status_failed {
background: rgba(255, 77, 77, 0.15);
color: #ff4d4d;
}
/* ── Accordion item ── */
.accordionItem {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 16px;
overflow: hidden;
}
/* ── Summary row ── */
.summaryRow {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
width: 100%;
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.summaryRow:hover {
background: rgba(255, 255, 255, 0.04);
}
.summaryLeft {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
flex-wrap: wrap;
}
.summaryDate {
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-mono);
white-space: nowrap;
}
.summaryRight {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.amount {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
}
.totalAmount {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.chevron {
color: var(--text-secondary);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.chevronOpen {
transform: rotate(180deg);
}
/* ── Accordion body — grid-rows animation ── */
.bodyOuter {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease;
}
.bodyOuterOpen {
grid-template-rows: 1fr;
}
.bodyInner {
overflow: hidden;
}
/* ── Body content ── */
.body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 32px;
padding: 4px 20px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.col {
display: flex;
flex-direction: column;
}
.colTitle {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--text-secondary);
margin: 14px 0 4px;
}
.infoRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
gap: 12px;
}
.infoRow:last-child {
border-bottom: none;
}
.infoRowTotal {
border-top: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: none;
margin-top: 4px;
}
.infoRowTotal .infoLabel,
.infoRowTotal .infoValue {
font-weight: 700;
color: var(--text-primary);
font-size: 14px;
}
.infoLabel {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
}
.infoValue {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
text-align: right;
}
.infoValueRow {
display: flex;
align-items: center;
gap: 6px;
}
.infoLink {
font-size: 13px;
color: var(--interactive, #4a6dff);
text-decoration: none;
font-family: var(--font-mono);
}
.infoLink:hover {
text-decoration: underline;
}
/* ── Copy button ── */
.copyBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--glass-border);
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.copyBtn:hover {
background: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
}
/* ── Responsive ── */
@media (max-width: 900px) {
.main {
padding: 20px 16px 32px;
}
.glow {
width: auto;
height: auto;
}
.summaryRight {
gap: 10px;
}
.totalAmount {
display: none;
}
}
@media (max-width: 640px) {
.body {
grid-template-columns: 1fr;
gap: 0;
}
.col + .col {
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.summaryDate {
display: none;
}
}

View File

@@ -0,0 +1,227 @@
import { useState } from 'react'
import { WalletHeader } from '@widgets/wallet-header'
import { Footer } from '@widgets/footer'
import { useOrders } from '@features/payment'
import type { OrderWithPayment, OrderStatus } from '@features/payment'
import styles from './TransactionsPage.module.css'
const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
pending: 'Ожидание',
completed: 'Выполнена',
cancelled: 'Отменена',
failed: 'Ошибка',
}
const PAYMENT_STATUS_LABELS: Record<OrderStatus, string> = {
pending: 'Ожидание',
completed: 'Оплачен',
cancelled: 'Отменён',
failed: 'Ошибка',
}
function formatDate(iso: string) {
if (!iso) return '—'
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function truncateHash(hash: string) {
if (!hash) return '—'
if (hash.length <= 16) return hash
return `${hash.slice(0, 8)}${hash.slice(-6)}`
}
function StatusBadge({ status, labels }: { status: OrderStatus; labels: Record<OrderStatus, string> }) {
return (
<span className={`${styles.statusBadge} ${styles[`status_${status}`]}`}>
{labels[status] ?? status}
</span>
)
}
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false)
function handleCopy() {
navigator.clipboard.writeText(value).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<button className={styles.copyBtn} onClick={handleCopy} type="button" title="Скопировать">
{copied ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
)
}
function OrderAccordion({ item }: { item: OrderWithPayment }) {
const [isOpen, setIsOpen] = useState(false)
const { order, payment } = item
return (
<div className={styles.accordionItem}>
<button
className={styles.summaryRow}
onClick={() => setIsOpen(v => !v)}
type="button"
aria-expanded={isOpen}
>
<div className={styles.summaryLeft}>
<span className={styles.summaryDate}>{formatDate(order.created_at)}</span>
<StatusBadge status={order.status} labels={ORDER_STATUS_LABELS} />
<StatusBadge status={payment.status} labels={PAYMENT_STATUS_LABELS} />
</div>
<div className={styles.summaryRight}>
<span className={styles.amount}>{order.usdt_amount} USDT</span>
<span className={styles.totalAmount}>{Number(order.total_price).toLocaleString('ru-RU')} </span>
<svg
className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`}
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</button>
<div className={`${styles.bodyOuter} ${isOpen ? styles.bodyOuterOpen : ''}`}>
<div className={styles.bodyInner}>
<div className={styles.body}>
<div className={styles.col}>
<p className={styles.colTitle}>Заказ</p>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Сумма USDT</span>
<span className={styles.infoValue}>{order.usdt_amount} USDT</span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Курс обмена</span>
<span className={styles.infoValue}>1 USDT = {order.usdt_exchange_rate} </span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Сервисный сбор</span>
<span className={styles.infoValue}>{order.service_fee} USDT</span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Комиссия за газ</span>
<span className={styles.infoValue}>{order.gas_fee} USDT</span>
</div>
<div className={`${styles.infoRow} ${styles.infoRowTotal}`}>
<span className={styles.infoLabel}>Итого к оплате</span>
<span className={styles.infoValue}>{Number(order.total_price).toLocaleString('ru-RU')} </span>
</div>
</div>
<div className={styles.col}>
<p className={styles.colTitle}>Платёж</p>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Статус</span>
<StatusBadge status={payment.status} labels={PAYMENT_STATUS_LABELS} />
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Оплачено</span>
<span className={styles.infoValue}>{payment.itpay_paid_amount || '—'}</span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Истекает</span>
<span className={styles.infoValue}>{formatDate(payment.expired_date)}</span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Хэш транзакции</span>
<span className={styles.infoValueRow}>
<span className={styles.infoValue} title={payment.web3_transaction_hash || undefined}>
{truncateHash(payment.web3_transaction_hash)}
</span>
{payment.web3_transaction_hash && (
<CopyButton value={payment.web3_transaction_hash} />
)}
</span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Чек</span>
{payment.receipt_cloudekassir_link ? (
<a
href={payment.receipt_cloudekassir_link}
target="_blank"
rel="noopener noreferrer"
className={styles.infoLink}
>
Открыть
</a>
) : (
<span className={styles.infoValue}></span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export function TransactionsPage() {
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useOrders()
const items: OrderWithPayment[] = data?.pages.flatMap(p => p.orders) ?? []
return (
<div className={styles.page}>
<WalletHeader />
<main className={styles.main}>
<div className={styles.glow} />
<h1 className={styles.title}>Транзакции</h1>
{isLoading && <p className={styles.status}>Загрузка...</p>}
{isError && <p className={styles.statusError}>Не удалось загрузить транзакции. Попробуйте обновить страницу.</p>}
{!isLoading && !isError && items.length === 0 && (
<p className={styles.empty}>У вас пока нет транзакций.</p>
)}
{items.length > 0 && (
<div className={styles.list}>
{items.map((item) => (
<OrderAccordion key={item.order.id} item={item} />
))}
</div>
)}
{hasNextPage && (
<div className={styles.loadMore}>
<button
className={styles.loadMoreBtn}
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
type="button"
>
{isFetchingNextPage ? 'Загрузка...' : 'Загрузить ещё'}
</button>
</div>
)}
</main>
<Footer />
</div>
)
}

View File

@@ -16,4 +16,5 @@ export const ROUTES = {
POLITIKA_COOKIE: '/politika-cookie', POLITIKA_COOKIE: '/politika-cookie',
SOGLASIE_PERSONALNYH_DANNYH: '/soglasie-personalnyh-dannyh', SOGLASIE_PERSONALNYH_DANNYH: '/soglasie-personalnyh-dannyh',
REESTR_PD_RKN: '/reestr-pd-rkn', REESTR_PD_RKN: '/reestr-pd-rkn',
TRANSACTIONS: '/transactions',
} as const } as const

View File

@@ -91,6 +91,9 @@ export function WalletHeader() {
<Link to={ROUTES.WALLET} className={styles.dropdownItem} onClick={() => setOpen(false)}> <Link to={ROUTES.WALLET} className={styles.dropdownItem} onClick={() => setOpen(false)}>
Кошелёк Кошелёк
</Link> </Link>
<Link to={ROUTES.TRANSACTIONS} className={styles.dropdownItem} onClick={() => setOpen(false)}>
Транзакции
</Link>
<button className={`${styles.dropdownItem} ${styles.danger}`} onClick={handleLogout}> <button className={`${styles.dropdownItem} ${styles.danger}`} onClick={handleLogout}>
Выйти Выйти
</button> </button>

File diff suppressed because one or more lines are too long