profile refactor

This commit is contained in:
2026-06-30 16:29:54 +03:00
parent b59de00642
commit 5ee794edf4
12 changed files with 361 additions and 152 deletions

View File

@@ -161,17 +161,61 @@ export interface Payment {
expired_date: string
}
export interface OrderWithPayment {
order: Order
export type SbpWithdrawalStatus =
| 'created'
| 'waiting_usdt'
| 'usdt_underpaid'
| 'usdt_received'
| 'wallet_error'
| 'payout_processing'
| 'payout_completed'
| 'payout_failed'
| 'cancelled'
| 'expired'
export interface SbpWithdrawalOperation {
id: string | null
created_at: string | null
updated_at: string | null
user_id: string | null
bank_id: string | null
bank_name: string | null
phone: string | null
usdt_amount: string | null
rub_amount: string | null
usdt_exchange_rate: string | null
service_fee_rate: string | null
service_fee_usdt: string | null
mozen_order_id: string | null
trace_id: string | null
status: SbpWithdrawalStatus | null
provider_error: string | null
usdt_received_at: string | null
payout_created_at: string | null
payout_completed_at: string | null
}
export type ClientOperationType = 'payment' | 'withdrawal'
export interface ClientOperation {
type: ClientOperationType | string
created_at: string
payment: Payment | null
withdrawal: SbpWithdrawalOperation | null
}
export interface OrdersResponse {
orders: OrderWithPayment[]
export interface ClientOperationsResponse {
status_code: number
operations: ClientOperation[]
limit: number
offset: number
}
export const ORDERS_LIMIT = 20
export const OPERATIONS_LIMIT = 20
export function getOrders(offset: number, limit: number = ORDERS_LIMIT): Promise<OrdersResponse> {
return doPaymentRequest(`/payment/orders?offset=${offset}&limit=${limit}`, {}, true)
export function getClientOperations(
offset: number,
limit: number = OPERATIONS_LIMIT,
): Promise<ClientOperationsResponse> {
return doPaymentRequest(`/payment/payments?offset=${offset}&limit=${limit}`, {}, true)
}

View File

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

View File

@@ -1,15 +0,0 @@
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,6 +2,20 @@ export { usePaymentConfig } from './hooks/usePaymentConfig'
export { usePaymentQuote } from './hooks/usePaymentQuote'
export { usePaymentQuoteByRub } from './hooks/usePaymentQuoteByRub'
export { useCreateOrder } from './hooks/useCreateOrder'
export { useOrders } from './hooks/useOrders'
export { useOperations } from './hooks/useOperations'
export { useCurrencyConversion } from './model/useCurrencyConversion'
export type { PaymentConfig, PaymentQuote, CreateOrderPayload, OrderResult, Order, Payment, OrderWithPayment, OrderStatus, PaymentStatus } from './api/paymentApi'
export type {
PaymentConfig,
PaymentQuote,
CreateOrderPayload,
OrderResult,
Order,
Payment,
OrderStatus,
PaymentStatus,
ClientOperation,
ClientOperationsResponse,
ClientOperationType,
SbpWithdrawalOperation,
SbpWithdrawalStatus,
} from './api/paymentApi'

View File

@@ -0,0 +1,14 @@
import type { SbpWithdrawalStatus } from '@features/payment'
export const SBP_WITHDRAWAL_STATUS_LABELS: Record<SbpWithdrawalStatus, string> = {
created: 'Создан',
waiting_usdt: 'Ожидание USDT',
usdt_underpaid: 'USDT недоплачен',
usdt_received: 'USDT получен',
wallet_error: 'Ошибка кошелька',
payout_processing: 'Выплата в обработке',
payout_completed: 'Выплата завершена',
payout_failed: 'Выплата не прошла',
cancelled: 'Отменён',
expired: 'Истёк',
}

View File

@@ -31,6 +31,18 @@
flex-wrap: wrap;
}
.opType {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 2px 8px;
white-space: nowrap;
}
.summaryDate {
font-size: 13px;
color: var(--text-secondary);

View File

@@ -0,0 +1,187 @@
import { useState } from 'react'
import type { ClientOperation, Payment, SbpWithdrawalOperation } from '@features/payment'
import { formatDate, formatRub, truncateHash } from '../model/format'
import { StatusBadge } from './StatusBadge'
import { WithdrawalStatusBadge } from './WithdrawalStatusBadge'
import { CopyButton } from './CopyButton'
import styles from './OperationAccordion.module.css'
interface Props {
op: ClientOperation
}
export function OperationAccordion({ op }: Props) {
const [isOpen, setIsOpen] = useState(false)
// type — основной признак, но опираемся и на наличие данных (защита от пустых полей).
const isWithdrawal = op.type === 'withdrawal' ? !!op.withdrawal : !op.payment && !!op.withdrawal
if (isWithdrawal && op.withdrawal) {
return <Accordion isOpen={isOpen} onToggle={() => setIsOpen((v) => !v)} summary={withdrawalSummary(op.withdrawal)}>
{withdrawalBody(op.withdrawal)}
</Accordion>
}
if (op.payment) {
return <Accordion isOpen={isOpen} onToggle={() => setIsOpen((v) => !v)} summary={paymentSummary(op.payment)}>
{paymentBody(op.payment)}
</Accordion>
}
return null
}
// --- Каркас аккордеона (общий для обоих типов операций) ---
interface AccordionProps {
isOpen: boolean
onToggle: () => void
summary: React.ReactNode
children: React.ReactNode
}
function Accordion({ isOpen, onToggle, summary, children }: AccordionProps) {
return (
<div className={styles.item}>
<button className={styles.summary} onClick={onToggle} type="button" aria-expanded={isOpen}>
{summary}
<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>
</button>
<div className={`${styles.bodyOuter} ${isOpen ? styles.bodyOuterOpen : ''}`}>
<div className={styles.bodyInner}>
<div className={styles.body}>{children}</div>
</div>
</div>
</div>
)
}
// --- Платёж ---
function paymentSummary(payment: Payment) {
return (
<>
<span className={styles.summaryLeft}>
<span className={styles.opType}>Платёж</span>
<span className={styles.summaryDate}>{formatDate(payment.created_at)}</span>
{payment.status && <StatusBadge status={payment.status} />}
</span>
<span className={styles.summaryRight}>
{payment.itpay_paid_amount && (
<span className={styles.totalAmount}>{formatRub(payment.itpay_paid_amount)} </span>
)}
</span>
</>
)
}
function paymentBody(payment: Payment) {
return (
<>
<div className={styles.col}>
<p className={styles.colTitle}>Платёж</p>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Статус</span>
{payment.status ? <StatusBadge status={payment.status} /> : <span className={styles.infoValue}></span>}
</div>
<InfoRow label="Оплачено" value={payment.itpay_paid_amount || '—'} />
<InfoRow label="Истекает" value={formatDate(payment.expired_date ?? '')} />
</div>
<div className={styles.col}>
<p className={styles.colTitle}>Транзакция</p>
<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>
</>
)
}
// --- Вывод (СБП) ---
function withdrawalSummary(w: SbpWithdrawalOperation) {
return (
<>
<span className={styles.summaryLeft}>
<span className={styles.opType}>Вывод СБП</span>
<span className={styles.summaryDate}>{formatDate(w.created_at ?? '')}</span>
{w.status && <WithdrawalStatusBadge status={w.status} />}
</span>
<span className={styles.summaryRight}>
{w.usdt_amount && <span className={styles.amount}>{w.usdt_amount} USDT</span>}
{w.rub_amount && <span className={styles.totalAmount}>{formatRub(w.rub_amount)} </span>}
</span>
</>
)
}
function withdrawalBody(w: SbpWithdrawalOperation) {
return (
<>
<div className={styles.col}>
<p className={styles.colTitle}>Вывод (СБП)</p>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Статус</span>
{w.status ? <WithdrawalStatusBadge status={w.status} /> : <span className={styles.infoValue}></span>}
</div>
<InfoRow label="Банк" value={w.bank_name || '—'} />
<InfoRow label="Телефон" value={w.phone || '—'} />
<InfoRow label="Создан" value={formatDate(w.payout_created_at ?? w.created_at ?? '')} />
<InfoRow label="Завершён" value={formatDate(w.payout_completed_at ?? '')} />
{w.provider_error && <InfoRow label="Ошибка" value={w.provider_error} />}
</div>
<div className={styles.col}>
<p className={styles.colTitle}>Суммы</p>
<InfoRow label="Сумма USDT" value={w.usdt_amount ? `${w.usdt_amount} USDT` : '—'} />
<InfoRow label="Курс обмена" value={w.usdt_exchange_rate ? `1 USDT = ${w.usdt_exchange_rate}` : '—'} />
<InfoRow label="Сервисный сбор" value={w.service_fee_usdt ? `${w.service_fee_usdt} USDT` : '—'} />
<InfoRow label="Сумма к выводу" value={w.rub_amount ? `${formatRub(w.rub_amount)}` : '—'} emphasised />
</div>
</>
)
}
function InfoRow({ label, value, emphasised }: { label: string; value: string; emphasised?: boolean }) {
return (
<div className={`${styles.infoRow} ${emphasised ? styles.infoRowTotal : ''}`}>
<span className={styles.infoLabel}>{label}</span>
<span className={styles.infoValue}>{value}</span>
</div>
)
}

View File

@@ -1,120 +0,0 @@
import { useState } from 'react'
import type { OrderWithPayment } from '@features/payment'
import { formatDate, formatRub, truncateHash } from '../model/format'
import { StatusBadge } from './StatusBadge'
import { CopyButton } from './CopyButton'
import styles from './OrderAccordion.module.css'
interface Props {
item: OrderWithPayment
}
export function OrderAccordion({ item }: Props) {
const [isOpen, setIsOpen] = useState(false)
const { order, payment } = item
return (
<div className={styles.item}>
<button
className={styles.summary}
onClick={() => setIsOpen(v => !v)}
type="button"
aria-expanded={isOpen}
>
<div className={styles.summaryLeft}>
<span className={styles.summaryDate}>{formatDate(order.created_at)}</span>
{payment && <StatusBadge status={payment.status} />}
</div>
<div className={styles.summaryRight}>
<span className={styles.amount}>{order.usdt_amount} USDT</span>
<span className={styles.totalAmount}>{formatRub(order.total_price)} </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>
<InfoRow label="Сумма USDT" value={`${order.usdt_amount} USDT`} />
<InfoRow label="Курс обмена" value={`1 USDT = ${order.usdt_exchange_rate}`} />
<InfoRow label="Сервисный сбор" value={`${order.service_fee} USDT`} />
<InfoRow label="Комиссия за газ" value={`${order.gas_fee} USDT`} />
<InfoRow
label="Итого к оплате"
value={`${formatRub(order.total_price)}`}
emphasised
/>
</div>
<div className={styles.col}>
<p className={styles.colTitle}>Платёж</p>
{payment ? (
<>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Статус</span>
<StatusBadge status={payment.status} />
</div>
<InfoRow label="Оплачено" value={payment.itpay_paid_amount || '—'} />
<InfoRow label="Истекает" value={formatDate(payment.expired_date)} />
<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 className={styles.infoRow}>
<span className={styles.infoValue}></span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
function InfoRow({ label, value, emphasised }: { label: string; value: string; emphasised?: boolean }) {
return (
<div className={`${styles.infoRow} ${emphasised ? styles.infoRowTotal : ''}`}>
<span className={styles.infoLabel}>{label}</span>
<span className={styles.infoValue}>{value}</span>
</div>
)
}

View File

@@ -1,22 +1,22 @@
import { useOrders } from '@features/payment'
import { useOperations } from '@features/payment'
import { Spinner } from '@shared/ui'
import { OrderAccordion } from './OrderAccordion'
import { OperationAccordion } from './OperationAccordion'
import styles from './TransactionsList.module.css'
export function TransactionsList() {
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useOrders()
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useOperations()
const items = data?.pages.flatMap(p => p.orders) ?? []
const items = data?.pages.flatMap(p => p.operations) ?? []
if (isLoading) return <Spinner fullscreen label="Загрузка" />
if (isError) return <p className={styles.statusError}>Не удалось загрузить транзакции. Попробуйте обновить страницу.</p>
if (items.length === 0) return <p className={styles.empty}>У вас пока нет транзакций.</p>
if (isError) return <p className={styles.statusError}>Не удалось загрузить операции. Попробуйте обновить страницу.</p>
if (items.length === 0) return <p className={styles.empty}>У вас пока нет операций.</p>
return (
<>
<div className={styles.list}>
{items.map(item => (
<OrderAccordion key={item.order.id} item={item} />
{items.map((op, i) => (
<OperationAccordion key={op.payment?.id ?? op.withdrawal?.id ?? i} op={op} />
))}
</div>

View File

@@ -0,0 +1,42 @@
.badge {
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
}
/* Успех */
.status_usdt_received,
.status_payout_completed {
background: rgba(0, 196, 140, 0.15);
color: var(--success, #00c48c);
}
/* В процессе / ожидание */
.status_created {
background: rgba(74, 109, 255, 0.15);
color: #4a6dff;
}
.status_waiting_usdt,
.status_payout_processing {
background: rgba(155, 109, 255, 0.15);
color: #9b6dff;
}
/* Ошибки */
.status_usdt_underpaid,
.status_wallet_error,
.status_payout_failed,
.status_expired {
background: rgba(255, 77, 77, 0.15);
color: #ff4d4d;
}
/* Нейтральное */
.status_cancelled {
background: rgba(255, 255, 255, 0.08);
color: var(--text-secondary);
}

View File

@@ -0,0 +1,15 @@
import type { SbpWithdrawalStatus } from '@features/payment'
import { SBP_WITHDRAWAL_STATUS_LABELS } from '../model/sbpWithdrawalStatusLabels'
import styles from './WithdrawalStatusBadge.module.css'
interface Props {
status: SbpWithdrawalStatus
}
export function WithdrawalStatusBadge({ status }: Props) {
return (
<span className={`${styles.badge} ${styles[`status_${status}`] ?? ''}`}>
{SBP_WITHDRAWAL_STATUS_LABELS[status] ?? status}
</span>
)
}