diff --git a/src/api/landings.ts b/src/api/landings.ts index 87497d4..b9bf5f1 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -305,6 +305,41 @@ export interface LandingStatsResponse { tariff_stats: LandingTariffStat[]; } +// ============================================================ +// Admin purchase list types +// ============================================================ + +export type PurchaseItemStatus = + | 'pending' + | 'paid' + | 'delivered' + | 'pending_activation' + | 'failed' + | 'expired'; + +export interface LandingPurchaseItem { + id: number; + token: string; + contact_type: 'email' | 'telegram'; + contact_value: string; + is_gift: boolean; + gift_recipient_type: 'email' | 'telegram' | null; + gift_recipient_value: string | null; + tariff_name: string; + period_days: number; + amount_kopeks: number; + currency: string; + payment_method: string; + status: PurchaseItemStatus; + created_at: string; + paid_at: string | null; +} + +export interface LandingPurchaseListResponse { + items: LandingPurchaseItem[]; + total: number; +} + // ============================================================ // Admin API // ============================================================ @@ -348,4 +383,16 @@ export const adminLandingsApi = { const response = await apiClient.get(`/cabinet/admin/landings/${id}/stats`); return response.data; }, + + getPurchases: async ( + id: number, + offset: number, + limit: number, + status?: PurchaseItemStatus, + ): Promise => { + const params: Record = { offset, limit }; + if (status) params.status = status; + const response = await apiClient.get(`/cabinet/admin/landings/${id}/purchases`, { params }); + return response.data; + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index b523618..b24bf13 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3249,6 +3249,31 @@ "funnel": "Funnel", "loadError": "Failed to load statistics", "noPurchases": "No purchases" + }, + "purchases": { + "title": "Purchases", + "contact": "Contact", + "recipient": "Recipient", + "tariff": "Tariff", + "period": "Period", + "days": "days", + "price": "Price", + "method": "Method", + "date": "Date", + "gift": "Gift", + "forSelf": "For self", + "allStatuses": "All statuses", + "status_pending": "Pending", + "status_paid": "Paid", + "status_delivered": "Delivered", + "status_pending_activation": "Pending activation", + "status_failed": "Failed", + "status_expired": "Expired", + "noPurchases": "No purchases", + "showing": "Showing {{from}}\u2013{{to}} of {{total}}", + "page": "Page {{current}} of {{total}}", + "prev": "Previous", + "next": "Next" } } }, diff --git a/src/locales/ru.json b/src/locales/ru.json index c2175bb..83426aa 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3800,6 +3800,31 @@ "funnel": "Воронка", "loadError": "Не удалось загрузить статистику", "noPurchases": "Нет покупок" + }, + "purchases": { + "title": "Покупки", + "contact": "Контакт", + "recipient": "Получатель", + "tariff": "Тариф", + "period": "Период", + "days": "дн.", + "price": "Цена", + "method": "Метод", + "date": "Дата", + "gift": "Подарок", + "forSelf": "Для себя", + "allStatuses": "Все статусы", + "status_pending": "Ожидание", + "status_paid": "Оплачен", + "status_delivered": "Доставлен", + "status_pending_activation": "Ожидает активации", + "status_failed": "Ошибка", + "status_expired": "Истёк", + "noPurchases": "Нет покупок", + "showing": "Показано {{from}}\u2013{{to}} из {{total}}", + "page": "Стр. {{current}} из {{total}}", + "prev": "Назад", + "next": "Далее" } } }, diff --git a/src/pages/AdminLandingStats.tsx b/src/pages/AdminLandingStats.tsx index a041cce..4f41625 100644 --- a/src/pages/AdminLandingStats.tsx +++ b/src/pages/AdminLandingStats.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -16,7 +16,12 @@ import { XAxis, YAxis, } from 'recharts'; -import { adminLandingsApi, resolveLocaleDisplay } from '../api/landings'; +import { + adminLandingsApi, + resolveLocaleDisplay, + type PurchaseItemStatus, + type LandingPurchaseItem, +} from '../api/landings'; import { useCurrency } from '../hooks/useCurrency'; import { useChartColors } from '../hooks/useChartColors'; import { CHART_COMMON } from '../constants/charts'; @@ -36,6 +41,177 @@ const ChartIcon = () => ( const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6']; const GIFT_COLOR = '#a855f7'; +const PURCHASE_STATUS_STYLES: Record = { + pending: 'bg-warning-500/20 text-warning-400', + paid: 'bg-accent-500/20 text-accent-400', + delivered: 'bg-success-500/20 text-success-400', + pending_activation: 'bg-accent-500/20 text-accent-400', + failed: 'bg-error-500/20 text-error-400', + expired: 'bg-dark-500/20 text-dark-400', +}; + +const PURCHASE_STATUS_OPTIONS: Array = [ + 'all', + 'pending', + 'paid', + 'delivered', + 'pending_activation', + 'failed', + 'expired', +]; + +const PURCHASES_PAGE_SIZE = 20; + +// Small icons for the purchase cards + +const EmailIcon = () => ( + + + +); + +const TelegramSmallIcon = () => ( + + + +); + +const ArrowRightIcon = () => ( + + + +); + +const GiftIcon = () => ( + + + +); + +const ChevronLeftSmall = () => ( + + + +); + +const ChevronRightSmall = () => ( + + + +); + +// Contact display helper +function ContactDisplay({ type, value }: { type: 'email' | 'telegram'; value: string }) { + return ( + + {type === 'email' ? : } + {value} + + ); +} + +// Purchase card component +interface PurchaseCardProps { + item: LandingPurchaseItem; + formatPrice: (kopeks: number) => string; + lang: string; + t: (key: string, opts?: Record) => string; +} + +function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) { + const statusStyle = PURCHASE_STATUS_STYLES[item.status] || 'bg-dark-600 text-dark-300'; + const dateStr = new Date(item.created_at).toLocaleDateString(lang, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + + return ( +
+ {/* Mobile: stacked | Desktop: horizontal */} +
+ {/* Status badge */} +
+ + {t(`admin.landings.purchases.status_${item.status}`)} + +
+ + {/* Contact info */} +
+
+ + {item.is_gift && item.gift_recipient_type && item.gift_recipient_value && ( + + + + + )} +
+
+ + {/* Tariff + period */} +
+ {item.tariff_name} + + {' '} + · {item.period_days} {t('admin.landings.purchases.days')} + +
+ + {/* Price */} +
+ {formatPrice(item.amount_kopeks)} +
+ + {/* Payment method */} +
{item.payment_method}
+ + {/* Gift badge */} + {item.is_gift && ( +
+ + + {t('admin.landings.purchases.gift')} + +
+ )} + + {/* Date */} +
{dateStr}
+
+
+ ); +} + export default function AdminLandingStats() { const { t, i18n } = useTranslation(); const { id } = useParams<{ id: string }>(); @@ -45,6 +221,12 @@ export default function AdminLandingStats() { const { formatWithCurrency } = useCurrency(); const colors = useChartColors(); + // Purchases list state + const [purchaseOffset, setPurchaseOffset] = useState(0); + const [purchaseStatusFilter, setPurchaseStatusFilter] = useState( + 'all', + ); + // Fetch stats const { data: stats, @@ -65,6 +247,31 @@ export default function AdminLandingStats() { staleTime: CHART_COMMON.STALE_TIME, }); + // Fetch purchases list + const { data: purchasesData, isLoading: purchasesLoading } = useQuery({ + queryKey: [ + 'landing-purchases', + numericId, + purchaseOffset, + PURCHASES_PAGE_SIZE, + purchaseStatusFilter, + ], + queryFn: () => + adminLandingsApi.getPurchases( + numericId, + purchaseOffset, + PURCHASES_PAGE_SIZE, + purchaseStatusFilter === 'all' ? undefined : purchaseStatusFilter, + ), + enabled: isValidId, + staleTime: CHART_COMMON.STALE_TIME, + }); + + const purchaseItems = purchasesData?.items ?? []; + const purchaseTotal = purchasesData?.total ?? 0; + const purchaseTotalPages = Math.ceil(purchaseTotal / PURCHASES_PAGE_SIZE); + const purchaseCurrentPage = Math.floor(purchaseOffset / PURCHASES_PAGE_SIZE) + 1; + // Prepare daily chart data const dailyData = useMemo(() => { if (!stats) return []; @@ -439,6 +646,101 @@ export default function AdminLandingStats() { )} + + {/* Purchases List */} +
+ {/* Header row: title + status filter */} +
+

{t('admin.landings.purchases.title')}

+ +
+ + {/* Content */} + {purchasesLoading ? ( +
+
+
+ ) : purchaseItems.length === 0 ? ( +
+ {t('admin.landings.purchases.noPurchases')} +
+ ) : ( + <> +
+ {purchaseItems.map((item) => ( + + formatWithCurrency(kopeks / CHART_COMMON.KOPEKS_DIVISOR) + } + lang={i18n.language} + t={t} + /> + ))} +
+ + {/* Pagination */} + {purchaseTotalPages > 1 && ( +
+ + {t('admin.landings.purchases.showing', { + from: purchaseOffset + 1, + to: Math.min(purchaseOffset + PURCHASES_PAGE_SIZE, purchaseTotal), + total: purchaseTotal, + })} + +
+ + + + {t('admin.landings.purchases.page', { + current: purchaseCurrentPage, + total: purchaseTotalPages, + })} + + + +
+
+ )} + + )} +
);