Merge branch 'BEDOLAGA-DEV:main' into dev_nikita

This commit is contained in:
FireWookie
2026-03-10 15:08:17 +05:00
committed by GitHub
77 changed files with 12471 additions and 1380 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -84,6 +84,7 @@ const RESOURCE_TYPES = [
'users',
'tickets',
'stats',
'sales_stats',
'broadcasts',
'tariffs',
'promocodes',
@@ -106,6 +107,7 @@ const RESOURCE_TYPES = [
'apps',
'email_templates',
'pinned_messages',
'landings',
'updates',
] as const;
@@ -116,6 +118,7 @@ const PAGE_SIZE_OPTIONS = [20, 50, 100] as const;
const AUTO_REFRESH_INTERVAL = 30_000;
interface FiltersState {
userId: string;
action: string;
resource: string;
status: string;
@@ -124,6 +127,7 @@ interface FiltersState {
}
const INITIAL_FILTERS: FiltersState = {
userId: '',
action: '',
resource: '',
status: '',
@@ -431,9 +435,6 @@ export default function AdminAuditLog() {
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
// Auto-refresh interval ref
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Build query params
const queryParams = useMemo((): AuditLogFilters => {
const params: AuditLogFilters = {
@@ -441,6 +442,10 @@ export default function AdminAuditLog() {
offset: page * pageSize,
};
const parsedUserId = parseInt(appliedFilters.userId, 10);
if (!isNaN(parsedUserId) && parsedUserId > 0) {
params.user_id = parsedUserId;
}
if (appliedFilters.action.trim()) {
params.action = appliedFilters.action.trim();
}
@@ -467,21 +472,12 @@ export default function AdminAuditLog() {
refetchInterval: autoRefresh ? AUTO_REFRESH_INTERVAL : false,
});
// Auto-refresh visual indicator
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(() => {
// The visual indicator updates are driven by isFetching from react-query
}, AUTO_REFRESH_INTERVAL);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [autoRefresh]);
// RBAC users for filter chips
const { data: rbacUsers } = useQuery({
queryKey: ['rbac-users'],
queryFn: () => rbacApi.getRbacUsers(),
staleTime: 5 * 60 * 1000,
});
const entries = data?.items ?? [];
const total = data?.total ?? 0;
@@ -517,8 +513,11 @@ export default function AdminAuditLog() {
setExporting(true);
try {
const exportParams: AuditLogFilters = {};
const exportUserId = parseInt(appliedFilters.userId, 10);
if (!isNaN(exportUserId) && exportUserId > 0) exportParams.user_id = exportUserId;
if (appliedFilters.action.trim()) exportParams.action = appliedFilters.action.trim();
if (appliedFilters.resource) exportParams.resource_type = appliedFilters.resource;
if (appliedFilters.status) exportParams.status = appliedFilters.status;
if (appliedFilters.dateFrom) exportParams.date_from = appliedFilters.dateFrom;
if (appliedFilters.dateTo) exportParams.date_to = appliedFilters.dateTo;
@@ -548,6 +547,7 @@ export default function AdminAuditLog() {
const hasActiveFilters = useMemo(() => {
return (
appliedFilters.userId.trim() !== '' ||
appliedFilters.action.trim() !== '' ||
appliedFilters.resource !== '' ||
appliedFilters.status !== '' ||
@@ -649,6 +649,51 @@ export default function AdminAuditLog() {
{filtersOpen && (
<div className="border-t border-dark-700 p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* User filter */}
{rbacUsers && rbacUsers.length > 0 && (
<div className="sm:col-span-2 lg:col-span-3">
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.auditLog.filters.user')}
</label>
<div className="flex flex-wrap gap-2">
{rbacUsers.map((ru) => {
const isSelected = filters.userId === String(ru.user_id);
const displayName =
ru.first_name || ru.email || ru.username || `#${ru.user_id}`;
return (
<button
key={ru.user_id}
type="button"
aria-pressed={isSelected}
onClick={() =>
setFilters((prev) => ({
...prev,
userId: isSelected ? '' : String(ru.user_id),
}))
}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-1 focus-visible:ring-offset-dark-900 ${
isSelected
? 'border-accent-500 bg-accent-500/20 text-accent-300'
: 'border-dark-600 bg-dark-900 text-dark-300 hover:border-dark-500 hover:text-dark-200'
}`}
>
<span
className={`flex h-4 w-4 items-center justify-center rounded border text-xs ${
isSelected
? 'border-accent-500 bg-accent-500 text-white'
: 'border-dark-500 bg-dark-800'
}`}
>
{isSelected && '✓'}
</span>
<span>{displayName}</span>
</button>
);
})}
</div>
</div>
)}
{/* Action search */}
<div>
<label

View File

@@ -1,12 +1,14 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns';
import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons';
import { usePlatform } from '../platform/hooks/usePlatform';
const PAGE_SIZE = 50;
// Bonus type labels and colors
const bonusTypeConfig: Record<
CampaignBonusType,
@@ -69,9 +71,20 @@ export default function AdminCampaigns() {
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
// Queries
const { data: campaignsData, isLoading } = useQuery({
const {
data: campaignsData,
isLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['admin-campaigns'],
queryFn: () => campaignsApi.getCampaigns(true),
queryFn: ({ pageParam = 0 }) => campaignsApi.getCampaigns(true, pageParam, PAGE_SIZE),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((sum, p) => sum + p.campaigns.length, 0);
return loaded < lastPage.total ? loaded : undefined;
},
});
const { data: overview } = useQuery({
@@ -96,7 +109,7 @@ export default function AdminCampaigns() {
},
});
const campaigns = campaignsData?.campaigns || [];
const campaigns = campaignsData?.pages.flatMap((p) => p.campaigns) ?? [];
return (
<div className="animate-fade-in">
@@ -261,6 +274,21 @@ export default function AdminCampaigns() {
</div>
</div>
))}
{/* Load more */}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dark-700 bg-dark-800 py-3 text-sm font-medium text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:opacity-50"
>
{isFetchingNextPage ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-dark-500 border-t-accent-500" />
) : (
t('admin.campaigns.loadMore', 'Load more')
)}
</button>
)}
</div>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,747 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
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';
import { AdminBackButton } from '../components/admin';
// Icons
const ChartIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
);
const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6'];
const GIFT_COLOR = '#a855f7';
const PURCHASE_STATUS_STYLES: Record<string, string> = {
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<PurchaseItemStatus | 'all'> = [
'all',
'pending',
'paid',
'delivered',
'pending_activation',
'failed',
'expired',
];
const PURCHASES_PAGE_SIZE = 20;
// Small icons for the purchase cards
const EmailIcon = () => (
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
);
const TelegramSmallIcon = () => (
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
const ArrowRightIcon = () => (
<svg
className="h-3 w-3 shrink-0 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
);
const GiftIcon = () => (
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
);
const ChevronLeftSmall = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const ChevronRightSmall = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
// Contact display helper
function ContactDisplay({ type, value }: { type: 'email' | 'telegram'; value: string }) {
return (
<span className="flex items-center gap-1 text-dark-300">
{type === 'email' ? <EmailIcon /> : <TelegramSmallIcon />}
<span className="min-w-0 truncate text-xs">{value}</span>
</span>
);
}
// Purchase card component
interface PurchaseCardProps {
item: LandingPurchaseItem;
formatPrice: (kopeks: number) => string;
lang: string;
t: (key: string, opts?: Record<string, unknown>) => 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 (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/40 p-3 transition-colors hover:border-dark-600 sm:p-4">
{/* Mobile: stacked | Desktop: horizontal */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
{/* Status badge */}
<div className="shrink-0">
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${statusStyle}`}
>
{t(`admin.landings.purchases.status_${item.status}`)}
</span>
</div>
{/* Contact info */}
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
<ContactDisplay type={item.contact_type} value={item.contact_value} />
{item.is_gift && item.gift_recipient_type && item.gift_recipient_value && (
<span className="flex items-center gap-1">
<ArrowRightIcon />
<ContactDisplay type={item.gift_recipient_type} value={item.gift_recipient_value} />
</span>
)}
</div>
</div>
{/* Tariff + period */}
<div className="shrink-0 text-sm text-dark-200">
<span className="font-medium">{item.tariff_name}</span>
<span className="text-dark-500">
{' '}
&middot; {item.period_days} {t('admin.landings.purchases.days')}
</span>
</div>
{/* Price */}
<div className="shrink-0 text-sm font-medium text-dark-100">
{formatPrice(item.amount_kopeks)}
</div>
{/* Payment method */}
<div className="shrink-0 text-xs text-dark-500">{item.payment_method}</div>
{/* Gift badge */}
{item.is_gift && (
<div className="shrink-0">
<span className="inline-flex items-center gap-1 rounded-md bg-purple-500/20 px-1.5 py-0.5 text-xs text-purple-400">
<GiftIcon />
{t('admin.landings.purchases.gift')}
</span>
</div>
)}
{/* Date */}
<div className="shrink-0 text-xs text-dark-500">{dateStr}</div>
</div>
</div>
);
}
export default function AdminLandingStats() {
const { t, i18n } = useTranslation();
const { id } = useParams<{ id: string }>();
const numericId = id ? Number(id) : NaN;
const isValidId = !isNaN(numericId);
const navigate = useNavigate();
const { formatWithCurrency } = useCurrency();
const colors = useChartColors();
// Purchases list state
const [purchaseOffset, setPurchaseOffset] = useState(0);
const [purchaseStatusFilter, setPurchaseStatusFilter] = useState<PurchaseItemStatus | 'all'>(
'all',
);
// Fetch stats
const {
data: stats,
isLoading,
error,
} = useQuery({
queryKey: ['landing-stats', numericId],
queryFn: () => adminLandingsApi.getStats(numericId),
enabled: isValidId,
staleTime: CHART_COMMON.STALE_TIME,
});
// Fetch landing detail for header
const { data: landing } = useQuery({
queryKey: ['admin-landing', numericId],
queryFn: () => adminLandingsApi.get(numericId),
enabled: isValidId,
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 [];
return stats.daily_stats.map((item) => ({
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
month: 'short',
day: 'numeric',
}),
purchases: item.purchases,
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
gifts: item.gifts,
}));
}, [stats, i18n.language]);
// Prepare tariff chart data
const tariffData = useMemo(() => {
if (!stats) return [];
return stats.tariff_stats.map((item) => ({
name: item.tariff_name,
purchases: item.purchases,
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
}));
}, [stats]);
// Donut data for gift vs regular
const donutData = useMemo(() => {
if (!stats) return [];
return [
{
name: t('admin.landings.stats.regular'),
value: stats.total_regular,
color: colors.referrals,
},
{ name: t('admin.landings.stats.gifts'), value: stats.total_gifts, color: GIFT_COLOR },
];
}, [stats, t, colors.referrals]);
// Bar chart height based on tariff count
const barChartHeight = useMemo(() => {
const count = tariffData.length;
return Math.max(220, count * 45 + 40);
}, [tariffData.length]);
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
// Error state
if (error || !stats) {
return (
<div className="animate-fade-in">
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to="/admin/landings" />
<h1 className="text-xl font-semibold text-dark-100">{t('admin.landings.stats.title')}</h1>
</div>
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-6 text-center">
<p className="text-error-400">{t('admin.landings.stats.loadError')}</p>
<button
onClick={() => navigate('/admin/landings')}
className="mt-4 text-sm text-dark-400 hover:text-dark-200"
>
{t('common.back')}
</button>
</div>
</div>
);
}
const landingTitle = landing ? resolveLocaleDisplay(landing.title) : `#${numericId}`;
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<AdminBackButton to="/admin/landings" />
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<ChartIcon />
</div>
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold text-dark-100">{landingTitle}</h1>
<div className="mt-1 flex items-center gap-2">
{landing?.is_active ? (
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.landings.active')}
</span>
) : (
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.landings.inactive')}
</span>
)}
</div>
</div>
</div>
</div>
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-xl font-bold text-accent-400 sm:text-2xl">
{stats.total_purchases}
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.totalPurchases')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="truncate text-xl font-bold text-success-400 sm:text-2xl">
{formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.revenue')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-xl font-bold text-purple-400 sm:text-2xl">{stats.total_gifts}</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.giftPurchases')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-xl font-bold text-warning-400 sm:text-2xl">
{stats.conversion_rate}%
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.conversionRate')}</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Daily Purchases & Revenue */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.dailyChart')}
</h3>
{dailyData.length === 0 ? (
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')}
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={dailyData} margin={CHART_COMMON.CHART.MARGIN}>
<defs>
<linearGradient
id={`landingPurchaseGrad-${numericId}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset={CHART_COMMON.GRADIENT.START_OFFSET}
stopColor={colors.referrals}
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
/>
<stop
offset={CHART_COMMON.GRADIENT.END_OFFSET}
stopColor={colors.referrals}
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
<linearGradient
id={`landingRevenueGrad-${numericId}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset={CHART_COMMON.GRADIENT.START_OFFSET}
stopColor={colors.earnings}
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
/>
<stop
offset={CHART_COMMON.GRADIENT.END_OFFSET}
stopColor={colors.earnings}
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
dataKey="label"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="left"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
allowDecimals={false}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
}}
labelStyle={{ color: colors.label }}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="purchases"
name={t('admin.landings.stats.purchases')}
stroke={colors.referrals}
fill={`url(#landingPurchaseGrad-${numericId})`}
strokeWidth={CHART_COMMON.STROKE_WIDTH}
/>
<Area
yAxisId="right"
type="monotone"
dataKey="revenue"
name={t('admin.landings.stats.revenueLabel')}
stroke={colors.earnings}
fill={`url(#landingRevenueGrad-${numericId})`}
strokeWidth={CHART_COMMON.STROKE_WIDTH}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
{/* Tariff Distribution */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.tariffChart')}
</h3>
{tariffData.length === 0 ? (
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')}
</div>
) : (
<ResponsiveContainer width="100%" height={barChartHeight}>
<BarChart
data={tariffData}
layout="vertical"
margin={{ ...CHART_COMMON.CHART.MARGIN, left: 10 }}
>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
type="number"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
}}
labelStyle={{ color: colors.label }}
formatter={(value: number | undefined) => {
return [value ?? 0, t('admin.landings.stats.purchases')];
}}
/>
<Bar
dataKey="purchases"
name={t('admin.landings.stats.purchases')}
radius={[0, 4, 4, 0]}
>
{tariffData.map((_, index) => (
<Cell key={index} fill={TARIFF_PALETTE[index % TARIFF_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Additional Stats Row */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="mb-1 text-sm text-dark-400">
{t('admin.landings.stats.avgPurchase')}
</div>
<div className="text-lg font-medium text-dark-200">
{formatWithCurrency(stats.avg_purchase_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="mb-1 text-sm text-dark-400">
{t('admin.landings.stats.regularPurchases')}
</div>
<div className="text-lg font-medium text-dark-200">{stats.total_regular}</div>
</div>
<div className="col-span-2 rounded-xl border border-dark-700 bg-dark-800 p-4 sm:col-span-1">
<div className="mb-1 text-sm text-dark-400">{t('admin.landings.stats.funnel')}</div>
<div className="text-lg font-medium text-dark-200">
{stats.total_created}{' '}
<span className="text-sm text-dark-500">{t('admin.landings.stats.created')}</span>
{' / '}
{stats.total_successful}{' '}
<span className="text-sm text-dark-500">{t('admin.landings.stats.successful')}</span>
</div>
</div>
</div>
{/* Gift vs Regular Donut */}
{stats.total_purchases > 0 && (
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.giftBreakdown')}
</h3>
<div className="flex items-center justify-center gap-8">
<div className="relative">
<ResponsiveContainer width={160} height={160}>
<PieChart>
<Pie
data={donutData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={70}
dataKey="value"
strokeWidth={0}
>
{donutData.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
}}
/>
</PieChart>
</ResponsiveContainer>
{/* Center text */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-bold text-dark-100">{stats.total_purchases}</span>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: colors.referrals }}
/>
<span className="text-sm text-dark-300">
{t('admin.landings.stats.regular')}: {stats.total_regular}
</span>
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: GIFT_COLOR }} />
<span className="text-sm text-dark-300">
{t('admin.landings.stats.gifts')}: {stats.total_gifts}
</span>
</div>
</div>
</div>
</div>
)}
{/* Purchases List */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
{/* Header row: title + status filter */}
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 className="font-medium text-dark-200">{t('admin.landings.purchases.title')}</h3>
<select
value={purchaseStatusFilter}
onChange={(e) => {
setPurchaseStatusFilter(e.target.value as PurchaseItemStatus | 'all');
setPurchaseOffset(0);
}}
className="rounded-lg border border-dark-600 bg-dark-900 px-3 py-1.5 text-sm text-dark-200 outline-none transition-colors focus:border-accent-500"
aria-label={t('admin.landings.purchases.allStatuses')}
>
{PURCHASE_STATUS_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt === 'all'
? t('admin.landings.purchases.allStatuses')
: t(`admin.landings.purchases.status_${opt}`)}
</option>
))}
</select>
</div>
{/* Content */}
{purchasesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : purchaseItems.length === 0 ? (
<div className="py-8 text-center text-sm text-dark-500">
{t('admin.landings.purchases.noPurchases')}
</div>
) : (
<>
<div className="space-y-2">
{purchaseItems.map((item) => (
<PurchaseCard
key={item.id}
item={item}
formatPrice={(kopeks) =>
formatWithCurrency(kopeks / CHART_COMMON.KOPEKS_DIVISOR)
}
lang={i18n.language}
t={t}
/>
))}
</div>
{/* Pagination */}
{purchaseTotalPages > 1 && (
<div className="mt-4 flex flex-col items-center gap-2 sm:flex-row sm:justify-between">
<span className="text-xs text-dark-500">
{t('admin.landings.purchases.showing', {
from: purchaseOffset + 1,
to: Math.min(purchaseOffset + PURCHASES_PAGE_SIZE, purchaseTotal),
total: purchaseTotal,
})}
</span>
<div className="flex items-center gap-2">
<button
onClick={() =>
setPurchaseOffset((prev) => Math.max(0, prev - PURCHASES_PAGE_SIZE))
}
disabled={purchaseOffset === 0}
className="flex items-center gap-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.landings.purchases.prev')}
>
<ChevronLeftSmall />
<span className="hidden sm:inline">{t('admin.landings.purchases.prev')}</span>
</button>
<span className="px-2 text-xs text-dark-400">
{t('admin.landings.purchases.page', {
current: purchaseCurrentPage,
total: purchaseTotalPages,
})}
</span>
<button
onClick={() => setPurchaseOffset((prev) => prev + PURCHASES_PAGE_SIZE)}
disabled={purchaseOffset + PURCHASES_PAGE_SIZE >= purchaseTotal}
className="flex items-center gap-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.landings.purchases.next')}
>
<span className="hidden sm:inline">{t('admin.landings.purchases.next')}</span>
<ChevronRightSmall />
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}

514
src/pages/AdminLandings.tsx Normal file
View File

@@ -0,0 +1,514 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminLandingsApi, LandingListItem, resolveLocaleDisplay } from '../api/landings';
import { useNotify } from '@/platform';
import { copyToClipboard } from '../utils/clipboard';
import { getApiErrorMessage } from '../utils/api-error';
import { usePlatform } from '../platform/hooks/usePlatform';
import { cn } from '../lib/utils';
import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons';
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// Icons (non-shared, page-specific)
const EditIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const XIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const GiftIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
);
const SaveIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
);
const StatsChartIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
);
// ============ Sortable Landing Card ============
interface SortableLandingCardProps {
landing: LandingListItem;
onEdit: () => void;
onStats: () => void;
onDelete: () => void;
onToggle: () => void;
onCopyUrl: () => void;
isPendingDelete?: boolean;
}
function SortableLandingCard({
landing,
onEdit,
onStats,
onDelete,
onToggle,
onCopyUrl,
isPendingDelete,
}: SortableLandingCardProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: landing.id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-xl border bg-dark-800 p-3 transition-colors sm:p-4',
isDragging
? 'border-accent-500/50 shadow-xl shadow-accent-500/20'
: landing.is_active
? 'border-dark-700'
: 'border-dark-700/50 opacity-60',
)}
>
<div className="flex items-start gap-2 sm:gap-3">
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="mt-0.5 flex-shrink-0 cursor-grab touch-none rounded-lg p-2 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing sm:mt-1 sm:p-1.5"
title={t('admin.tariffs.dragToReorder')}
>
<GripIcon />
</button>
{/* Content + Actions wrapper */}
<div className="min-w-0 flex-1">
{/* Top row: title/slug + actions (desktop) */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
<h3 className="truncate font-medium text-dark-100">
{resolveLocaleDisplay(landing.title)}
</h3>
<span className="shrink-0 rounded bg-dark-800 px-2 py-0.5 text-xs text-dark-400">
{landing.slug}
</span>
{landing.is_active ? (
<span className="shrink-0 rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.landings.active')}
</span>
) : (
<span className="shrink-0 rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.landings.inactive')}
</span>
)}
{landing.gift_enabled && (
<span className="shrink-0 rounded bg-accent-500/20 px-1.5 py-0.5 text-xs text-accent-400">
<GiftIcon />
</span>
)}
{landing.has_active_discount && (
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-[10px] font-medium text-accent-400">
{t('admin.landings.discountActive', 'Discount')}
</span>
)}
</div>
<div className="text-sm text-dark-400">
<span>
{landing.purchase_stats.total} {t('admin.landings.purchaseCount')}
</span>
</div>
</div>
{/* Actions: hidden on mobile, shown on desktop */}
<div className="hidden shrink-0 items-center gap-1.5 sm:flex">
<button
onClick={onCopyUrl}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.copyUrl')}
>
<CopyIcon />
</button>
<button
onClick={onStats}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.statistics')}
>
<StatsChartIcon />
</button>
<button
onClick={onToggle}
className={cn(
'rounded-lg p-2 transition-colors',
landing.is_active
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600',
)}
title={
landing.is_active ? t('admin.landings.inactive') : t('admin.landings.active')
}
>
{landing.is_active ? <CheckIcon /> : <XIcon />}
</button>
<button
onClick={onEdit}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.edit')}
>
<EditIcon />
</button>
<button
onClick={onDelete}
className={cn(
'rounded-lg p-2 transition-colors',
isPendingDelete
? 'bg-error-500/20 text-error-400 ring-1 ring-error-500/30'
: 'bg-dark-700 text-dark-300 hover:bg-error-500/20 hover:text-error-400',
)}
title={
isPendingDelete
? t('admin.landings.deleteConfirm', {
title: resolveLocaleDisplay(landing.title),
})
: t('common.delete')
}
>
{isPendingDelete ? (
<span className="px-1 text-xs font-medium">{t('common.delete')}?</span>
) : (
<TrashIcon />
)}
</button>
</div>
</div>
{/* Actions: shown on mobile only */}
<div className="mt-2 flex items-center gap-1.5 sm:hidden">
<button
onClick={onCopyUrl}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.copyUrl')}
>
<CopyIcon />
</button>
<button
onClick={onStats}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.statistics')}
>
<StatsChartIcon />
</button>
<button
onClick={onToggle}
className={cn(
'rounded-lg p-2 transition-colors',
landing.is_active
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600',
)}
title={landing.is_active ? t('admin.landings.inactive') : t('admin.landings.active')}
>
{landing.is_active ? <CheckIcon /> : <XIcon />}
</button>
<button
onClick={onEdit}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.edit')}
>
<EditIcon />
</button>
<div className="flex-1" />
<button
onClick={onDelete}
className={cn(
'rounded-lg p-2 transition-colors',
isPendingDelete
? 'bg-error-500/20 text-error-400 ring-1 ring-error-500/30'
: 'bg-dark-700 text-dark-300 hover:bg-error-500/20 hover:text-error-400',
)}
title={
isPendingDelete
? t('admin.landings.deleteConfirm', {
title: resolveLocaleDisplay(landing.title),
})
: t('common.delete')
}
>
{isPendingDelete ? (
<span className="px-1 text-xs font-medium">{t('common.delete')}?</span>
) : (
<TrashIcon />
)}
</button>
</div>
</div>
</div>
</div>
);
}
// ============ Main Page ============
export default function AdminLandings() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const notify = useNotify();
const { capabilities } = usePlatform();
const [localLandings, setLocalLandings] = useState<LandingListItem[]>([]);
const [orderChanged, setOrderChanged] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
const deleteTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
return () => {
if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
};
}, []);
// Queries
const { data: landingsData, isLoading } = useQuery({
queryKey: ['admin-landings'],
queryFn: () => adminLandingsApi.list(),
staleTime: 30_000,
});
// Sync fetched data to local state
useEffect(() => {
if (landingsData && !orderChanged) {
setLocalLandings(landingsData);
}
}, [landingsData, orderChanged]);
// Save order mutation
const saveOrderMutation = useMutation({
mutationFn: (landingIds: number[]) => adminLandingsApi.reorder(landingIds),
onSuccess: () => {
setOrderChanged(false);
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
notify.success(t('admin.landings.orderSaved'));
},
onError: (err: unknown) => {
notify.error(getApiErrorMessage(err, t('common.error')));
},
});
// Mutations
const deleteMutation = useMutation({
mutationFn: adminLandingsApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
notify.success(t('admin.landings.deleted'));
},
onError: (err: unknown) => {
notify.error(getApiErrorMessage(err, t('common.error')));
},
});
const handleDelete = (landing: LandingListItem) => {
if (pendingDeleteId === landing.id) {
deleteMutation.mutate(landing.id);
setPendingDeleteId(null);
} else {
setPendingDeleteId(landing.id);
if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
deleteTimeoutRef.current = setTimeout(
() => setPendingDeleteId((prev) => (prev === landing.id ? null : prev)),
3000,
);
}
};
const toggleMutation = useMutation({
mutationFn: adminLandingsApi.toggle,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
},
onError: (err: unknown) => {
notify.error(getApiErrorMessage(err, t('common.error')));
},
});
const handleCopyUrl = async (slug: string) => {
const url = `${window.location.origin}/buy/${slug}`;
try {
await copyToClipboard(url);
notify.success(t('admin.landings.urlCopied'));
} catch {
// Clipboard write failed silently
}
};
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setLocalLandings((prev) => {
const oldIndex = prev.findIndex((l) => l.id === active.id);
const newIndex = prev.findIndex((l) => l.id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
setOrderChanged(true);
}
}, []);
const handleSaveOrder = () => {
saveOrderMutation.mutate(localLandings.map((l) => l.id));
};
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{/* Show back button only on web, not in Telegram Mini App */}
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.landings.title')}</h1>
</div>
</div>
<div className="flex items-center gap-2">
{orderChanged && (
<button
onClick={handleSaveOrder}
disabled={saveOrderMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-success-500 px-4 py-2 text-white transition-colors hover:bg-success-600"
>
{saveOrderMutation.isPending ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<SaveIcon />
)}
{t('admin.landings.saveOrder')}
</button>
)}
<button
onClick={() => navigate('/admin/landings/create')}
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
>
<PlusIcon />
{t('admin.landings.create')}
</button>
</div>
</div>
{/* Drag hint */}
<div className="mb-4 flex items-center gap-2 text-sm text-dark-500">
<GripIcon />
{t('admin.tariffs.dragToReorder')}
</div>
{/* Landings List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : localLandings.length === 0 ? (
<div className="py-12 text-center">
<p className="text-dark-400">{t('common.noData')}</p>
</div>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={localLandings.map((l) => l.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{localLandings.map((landing) => (
<SortableLandingCard
key={landing.id}
landing={landing}
onEdit={() => navigate(`/admin/landings/${landing.id}/edit`)}
onStats={() => navigate(`/admin/landings/${landing.id}/stats`)}
onDelete={() => handleDelete(landing)}
onToggle={() => toggleMutation.mutate(landing.id)}
onCopyUrl={() => handleCopyUrl(landing.slug)}
isPendingDelete={pendingDeleteId === landing.id}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
}

View File

@@ -304,6 +304,16 @@ const ClipboardDocumentListIcon = () => (
</svg>
);
const RectangleGroupIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z"
/>
</svg>
);
interface AdminItem {
to: string;
icon: React.ReactNode;
@@ -499,6 +509,13 @@ export default function AdminPanel() {
description: t('admin.panel.paymentMethodsDesc'),
permission: 'payment_methods:read',
},
{
to: '/admin/landings',
icon: <RectangleGroupIcon />,
title: t('admin.nav.landings'),
description: t('admin.panel.landingsDesc'),
permission: 'landings:read',
},
],
},
{

View File

@@ -228,7 +228,7 @@ export default function AdminPayments() {
<a
href={payment.payment_url}
target="_blank"
rel="noopener noreferrer"
rel="noopener"
className="btn-secondary px-3 py-1.5 text-xs"
>
{t('admin.payments.openLink')}

View File

@@ -61,6 +61,7 @@ export default function AdminPromoGroupCreate() {
const [trafficDiscount, setTrafficDiscount] = useState<number | ''>(0);
const [deviceDiscount, setDeviceDiscount] = useState<number | ''>(0);
const [applyToAddons, setApplyToAddons] = useState(true);
const [isDefault, setIsDefault] = useState(false);
const [autoAssignSpent, setAutoAssignSpent] = useState<number | ''>(0);
const [periodDiscounts, setPeriodDiscounts] = useState<PeriodDiscount[]>([]);
@@ -79,6 +80,7 @@ export default function AdminPromoGroupCreate() {
setTrafficDiscount(data.traffic_discount_percent || 0);
setDeviceDiscount(data.device_discount_percent || 0);
setApplyToAddons(data.apply_discounts_to_addons ?? true);
setIsDefault(data.is_default ?? false);
setAutoAssignSpent(
data.auto_assign_total_spent_kopeks ? data.auto_assign_total_spent_kopeks / 100 : 0,
);
@@ -151,7 +153,8 @@ export default function AdminPromoGroupCreate() {
period_discounts:
Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined,
apply_discounts_to_addons: applyToAddons,
auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : null,
auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : 0,
is_default: isDefault,
};
if (isEdit) {
@@ -388,6 +391,24 @@ export default function AdminPromoGroupCreate() {
</button>
<span className="text-sm text-dark-200">{t('admin.promoGroups.form.applyToAddons')}</span>
</label>
{/* Default group */}
<label className="flex cursor-pointer items-center gap-3">
<button
type="button"
onClick={() => setIsDefault(!isDefault)}
className={`relative h-6 w-11 rounded-full transition-colors ${
isDefault ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
isDefault ? 'left-6' : 'left-1'
}`}
/>
</button>
<span className="text-sm text-dark-200">{t('admin.promoGroups.form.isDefault')}</span>
</label>
</div>
{/* Footer */}

View File

@@ -9,7 +9,7 @@ import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
import { usePlatform } from '../platform/hooks/usePlatform';
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
import { BrandingTab } from '../components/admin/BrandingTab';
import { ButtonsTab } from '../components/admin/ButtonsTab';
import { MenuEditorTab } from '../components/admin/MenuEditorTab';
import { ThemeTab } from '../components/admin/ThemeTab';
import { FavoritesTab } from '../components/admin/FavoritesTab';
import { SettingsTab } from '../components/admin/SettingsTab';
@@ -176,7 +176,7 @@ export default function AdminSettings() {
case 'theme':
return <ThemeTab />;
case 'buttons':
return <ButtonsTab />;
return <MenuEditorTab />;
case 'favorites':
return (
<FavoritesTab

View File

@@ -9,6 +9,7 @@ import {
TariffUpdateRequest,
PeriodPrice,
ServerInfo,
ExternalSquadInfo,
} from '../api/tariffs';
import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
@@ -105,6 +106,7 @@ export default function AdminTariffCreate() {
const [tierLevel, setTierLevel] = useState<number | ''>(1);
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>([]);
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null);
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
@@ -138,6 +140,12 @@ export default function AdminTariffCreate() {
queryFn: () => tariffsApi.getAvailableServers(),
});
// Fetch external squads
const { data: externalSquads = [] } = useQuery({
queryKey: ['admin-tariffs-external-squads'],
queryFn: () => tariffsApi.getAvailableExternalSquads(),
});
// Fetch promo groups
const { data: promoGroups = [] } = useQuery({
queryKey: ['admin-tariffs-promo-groups'],
@@ -165,6 +173,7 @@ export default function AdminTariffCreate() {
setTierLevel(data.tier_level || 1);
setPeriodPrices(data.period_prices?.length ? data.period_prices : []);
setSelectedSquads(data.allowed_squads || []);
setSelectedExternalSquad(data.external_squad_uuid || null);
setSelectedPromoGroups(
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
);
@@ -211,6 +220,7 @@ export default function AdminTariffCreate() {
tier_level: toNumber(tierLevel, 1),
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
allowed_squads: selectedSquads,
external_squad_uuid: selectedExternalSquad || null,
promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined,
traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages,
@@ -660,6 +670,77 @@ export default function AdminTariffCreate() {
{activeTab === 'servers' && (
<div className="space-y-4">
{/* External Squad */}
{externalSquads.length > 0 && (
<div className="card space-y-4">
<h4 className="text-sm font-medium text-dark-200">
{t('admin.tariffs.externalSquadTitle')}
</h4>
<p className="text-sm text-dark-400">{t('admin.tariffs.externalSquadHint')}</p>
<div className="space-y-2">
<button
type="button"
onClick={() => setSelectedExternalSquad(null)}
className={`flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors ${
!selectedExternalSquad
? isDaily
? 'bg-warning-500/20 text-warning-300'
: 'bg-accent-500/20 text-accent-300'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
<div
className={`flex h-5 w-5 items-center justify-center rounded-full ${
!selectedExternalSquad
? isDaily
? 'bg-warning-500 text-white'
: 'bg-accent-500 text-white'
: 'bg-dark-600'
}`}
>
{!selectedExternalSquad && <CheckIcon />}
</div>
<span className="flex-1 text-sm font-medium">
{t('admin.tariffs.noExternalSquad')}
</span>
</button>
{externalSquads.map((squad: ExternalSquadInfo) => {
const isSelected = selectedExternalSquad === squad.uuid;
return (
<button
key={squad.uuid}
type="button"
onClick={() => setSelectedExternalSquad(squad.uuid)}
className={`flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors ${
isSelected
? isDaily
? 'bg-warning-500/20 text-warning-300'
: 'bg-accent-500/20 text-accent-300'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
<div
className={`flex h-5 w-5 items-center justify-center rounded-full ${
isSelected
? isDaily
? 'bg-warning-500 text-white'
: 'bg-accent-500 text-white'
: 'bg-dark-600'
}`}
>
{isSelected && <CheckIcon />}
</div>
<span className="flex-1 text-sm font-medium">{squad.name}</span>
<span className="text-xs text-dark-500">
{squad.members_count} {t('admin.tariffs.externalSquadUsers')}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Servers */}
<div className="card space-y-4">
<h4 className="text-sm font-medium text-dark-200">{t('admin.tariffs.serversTitle')}</h4>

82
src/pages/AutoLogin.tsx Normal file
View File

@@ -0,0 +1,82 @@
import { useEffect, useState, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { authApi } from '../api/auth';
import { useAuthStore } from '../store/auth';
export default function AutoLogin() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
const [error, setError] = useState(false);
const attemptedRef = useRef(false);
const token = searchParams.get('token');
useEffect(() => {
// Prevent referrer leaking the token
const meta = document.createElement('meta');
meta.name = 'referrer';
meta.content = 'no-referrer';
document.head.appendChild(meta);
return () => {
document.head.removeChild(meta);
};
}, []);
useEffect(() => {
if (!token || attemptedRef.current) {
if (!token) setError(true);
return;
}
attemptedRef.current = true;
authApi
.autoLogin(token)
.then(async (response) => {
setTokens(response.access_token, response.refresh_token);
setUser(response.user);
await checkAdminStatus();
navigate('/', { replace: true });
})
.catch(() => {
setError(true);
});
}, [token, navigate, setTokens, setUser, checkAdminStatus]);
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
<div className="w-full max-w-sm rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8 text-center">
{error ? (
<div className="space-y-4">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-error-500/10">
<svg
className="h-8 w-8 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-sm text-dark-300">{t('landing.autoLoginFailed')}</p>
<button
type="button"
onClick={() => navigate('/login', { replace: true })}
className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('auth.login', 'Login')}
</button>
</div>
) : (
<div className="space-y-4">
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500" />
<p className="text-sm text-dark-300">{t('landing.autoLoginProcessing')}</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -8,12 +8,12 @@ import { useAuthStore } from '../store/auth';
import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency';
import { API } from '../config/constants';
import { useToast } from '../components/Toast';
import type { PaginatedResponse, Transaction } from '../types';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus';
// Icons
const ChevronDownIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
@@ -51,7 +51,6 @@ export default function Balance() {
const { formatAmount, currencySymbol } = useCurrency();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { showToast } = useToast();
const paymentHandledRef = useRef(false);
// Fetch balance from API
@@ -72,30 +71,19 @@ export default function Balance() {
if (paymentHandledRef.current) return;
const paymentStatus = searchParams.get('payment') || searchParams.get('status');
const isSuccess =
paymentStatus === 'success' ||
paymentStatus === 'paid' ||
paymentStatus === 'completed' ||
searchParams.get('success') === 'true';
const normalised = paymentStatus?.toLowerCase() ?? '';
const isSuccess = isPaidStatus(normalised) || searchParams.get('success') === 'true';
const isFailed = isFailedStatus(normalised);
if (isSuccess) {
paymentHandledRef.current = true;
refetchBalance();
refreshUser();
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
showToast({
type: 'success',
title: t('balance.paymentSuccess.title'),
message: t('balance.paymentSuccess.message'),
duration: 6000,
});
navigate('/balance', { replace: true });
navigate('/balance/top-up/result?status=success', { replace: true });
} else if (isFailed) {
paymentHandledRef.current = true;
navigate('/balance/top-up/result?status=failed', { replace: true });
}
}, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]);
}, [searchParams, navigate]);
const [promocode, setPromocode] = useState('');
const [promocodeLoading, setPromocodeLoading] = useState(false);
@@ -175,6 +163,7 @@ export default function Balance() {
await refetchBalance();
await refreshUser();
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
}
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { detail?: string } } };

View File

@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { authApi } from '../api/auth';
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { useToast } from '../components/Toast';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
@@ -24,47 +25,191 @@ const isLinkableProvider = (provider: string): boolean =>
// SessionStorage key for Telegram link CSRF state
export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state';
/** Compact Telegram Login Widget for account linking (browser only). */
/** Telegram account linking widget (browser only). Supports OIDC popup and legacy widget. */
function TelegramLinkWidget() {
const containerRef = useRef<HTMLDivElement>(null);
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const navigate = useNavigate();
const { showToast } = useToast();
const { t } = useTranslation();
const queryClient = useQueryClient();
const [oidcLoading, setOidcLoading] = useState(false);
const [scriptLoaded, setScriptLoaded] = useState(false);
const mountedRef = useRef(true);
const { data: widgetConfig } = useQuery<TelegramWidgetConfig>({
queryKey: ['telegram-widget-config'],
queryFn: brandingApi.getTelegramWidgetConfig,
staleTime: 60000,
});
const botUsername =
widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const isOIDC = Boolean(widgetConfig?.oidc_enabled && widgetConfig?.oidc_client_id);
useEffect(() => {
if (!containerRef.current || !botUsername) return;
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// Shared handler for link result
const handleLinkResult = async (response: Awaited<ReturnType<typeof authApi.linkTelegram>>) => {
if (response.merge_required && response.merge_token) {
navigate(`/merge/${response.merge_token}`, { replace: true });
} else {
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
}
};
// OIDC callback handler (ref pattern to avoid stale closures)
const handleOIDCCallbackRef =
useRef<(data: { id_token?: string; error?: string }) => void>(undefined);
handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => {
if (!mountedRef.current) return;
if (data.error || !data.id_token) {
setOidcLoading(false);
showToast({
type: 'error',
message: data.error || t('profile.accounts.linkError'),
});
return;
}
try {
setOidcLoading(true);
const response = await authApi.linkTelegram({ id_token: data.id_token });
if (mountedRef.current) await handleLinkResult(response);
} catch (err: unknown) {
if (mountedRef.current) {
showToast({
type: 'error',
message: getErrorDetail(err) || t('profile.accounts.linkError'),
});
}
} finally {
if (mountedRef.current) setOidcLoading(false);
}
};
// Load OIDC script and init
useEffect(() => {
if (!isOIDC || !widgetConfig?.oidc_client_id) return;
const scriptId = 'telegram-login-oidc-script';
let script = document.getElementById(scriptId) as HTMLScriptElement | null;
const initTelegramLogin = () => {
if (window.Telegram?.Login) {
window.Telegram.Login.init(
{
client_id: Number(widgetConfig.oidc_client_id) || widgetConfig.oidc_client_id,
request_access: widgetConfig.request_access ? ['write'] : undefined,
lang: document.documentElement.lang || 'en',
},
(data) => handleOIDCCallbackRef.current?.(data),
);
setScriptLoaded(true);
}
};
if (!script) {
script = document.createElement('script');
script.id = scriptId;
script.src = 'https://oauth.telegram.org/js/telegram-login.js?3';
script.async = true;
script.onload = () => initTelegramLogin();
script.onerror = () => {
if (mountedRef.current) {
showToast({ type: 'error', message: t('profile.accounts.linkError') });
}
};
document.head.appendChild(script);
} else {
initTelegramLogin();
}
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]);
// Legacy widget effect (only when NOT OIDC)
useEffect(() => {
if (isOIDC || !containerRef.current || !botUsername) return;
const container = containerRef.current;
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Generate CSRF state token and store in sessionStorage
const csrfState = crypto.randomUUID();
sessionStorage.setItem(LINK_TELEGRAM_STATE_KEY, csrfState);
const redirectUrl = `${window.location.origin}/auth/link/telegram/callback?csrf_state=${encodeURIComponent(csrfState)}`;
const callbackName = '__onTelegramLinkAuth';
(window as unknown as Record<string, unknown>)[callbackName] = async (
user: Record<string, unknown>,
) => {
if (!mountedRef.current) return;
try {
const response = await authApi.linkTelegram({
id: user.id as number,
first_name: user.first_name as string,
last_name: (user.last_name as string) || undefined,
username: (user.username as string) || undefined,
photo_url: (user.photo_url as string) || undefined,
auth_date: user.auth_date as number,
hash: user.hash as string,
});
if (mountedRef.current) await handleLinkResult(response);
} catch (err: unknown) {
if (mountedRef.current) {
showToast({
type: 'error',
message: getErrorDetail(err) || t('profile.accounts.linkError'),
});
}
}
};
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.src = 'https://telegram.org/js/telegram-widget.js?23';
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'small');
script.setAttribute('data-radius', '8');
script.setAttribute('data-auth-url', redirectUrl);
script.setAttribute('data-onauth', `${callbackName}(user)`);
script.setAttribute('data-request-access', 'write');
script.async = true;
container.appendChild(script);
return () => {
delete (window as unknown as Record<string, unknown>)[callbackName];
while (container.firstChild) {
container.removeChild(container.firstChild);
}
};
}, [botUsername]);
}, [isOIDC, botUsername, navigate, showToast, t, queryClient]);
if (!botUsername) {
if (!botUsername && !isOIDC) {
return null;
}
if (isOIDC) {
return (
<Button
variant="primary"
size="sm"
disabled={oidcLoading || !scriptLoaded}
loading={oidcLoading}
onClick={() => {
setOidcLoading(true);
if (window.Telegram?.Login) {
window.Telegram.Login.open();
} else {
setOidcLoading(false);
}
}}
>
{t('profile.accounts.link')}
</Button>
);
}
return <div ref={containerRef} className="flex items-center" />;
}

View File

@@ -37,6 +37,7 @@ export default function Connection() {
const handleOpenQR = useCallback(() => {
navigate('/connection/qr', {
replace: !isTelegramWebApp,
state: {
url: appConfig?.subscriptionUrl,
hideLink: appConfig?.hideLink ?? false,

View File

@@ -2,8 +2,8 @@ import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { QRCodeSVG } from 'qrcode.react';
import { useAuthStore } from '../store/auth';
import { useBranding } from '../hooks/useBranding';
import { AdminBackButton } from '@/components/admin';
interface ConnectionQRState {
url: string;
@@ -20,96 +20,56 @@ export default function ConnectionQR() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
const { appName } = useBranding();
const state = location.state as unknown;
const validState = isValidState(state) ? state : null;
useEffect(() => {
if (!isLoading && !isAuthenticated) {
navigate('/login', { replace: true });
}
}, [isAuthenticated, isLoading, navigate]);
useEffect(() => {
if (!isLoading && isAuthenticated && !validState) {
if (!validState) {
navigate('/connection', { replace: true });
}
}, [isLoading, isAuthenticated, validState, navigate]);
}, [validState, navigate]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
navigate(-1);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [navigate]);
if (isLoading || !validState) {
if (!validState) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex min-h-dvh flex-col items-center justify-center bg-gradient-to-b from-dark-900 to-dark-950">
{/* Close button */}
<button
type="button"
onClick={() => navigate(-1)}
className="absolute left-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-dark-800/60 backdrop-blur-sm transition-colors hover:bg-dark-700/80"
aria-label={t('common.close')}
>
<svg
className="h-5 w-5 text-dark-200"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="animate-fade-in">
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to="/connection" replace />
<h1 className="text-2xl font-bold text-dark-100">{t('subscription.connection.qrTitle')}</h1>
</div>
{/* Content */}
<div className="flex w-full max-w-sm animate-scale-in flex-col items-center px-6">
{/* Branding name */}
{appName && (
<p className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{appName}
<div className="flex flex-col items-center">
<div className="flex w-full max-w-sm flex-col items-center px-6">
{appName && (
<p className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{appName}
</p>
)}
<p className="mb-8 text-center text-sm text-dark-400">
{t('subscription.connection.qrScanHint')}
</p>
)}
{/* Title */}
<h1 className="mb-2 text-center text-xl font-bold text-dark-100">
{t('subscription.connection.qrTitle')}
</h1>
<div className="rounded-3xl bg-white p-6">
<QRCodeSVG
value={validState.url}
size={280}
level="M"
includeMargin={false}
className="h-[280px] w-[280px] sm:h-[360px] sm:w-[360px]"
/>
</div>
{/* Hint */}
<p className="mb-8 text-center text-sm text-dark-400">
{t('subscription.connection.qrScanHint')}
</p>
{/* QR code container */}
<div className="rounded-3xl bg-white p-6">
<QRCodeSVG
value={validState.url}
size={280}
level="M"
includeMargin={false}
className="h-[280px] w-[280px] sm:h-[360px] sm:w-[360px]"
/>
{!validState.hideLink && (
<p className="mt-6 max-w-full truncate text-center font-mono text-xs text-dark-500">
{validState.url}
</p>
)}
</div>
{/* URL display */}
{!validState.hideLink && (
<p className="mt-6 max-w-full truncate text-center font-mono text-xs text-dark-500">
{validState.url}
</p>
)}
</div>
</div>
);

View File

@@ -14,6 +14,8 @@ import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActi
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
import StatsGrid from '../components/dashboard/StatsGrid';
import { giftApi } from '../api/gift';
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
import { API } from '../config/constants';
const ChevronRightIcon = () => (
@@ -80,6 +82,13 @@ export default function Dashboard() {
retry: false,
});
const { data: pendingGifts } = useQuery({
queryKey: ['pending-gifts'],
queryFn: giftApi.getPendingGifts,
staleTime: 30_000,
retry: false,
});
const activateTrialMutation = useMutation({
mutationFn: subscriptionApi.activateTrial,
onSuccess: () => {
@@ -87,6 +96,7 @@ export default function Dashboard() {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
},
onError: (error: { response?: { data?: { detail?: string } } }) => {
@@ -220,6 +230,9 @@ export default function Dashboard() {
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
</div>
{/* Pending Gift Activations */}
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
{/* Subscription Status Card */}
{subLoading ? (
<div className="bento-card">
@@ -234,8 +247,12 @@ export default function Dashboard() {
<div className="skeleton h-12 w-full rounded-xl" />
</div>
</div>
) : subscription?.is_expired ? (
<SubscriptionCardExpired subscription={subscription} />
) : subscription?.is_expired || subscription?.status === 'disabled' ? (
<SubscriptionCardExpired
subscription={subscription}
balanceKopeks={balanceData?.balance_kopeks ?? 0}
balanceRubles={balanceData?.balance_rubles ?? 0}
/>
) : subscription ? (
<SubscriptionCardActive
subscription={subscription}

457
src/pages/GiftResult.tsx Normal file
View File

@@ -0,0 +1,457 @@
import { useCallback, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { giftApi } from '../api/gift';
import { Spinner } from '@/components/ui/Spinner';
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
const KNOWN_WARNINGS = new Set(['telegram_unresolvable']);
// ============================================================
// Sub-components
// ============================================================
function PendingState() {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<Spinner className="h-16 w-16 border-[3px]" />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.processing', 'Processing your gift...')}
</h1>
<p className="mt-2 text-sm text-dark-400">
{t('gift.pendingDesc', 'Please wait while we process your payment')}
</p>
</div>
</motion.div>
);
}
function DeliveredState({
recipientContact,
tariffName,
periodDays,
giftMessage,
warning,
}: {
recipientContact: string | null;
tariffName: string | null;
periodDays: number | null;
giftMessage: string | null;
warning: string | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('gift.successTitle', 'Gift sent!')}</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('gift.days', 'days')}
</p>
)}
{recipientContact && (
<p className="mt-2 text-sm text-dark-400">
{t('gift.successDesc', {
contact: recipientContact,
defaultValue: `Sent to ${recipientContact}`,
})}
</p>
)}
{giftMessage && (
<p className="mt-2 text-sm italic text-dark-400">&ldquo;{giftMessage}&rdquo;</p>
)}
</div>
{warning && (
<div className="w-full rounded-xl border border-warning-500/20 bg-warning-500/5 p-3">
<p className="text-sm text-warning-400">{t(`gift.warning.${warning}`)}</p>
</div>
)}
<button
type="button"
onClick={() => navigate('/')}
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.backToDashboard', 'Back to dashboard')}
</button>
</motion.div>
);
}
function PendingActivationState({
recipientContact,
tariffName,
periodDays,
warning,
}: {
recipientContact: string | null;
tariffName: string | null;
periodDays: number | null;
warning: string | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
{/* Info icon */}
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-warning-500/10">
<svg
className="h-10 w-10 text-warning-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.pendingActivationTitle', 'Gift pending activation')}
</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('gift.days', 'days')}
</p>
)}
{recipientContact && (
<p className="mt-2 text-sm text-dark-400">
{t('gift.successDesc', {
contact: recipientContact,
defaultValue: `Sent to ${recipientContact}`,
})}
</p>
)}
<p className="mt-2 text-sm text-dark-400">
{t(
'gift.pendingActivationDesc',
'The recipient currently has an active subscription. Your gift will be activated once their current subscription expires.',
)}
</p>
</div>
{warning && (
<div className="w-full rounded-xl border border-warning-500/20 bg-warning-500/5 p-3">
<p className="text-sm text-warning-400">{t(`gift.warning.${warning}`)}</p>
</div>
)}
<button
type="button"
onClick={() => navigate('/')}
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.backToDashboard', 'Back to dashboard')}
</button>
</motion.div>
);
}
function FailedState() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCrossmark />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.failedTitle', 'Something went wrong')}
</h1>
<p className="mt-2 text-sm text-dark-400">
{t('gift.failedDesc', 'Your gift could not be processed. Please try again.')}
</p>
</div>
<button
type="button"
onClick={() => navigate('/gift')}
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.tryAgain', 'Try again')}
</button>
</motion.div>
);
}
function PollErrorState() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-warning-500/10">
<svg
className="h-10 w-10 text-warning-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.pollErrorTitle', 'Could not check gift status')}
</h1>
<p className="mt-2 text-sm text-dark-400">
{t(
'gift.pollErrorDesc',
'Your purchase was successful. Check your dashboard for details.',
)}
</p>
</div>
<button
type="button"
onClick={() => navigate('/')}
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.backToDashboard', 'Back to dashboard')}
</button>
</motion.div>
);
}
function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-dark-800/50">
<svg
className="h-10 w-10 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.pollTimeout', 'Taking longer than expected')}
</h1>
<p className="mt-2 text-sm text-dark-400">
{t(
'gift.pollTimeoutDesc',
'Payment processing is taking longer than usual. You can try checking again.',
)}
</p>
</div>
<button
type="button"
onClick={onRetry}
className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.retry', 'Retry')}
</button>
</motion.div>
);
}
function NoTokenState() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-dark-800/50">
<svg
className="h-10 w-10 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">{t('gift.noToken', 'Invalid link')}</h1>
<p className="mt-2 text-sm text-dark-400">
{t('gift.noTokenDesc', 'This gift link is invalid or has expired.')}
</p>
</div>
<button
type="button"
onClick={() => navigate('/gift')}
className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.backToGift', 'Go back')}
</button>
</motion.div>
);
}
// ============================================================
// Main Component
// ============================================================
export default function GiftResult() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const mode = searchParams.get('mode');
const rawUrlWarning = searchParams.get('warning');
const urlWarning = rawUrlWarning && KNOWN_WARNINGS.has(rawUrlWarning) ? rawUrlWarning : null;
const pollStart = useRef(Date.now());
const [pollTimedOut, setPollTimedOut] = useState(false);
const isBalanceMode = mode === 'balance';
const {
data: status,
isError,
refetch,
} = useQuery({
queryKey: ['gift-status', token],
queryFn: () => giftApi.getPurchaseStatus(token!),
enabled: !!token && !pollTimedOut,
refetchInterval: (query) => {
// Balance mode: fetch once, no polling
if (isBalanceMode) return false;
const s = query.state.data?.status;
if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired')
return false;
// Check poll timeout
if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true);
return false;
}
return 3000;
},
retry: 2,
});
const handleRetryPoll = useCallback(() => {
pollStart.current = Date.now();
setPollTimedOut(false);
refetch();
}, [refetch]);
// No token
if (!token) {
return (
<div className="flex min-h-dvh items-center justify-center px-4">
<div
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
aria-live="polite"
aria-atomic="true"
>
<NoTokenState />
</div>
</div>
);
}
const isDelivered = status?.status === 'delivered';
const isPendingActivation = status?.status === 'pending_activation';
const isFailed = status?.status === 'failed' || status?.status === 'expired';
// Warning from status response (persisted on purchase) takes priority over URL param
const statusWarning =
status?.warning && KNOWN_WARNINGS.has(status.warning) ? status.warning : null;
const warning = statusWarning ?? urlWarning;
return (
<div className="flex min-h-dvh items-center justify-center px-4">
<div
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
aria-live="polite"
aria-atomic="true"
>
{isError ? (
<PollErrorState />
) : isDelivered ? (
<DeliveredState
recipientContact={status.recipient_contact_value}
tariffName={status.tariff_name}
periodDays={status.period_days}
giftMessage={status.gift_message}
warning={warning}
/>
) : isPendingActivation ? (
<PendingActivationState
recipientContact={status.recipient_contact_value}
tariffName={status.tariff_name}
periodDays={status.period_days}
warning={warning}
/>
) : isFailed ? (
<FailedState />
) : pollTimedOut ? (
<PollTimedOutState onRetry={handleRetryPoll} />
) : (
<PendingState />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,985 @@
import { useState, useMemo, useEffect } from 'react';
import { useNavigate, Link } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion';
import { giftApi } from '../api/gift';
import type {
GiftConfig,
GiftTariff,
GiftTariffPeriod,
GiftPaymentMethod,
GiftPurchaseRequest,
} from '../api/gift';
import { cn } from '../lib/utils';
import { getApiErrorMessage } from '../utils/api-error';
import { formatPrice } from '../utils/format';
// ============================================================
// Helpers
// ============================================================
function detectContactType(value: string): 'email' | 'telegram' {
return value.startsWith('@') ? 'telegram' : 'email';
}
function isValidContact(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed.startsWith('@')) {
return /^@[a-zA-Z][a-zA-Z0-9_]{4,31}$/.test(trimmed);
}
return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed);
}
function formatPeriodLabel(
days: number,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
const key = `landing.periodLabels.d${days}`;
const result = t(key);
if (result !== key) return result;
const months = Math.floor(days / 30);
const remainder = days % 30;
if (months > 0 && remainder === 0) {
return t('landing.periodLabels.nMonths', { count: months });
}
return t('landing.periodLabels.nDays', { count: days });
}
// ============================================================
// Sub-components
// ============================================================
function LoadingSkeleton() {
return (
<div className="flex min-h-dvh items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500" />
</div>
</div>
);
}
function ErrorState({ message }: { message: string }) {
const { t } = useTranslation();
return (
<div className="flex min-h-dvh items-center justify-center px-4">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-error-500/10">
<svg
className="h-8 w-8 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-dark-50">{t('gift.failedTitle', 'Error')}</h2>
<p className="text-sm text-dark-300">{message}</p>
</div>
</div>
);
}
function DisabledState() {
const { t } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
const timer = setTimeout(() => navigate('/'), 3000);
return () => clearTimeout(timer);
}, [navigate]);
return (
<div className="flex min-h-dvh items-center justify-center px-4">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-dark-800/50">
<svg
className="h-8 w-8 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-dark-50">
{t('gift.featureDisabled', 'Gift subscriptions are currently unavailable')}
</h2>
<p className="text-sm text-dark-300">{t('gift.redirecting', 'Redirecting...')}</p>
</div>
</div>
);
}
function PeriodTabs({
periods,
selectedDays,
onSelect,
}: {
periods: GiftTariffPeriod[];
selectedDays: number;
onSelect: (days: number) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
{periods.map((period) => (
<button
key={period.days}
type="button"
onClick={() => onSelect(period.days)}
className={cn(
'whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all duration-200',
selectedDays === period.days
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25'
: 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50 hover:text-dark-100',
)}
>
{formatPeriodLabel(period.days, t)}
</button>
))}
</div>
);
}
function TariffCard({
tariff,
isSelected,
selectedPeriod,
onSelect,
}: {
tariff: GiftTariff;
isSelected: boolean;
selectedPeriod: GiftTariffPeriod | undefined;
onSelect: () => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
role="radio"
aria-checked={isSelected}
onClick={onSelect}
className={cn(
'relative flex w-full flex-col rounded-2xl border p-5 text-start transition-all duration-200',
isSelected
? 'border-accent-500/50 bg-accent-500/5 ring-1 ring-accent-500/25'
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30',
)}
>
{/* Header */}
<div className="mb-3 flex items-start justify-between">
<div>
<h3 className="text-base font-semibold text-dark-50">{tariff.name}</h3>
{tariff.description && (
<p className="mt-0.5 text-xs text-dark-400">{tariff.description}</p>
)}
</div>
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600',
)}
>
{isSelected && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
</div>
{/* Info row */}
<div className="flex items-center gap-3 text-xs text-dark-400">
<span className="flex items-center gap-1">
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
{tariff.traffic_limit_gb === 0 ? '\u221E' : tariff.traffic_limit_gb}{' '}
{t('landing.gb', 'GB')}
</span>
<span className="flex items-center gap-1">
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
{tariff.device_limit} {t('landing.devices', 'devices')}
</span>
</div>
{/* Price */}
{selectedPeriod && (
<div className="mt-3 border-t border-dark-800/30 pt-3">
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-accent-400">
{formatPrice(selectedPeriod.price_kopeks)}
</span>
{selectedPeriod.original_price_kopeks != null &&
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
<>
<span className="text-sm text-dark-500 line-through">
{formatPrice(selectedPeriod.original_price_kopeks)}
</span>
{selectedPeriod.discount_percent != null && (
<span className="rounded-full bg-accent-500/20 px-1.5 py-0.5 text-[10px] font-bold text-accent-400">
-{selectedPeriod.discount_percent}%
</span>
)}
</>
)}
</div>
</div>
)}
</button>
);
}
function PaymentModeToggle({
mode,
onToggle,
balanceLabel,
}: {
mode: 'balance' | 'gateway';
onToggle: (mode: 'balance' | 'gateway') => void;
balanceLabel: string;
}) {
const { t } = useTranslation();
return (
<div
role="group"
aria-label={t('gift.paymentMode', 'Payment mode')}
className="flex rounded-xl bg-dark-800/50 p-1"
>
<button
type="button"
onClick={() => onToggle('balance')}
aria-pressed={mode === 'balance'}
className={cn(
'flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200',
mode === 'balance'
? 'bg-dark-700 text-dark-50 shadow-sm'
: 'text-dark-400 hover:text-dark-200',
)}
>
{balanceLabel}
</button>
<button
type="button"
onClick={() => onToggle('gateway')}
aria-pressed={mode === 'gateway'}
className={cn(
'flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200',
mode === 'gateway'
? 'bg-dark-700 text-dark-50 shadow-sm'
: 'text-dark-400 hover:text-dark-200',
)}
>
{t('gift.viaGateway', 'Via payment gateway')}
</button>
</div>
);
}
function PaymentMethodCard({
method,
isSelected,
selectedSubOption,
onSelect,
onSelectSubOption,
}: {
method: GiftPaymentMethod;
isSelected: boolean;
selectedSubOption: string | null;
onSelect: () => void;
onSelectSubOption: (subOptionId: string) => void;
}) {
const hasSubOptions = method.sub_options && method.sub_options.length > 1;
return (
<div
className={cn(
'rounded-2xl border transition-all duration-200',
isSelected
? 'border-accent-500/50 bg-accent-500/5'
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30',
)}
>
<button
type="button"
role="radio"
aria-checked={isSelected}
onClick={onSelect}
className="flex w-full items-center gap-4 p-4 text-start"
>
{/* Icon */}
{method.icon_url && (
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-dark-800/50">
<img src={method.icon_url} alt="" className="h-6 w-6 object-contain" />
</div>
)}
{/* Text */}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-dark-100">{method.display_name}</p>
{method.description && (
<p className="mt-0.5 truncate text-xs text-dark-400">{method.description}</p>
)}
</div>
{/* Radio */}
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600',
)}
>
{isSelected && <div className="h-2 w-2 rounded-full bg-white" />}
</div>
</button>
{/* Sub-options */}
{isSelected && hasSubOptions && (
<div className="border-t border-dark-800/30 px-4 pb-4 pt-3">
<div className="flex flex-wrap gap-2">
{method.sub_options!.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => onSelectSubOption(opt.id)}
className={cn(
'rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200',
selectedSubOption === opt.id
? 'bg-accent-500 text-white shadow-sm shadow-accent-500/25'
: 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50 hover:text-dark-100',
)}
>
{opt.name}
</button>
))}
</div>
</div>
)}
</div>
);
}
function RecipientSection({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation();
return (
<div className="space-y-4 rounded-2xl border border-dark-800/50 bg-dark-900/50 p-5">
<div>
<label
htmlFor="gift-recipient-input"
className="mb-2 block text-sm font-medium text-dark-200"
>
{t('gift.recipient', 'Recipient')}
</label>
<input
id="gift-recipient-input"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t('gift.recipientPlaceholder', 'email@example.com or @telegram')}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
/>
<p className="mt-1.5 text-xs text-dark-500">
{t(
'gift.recipientHint',
'Enter the email or Telegram username of the person you want to gift',
)}
</p>
</div>
</div>
);
}
function GiftMessageSection({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation();
return (
<AnimatePresence mode="wait">
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-2xl border border-dark-800/50 bg-dark-900/50 p-5">
<label
htmlFor="gift-message-input"
className="mb-2 block text-sm font-medium text-dark-200"
>
{t('gift.giftMessage', 'Personal message')}
</label>
<textarea
id="gift-message-input"
value={value}
onChange={(e) => {
if (e.target.value.length <= 1000) {
onChange(e.target.value);
}
}}
placeholder={t(
'gift.giftMessagePlaceholder',
'Add a personal message for the recipient (optional)',
)}
rows={3}
maxLength={1000}
className="w-full resize-none rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
/>
<p className="mt-1 text-right text-xs text-dark-500">{value.length}/1000</p>
</div>
</motion.div>
</AnimatePresence>
);
}
function GiftSummaryCard({
config,
selectedTariff,
selectedPeriod,
currentPrice,
paymentMode,
isSubmitting,
canSubmit,
submitError,
insufficientBalance,
onSubmit,
}: {
config: GiftConfig;
selectedTariff: GiftTariff | undefined;
selectedPeriod: GiftTariffPeriod | undefined;
currentPrice: number;
paymentMode: 'balance' | 'gateway';
isSubmitting: boolean;
canSubmit: boolean;
submitError: string | null;
insufficientBalance: boolean;
onSubmit: () => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-5">
{/* Summary */}
<div className="rounded-2xl border border-dark-800/50 bg-dark-900/50 p-5">
{selectedTariff && (
<div className="mb-3">
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
{t('gift.tariff', 'Tariff')}
</p>
<p className="mt-1 text-sm font-semibold text-dark-50">{selectedTariff.name}</p>
</div>
)}
{selectedPeriod && (
<div className="mb-4">
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
{t('gift.period', 'Period')}
</p>
<p className="mt-1 text-sm text-dark-200">
{formatPeriodLabel(selectedPeriod.days, t)}
</p>
</div>
)}
<div className="border-t border-dark-800/50 pt-4">
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
{t('gift.total', 'Total')}
</p>
<div className="mt-1 flex items-center gap-2">
<span className="text-2xl font-bold text-accent-400">{formatPrice(currentPrice)}</span>
{selectedPeriod?.original_price_kopeks != null &&
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
<>
<span className="text-base text-dark-500 line-through">
{formatPrice(selectedPeriod.original_price_kopeks)}
</span>
{selectedPeriod.discount_percent != null && (
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs font-bold text-accent-400">
-{selectedPeriod.discount_percent}%
</span>
)}
</>
)}
</div>
</div>
{/* Balance info for balance mode */}
{paymentMode === 'balance' && (
<div className="mt-4 border-t border-dark-800/50 pt-4">
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
{t('gift.yourBalance', 'Your balance')}
</p>
<p className="mt-1 text-sm font-semibold text-dark-200">
{formatPrice(config.balance_kopeks)}
</p>
</div>
)}
</div>
{/* Insufficient balance warning */}
<AnimatePresence>
{insufficientBalance && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="rounded-xl border border-warning-500/20 bg-warning-500/5 p-3"
>
<p className="text-sm text-warning-400">
{t('gift.insufficientBalance', 'Insufficient balance.')}{' '}
<Link
to="/balance"
className="font-medium text-accent-400 underline underline-offset-2"
>
{t('gift.topUpBalance', 'Top up balance')}
</Link>
</p>
</motion.div>
)}
</AnimatePresence>
{/* Error */}
<AnimatePresence>
{submitError && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="rounded-xl border border-error-500/20 bg-error-500/5 p-3"
>
<p className="text-sm text-error-400">{submitError}</p>
</motion.div>
)}
</AnimatePresence>
{/* Gift button */}
<button
type="button"
onClick={onSubmit}
disabled={!canSubmit || isSubmitting}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-2xl px-6 py-4 text-base font-semibold transition-all duration-200',
canSubmit && !isSubmitting
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400 hover:shadow-accent-500/40 active:scale-[0.98]'
: 'cursor-not-allowed bg-dark-800 text-dark-500',
)}
>
{isSubmitting ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<>
{t('gift.giftButton', 'Gift')} {formatPrice(currentPrice)}
</>
)}
</button>
</div>
);
}
// ============================================================
// Main Component
// ============================================================
export default function GiftSubscription() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
// Fetch config
const {
data: config,
isLoading,
error,
} = useQuery({
queryKey: ['gift-config'],
queryFn: giftApi.getConfig,
staleTime: 30_000,
});
// Selection state
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
const [recipientValue, setRecipientValue] = useState('');
const [giftMessage, setGiftMessage] = useState('');
const [paymentMode, setPaymentMode] = useState<'balance' | 'gateway'>('balance');
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [selectedSubOption, setSelectedSubOption] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
// Collect ALL unique periods across ALL tariffs
const allPeriods = useMemo(() => {
if (!config) return [];
const periodMap = new Map<number, GiftTariffPeriod>();
for (const tariff of config.tariffs) {
for (const period of tariff.periods) {
if (!periodMap.has(period.days)) {
periodMap.set(period.days, period);
}
}
}
return Array.from(periodMap.values()).sort((a, b) => a.days - b.days);
}, [config]);
// Filter tariffs to only those that have the selected period
const visibleTariffs = useMemo(() => {
if (!config || !selectedPeriodDays) return config?.tariffs ?? [];
return config.tariffs.filter((tariff) =>
tariff.periods.some((p) => p.days === selectedPeriodDays),
);
}, [config, selectedPeriodDays]);
// Auto-select first tariff, period, method on config load
useEffect(() => {
if (!config) return;
if (allPeriods.length > 0 && selectedPeriodDays === null) {
setSelectedPeriodDays(allPeriods[0].days);
}
if (visibleTariffs.length > 0 && selectedTariffId === null) {
setSelectedTariffId(visibleTariffs[0].id);
}
if (config.payment_methods.length > 0 && selectedMethod === null) {
const firstMethod = config.payment_methods[0];
setSelectedMethod(firstMethod.method_id);
if (firstMethod.sub_options && firstMethod.sub_options.length >= 1) {
setSelectedSubOption(firstMethod.sub_options[0].id);
} else {
setSelectedSubOption(null);
}
}
}, [config, allPeriods, visibleTariffs, selectedTariffId, selectedPeriodDays, selectedMethod]);
// When period changes, auto-select first visible tariff if current is hidden
useEffect(() => {
if (!visibleTariffs.length) return;
const currentVisible = visibleTariffs.find((tariff) => tariff.id === selectedTariffId);
if (!currentVisible) {
setSelectedTariffId(visibleTariffs[0].id);
}
}, [visibleTariffs, selectedTariffId]);
// Derived data
const selectedTariff = useMemo(
() => config?.tariffs.find((tr) => tr.id === selectedTariffId),
[config?.tariffs, selectedTariffId],
);
const selectedPeriod = useMemo(
() => selectedTariff?.periods.find((p) => p.days === selectedPeriodDays),
[selectedTariff, selectedPeriodDays],
);
const currentPrice = selectedPeriod?.price_kopeks ?? 0;
const insufficientBalance =
paymentMode === 'balance' && config != null && config.balance_kopeks < currentPrice;
// Validation
const canSubmit = useMemo(() => {
if (!selectedTariffId || !selectedPeriodDays) return false;
if (!isValidContact(recipientValue)) return false;
if (paymentMode === 'gateway' && !selectedMethod) return false;
if (insufficientBalance) return false;
return true;
}, [
selectedTariffId,
selectedPeriodDays,
recipientValue,
paymentMode,
selectedMethod,
insufficientBalance,
]);
// Purchase mutation
const purchaseMutation = useMutation({
mutationFn: (data: GiftPurchaseRequest) => giftApi.createPurchase(data),
onSuccess: (result) => {
if (result.payment_url) {
window.location.href = result.payment_url;
} else {
// Balance mode - show success
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['gift-config'] });
const params = new URLSearchParams({ token: result.purchase_token, mode: 'balance' });
if (result.warning) {
params.set('warning', result.warning);
}
navigate('/gift/result?' + params.toString());
}
},
onError: (err) => {
const msg = getApiErrorMessage(
err,
t('gift.failedDesc', 'Something went wrong. Please try again.'),
);
setSubmitError(msg);
},
});
// Submit handler
const handleSubmit = () => {
if (!selectedTariffId || !selectedPeriodDays || !canSubmit || purchaseMutation.isPending)
return;
setSubmitError(null);
let paymentMethod: string | undefined;
if (paymentMode === 'gateway' && selectedMethod) {
paymentMethod = selectedMethod;
if (selectedSubOption) {
paymentMethod = `${paymentMethod}_${selectedSubOption}`;
}
}
const data: GiftPurchaseRequest = {
tariff_id: selectedTariffId,
period_days: selectedPeriodDays,
recipient_type: detectContactType(recipientValue),
recipient_value: recipientValue.trim(),
gift_message: giftMessage.trim() || undefined,
payment_mode: paymentMode,
payment_method: paymentMethod,
};
purchaseMutation.mutate(data);
};
// Balance label with current amount
const balanceLabel = useMemo(() => {
if (!config) return t('gift.fromBalance', 'From balance');
return `${t('gift.fromBalance', 'From balance')} (${formatPrice(config.balance_kopeks)})`;
}, [config, t]);
// Loading state
if (isLoading) {
return <LoadingSkeleton />;
}
// Error state
if (error || !config) {
const errMsg = getApiErrorMessage(error, t('gift.notFound', 'Gift configuration not found'));
return <ErrorState message={errMsg} />;
}
// Disabled state
if (!config.is_enabled) {
return <DisabledState />;
}
const showTariffCards = visibleTariffs.length > 1;
return (
<div className="min-h-dvh">
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-10 text-center"
>
<h1 className="text-3xl font-bold tracking-tight text-dark-50 sm:text-4xl">
{t('gift.title', 'Gift Subscription')}
</h1>
<p className="mt-3 text-base text-dark-300 sm:text-lg">
{t('gift.subtitle', 'Give the gift of secure internet to someone special')}
</p>
</motion.div>
{/* Two-column layout */}
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
{/* Left column */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="min-w-0 space-y-6"
>
{/* Period tabs */}
{allPeriods.length > 0 && (
<div>
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{t('gift.choosePeriod', 'Choose period')}
</h2>
<PeriodTabs
periods={allPeriods}
selectedDays={selectedPeriodDays ?? 0}
onSelect={setSelectedPeriodDays}
/>
</div>
)}
{/* Tariff cards */}
{showTariffCards && (
<div>
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{t('gift.chooseTariff', 'Choose tariff')}
</h2>
<div
role="radiogroup"
aria-label={t('gift.chooseTariff', 'Choose tariff')}
className="grid gap-3 sm:grid-cols-2"
>
{visibleTariffs.map((tariff) => {
const period = tariff.periods.find((p) => p.days === selectedPeriodDays);
return (
<TariffCard
key={tariff.id}
tariff={tariff}
isSelected={tariff.id === selectedTariffId}
selectedPeriod={period}
onSelect={() => setSelectedTariffId(tariff.id)}
/>
);
})}
</div>
</div>
)}
{/* Recipient */}
<div>
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{t('gift.recipientLabel', 'Recipient')}
</h2>
<RecipientSection
value={recipientValue}
onChange={(v) => {
setRecipientValue(v);
setSubmitError(null);
}}
/>
</div>
{/* Gift message */}
<div>
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{t('gift.giftMessageLabel', 'Message')}
</h2>
<GiftMessageSection value={giftMessage} onChange={setGiftMessage} />
</div>
{/* Payment mode toggle */}
<div>
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{t('gift.paymentMode', 'Payment method')}
</h2>
<PaymentModeToggle
mode={paymentMode}
onToggle={setPaymentMode}
balanceLabel={balanceLabel}
/>
</div>
{/* Payment method cards (gateway mode only) */}
<AnimatePresence mode="wait">
{paymentMode === 'gateway' && config.payment_methods.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div
role="radiogroup"
aria-label={t('gift.paymentMethod', 'Payment method')}
className="space-y-2"
>
{config.payment_methods.map((method) => (
<PaymentMethodCard
key={method.method_id}
method={method}
isSelected={method.method_id === selectedMethod}
selectedSubOption={
method.method_id === selectedMethod ? selectedSubOption : null
}
onSelect={() => {
setSelectedMethod(method.method_id);
if (method.sub_options && method.sub_options.length >= 1) {
setSelectedSubOption(method.sub_options[0].id);
} else {
setSelectedSubOption(null);
}
}}
onSelectSubOption={setSelectedSubOption}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Right column (sticky sidebar / bottom on mobile) */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="min-w-0 lg:sticky lg:top-8 lg:self-start"
>
<GiftSummaryCard
config={config}
selectedTariff={selectedTariff}
selectedPeriod={selectedPeriod}
currentPrice={currentPrice}
paymentMode={paymentMode}
isSubmitting={purchaseMutation.isPending}
canSubmit={canSubmit}
submitError={submitError}
insufficientBalance={insufficientBalance}
onSubmit={handleSubmit}
/>
</motion.div>
</div>
</div>
</div>
);
}

View File

@@ -485,10 +485,7 @@ export default function Login() {
</p>
</div>
) : (
<TelegramLoginButton
botUsername={botUsername}
referralCode={referralCode || undefined}
/>
<TelegramLoginButton referralCode={referralCode || undefined} />
)}
</div>

View File

@@ -0,0 +1,756 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { QRCodeSVG } from 'qrcode.react';
import { landingApi } from '../api/landings';
import { authApi } from '../api/auth';
import { useAuthStore } from '../store/auth';
import { copyToClipboard } from '../utils/clipboard';
import { Spinner } from '@/components/ui/Spinner';
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
import { cn } from '../lib/utils';
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
// ============================================================
// Sub-components
// ============================================================
function PendingState() {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<Spinner className="h-16 w-16 border-[3px]" />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('landing.awaitingPayment', 'Awaiting payment')}
</h1>
<p className="mt-2 text-sm text-dark-400">{t('landing.awaitingPaymentDesc')}</p>
</div>
</motion.div>
);
}
function CopyableField({ label, value }: { label: string; value: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
const handleCopy = useCallback(async () => {
try {
await copyToClipboard(value);
setCopied(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard write failed silently
}
}, [value]);
return (
<div className="flex items-center gap-2 rounded-xl bg-dark-800/50 px-4 py-3">
<div className="flex-1 text-left">
<p className="text-xs text-dark-400">{label}</p>
<p className="mt-0.5 font-mono text-sm text-dark-100">{value}</p>
</div>
<button
type="button"
onClick={handleCopy}
className={cn(
'shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
copied
? 'bg-success-500/10 text-success-500'
: 'bg-dark-700/50 text-dark-300 hover:bg-dark-600/50',
)}
>
{copied ? t('landing.copied', 'Copied!') : t('landing.copy', 'Copy')}
</button>
</div>
);
}
function CabinetCredentialsState({
cabinetEmail,
cabinetPassword,
autoLoginToken,
tariffName,
periodDays,
}: {
cabinetEmail: string;
cabinetPassword: string | null;
autoLoginToken: string | null;
tariffName: string | null;
periodDays: number | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState(false);
const handleGoToCabinet = useCallback(async () => {
if (!autoLoginToken) {
navigate('/login');
return;
}
setIsLoggingIn(true);
setLoginError(false);
try {
const response = await authApi.autoLogin(autoLoginToken);
setTokens(response.access_token, response.refresh_token);
setUser(response.user);
await checkAdminStatus();
navigate('/');
} catch {
setLoginError(true);
setIsLoggingIn(false);
}
}, [autoLoginToken, navigate, setTokens, setUser, checkAdminStatus]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
{/* Title */}
<div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.cabinetReady')}</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('landing.daysAccess')}
</p>
)}
</div>
{/* Credentials */}
<div className="w-full space-y-3">
<CopyableField label={t('landing.cabinetEmail')} value={cabinetEmail} />
{cabinetPassword && (
<CopyableField label={t('landing.cabinetPassword')} value={cabinetPassword} />
)}
{cabinetPassword && <p className="text-xs text-dark-400">{t('landing.saveCredentials')}</p>}
{!cabinetPassword && (
<p className="text-xs text-dark-400">{t('landing.credentialsSentToEmail')}</p>
)}
</div>
{/* Go to Cabinet button */}
<button
type="button"
onClick={handleGoToCabinet}
disabled={isLoggingIn}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-medium text-white transition-colors',
isLoggingIn ? 'cursor-not-allowed bg-accent-500/50' : 'bg-accent-500 hover:bg-accent-400',
)}
>
{isLoggingIn ? (
<>
<Spinner className="h-4 w-4" />
{t('landing.autoLoginProcessing')}
</>
) : (
t('landing.goToCabinet')
)}
</button>
{loginError && <p className="text-xs text-error-400">{t('landing.autoLoginFailed')}</p>}
</motion.div>
);
}
function SuccessState({
subscriptionUrl,
cryptoLink,
contactValue,
recipientContactValue,
tariffName,
periodDays,
isGift,
giftMessage,
recipientInBot,
botLink,
contactType,
}: {
subscriptionUrl: string | null;
cryptoLink: string | null;
contactValue: string | null;
recipientContactValue: string | null;
tariffName: string | null;
periodDays: number | null;
isGift: boolean;
giftMessage: string | null;
recipientInBot: boolean | null;
botLink: string | null;
contactType: string | null;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
};
}, []);
const handleCopy = useCallback(async () => {
const url = subscriptionUrl ?? cryptoLink;
if (!url) return;
try {
await copyToClipboard(url);
setCopied(true);
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard write failed silently
}
}, [subscriptionUrl, cryptoLink]);
const displayUrl = subscriptionUrl ?? cryptoLink;
const displayContact = isGift ? recipientContactValue : contactValue;
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
{/* Title */}
<div>
<h1 className="text-xl font-bold text-dark-50">
{isGift ? t('landing.giftSentSuccess') : t('landing.purchaseSuccess')}
</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('landing.daysAccess')}
</p>
)}
{isGift && contactType === 'telegram' && recipientInBot === true && (
<p className="mt-2 text-sm text-dark-400">{t('landing.giftTelegramSent')}</p>
)}
{isGift && contactType === 'telegram' && recipientInBot !== true && (
<p className="mt-2 text-sm text-dark-400">{t('landing.giftTelegramNotInBot')}</p>
)}
{!(isGift && contactType === 'telegram') && displayContact && (
<p className="mt-2 text-sm text-dark-400">
{isGift
? t('landing.giftSentTo', { contact: displayContact })
: t('landing.keySentTo', { contact: displayContact })}
</p>
)}
{isGift && giftMessage && (
<p className="mt-2 text-sm italic text-dark-400">
{t('landing.giftMessage')}: {giftMessage}
</p>
)}
</div>
{/* Bot link for telegram gifts where recipient is not in bot */}
{isGift && contactType === 'telegram' && recipientInBot !== true && botLink && (
<a
href={botLink}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('landing.openBot')}
</a>
)}
{/* QR Code */}
{displayUrl && (
<div className="space-y-4">
<div className="rounded-2xl bg-white p-5">
<QRCodeSVG
value={displayUrl}
size={200}
level="M"
includeMargin={false}
className="h-[200px] w-[200px]"
/>
</div>
{/* Copy button */}
<button
type="button"
onClick={handleCopy}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition-all duration-200',
copied
? 'bg-success-500/10 text-success-500'
: 'bg-dark-800/50 text-dark-200 hover:bg-dark-700/50',
)}
>
{copied ? (
<>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{t('landing.copied', 'Copied!')}
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
{t('landing.copyLink', 'Copy link')}
</>
)}
</button>
</div>
)}
</motion.div>
);
}
function PendingActivationState({
tariffName,
periodDays,
giftMessage,
isGift,
isActivating,
onActivate,
autoLoginToken,
}: {
tariffName: string | null;
periodDays: number | null;
giftMessage: string | null;
isGift: boolean;
isActivating: boolean;
onActivate: () => void;
autoLoginToken: string | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
const [isLoggingIn, setIsLoggingIn] = useState(false);
const handleGoToCabinet = useCallback(async () => {
if (!autoLoginToken) {
navigate('/login');
return;
}
setIsLoggingIn(true);
try {
const response = await authApi.autoLogin(autoLoginToken);
setTokens(response.access_token, response.refresh_token);
setUser(response.user);
await checkAdminStatus();
navigate('/');
} catch {
setIsLoggingIn(false);
navigate('/login');
}
}, [autoLoginToken, navigate, setTokens, setUser, checkAdminStatus]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
{/* Warning icon */}
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-warning-500/10">
<svg
className="h-10 w-10 text-warning-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.pendingActivation')}</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('landing.daysAccess')}
</p>
)}
<p className="mt-2 text-sm text-dark-400">{t('landing.pendingActivationDesc')}</p>
{isGift && giftMessage && (
<p className="mt-2 text-sm italic text-dark-400">
{t('landing.giftMessage')}: {giftMessage}
</p>
)}
</div>
<div className="flex w-full flex-col gap-3">
<button
type="button"
onClick={onActivate}
disabled={isActivating}
className={cn(
'flex items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-medium text-white transition-colors',
isActivating
? 'cursor-not-allowed bg-accent-500/50'
: 'bg-accent-500 hover:bg-accent-400',
)}
>
{isActivating ? (
<>
<Spinner className="h-4 w-4" />
{t('landing.activating')}
</>
) : (
t('landing.activateNow')
)}
</button>
{autoLoginToken && (
<button
type="button"
onClick={handleGoToCabinet}
disabled={isLoggingIn}
className={cn(
'flex items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-medium transition-colors',
isLoggingIn
? 'cursor-not-allowed bg-dark-800/30 text-dark-400'
: 'bg-dark-800/50 text-dark-200 hover:bg-dark-700/50',
)}
>
{isLoggingIn ? (
<>
<Spinner className="h-4 w-4" />
{t('landing.autoLoginProcessing')}
</>
) : (
t('landing.goToCabinet')
)}
</button>
)}
</div>
</motion.div>
);
}
function GiftPendingActivationState({
tariffName,
periodDays,
recipientContactValue,
giftMessage,
recipientInBot,
botLink,
contactType,
}: {
tariffName: string | null;
periodDays: number | null;
recipientContactValue: string | null;
giftMessage: string | null;
recipientInBot: boolean | null;
botLink: string | null;
contactType: string | null;
}) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.giftSentSuccess')}</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('landing.daysAccess')}
</p>
)}
{contactType === 'telegram' && recipientInBot === true && (
<p className="mt-2 text-sm text-dark-400">{t('landing.giftTelegramPendingSent')}</p>
)}
{contactType === 'telegram' && recipientInBot !== true && (
<p className="mt-2 text-sm text-dark-400">{t('landing.giftTelegramPendingNotInBot')}</p>
)}
{contactType !== 'telegram' && (
<p className="mt-2 text-sm text-dark-400">{t('landing.giftPendingActivationDesc')}</p>
)}
{contactType !== 'telegram' && recipientContactValue && (
<p className="mt-2 text-sm text-dark-400">
{t('landing.giftSentTo', { contact: recipientContactValue })}
</p>
)}
{giftMessage && (
<p className="mt-2 text-sm italic text-dark-400">
{t('landing.giftMessage')}: {giftMessage}
</p>
)}
</div>
{/* Bot link for telegram gifts where recipient is not in bot */}
{contactType === 'telegram' && recipientInBot !== true && botLink && (
<a
href={botLink}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('landing.openBot')}
</a>
)}
</motion.div>
);
}
function FailedState() {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCrossmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.purchaseFailed')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('landing.purchaseFailedDesc')}</p>
</div>
</motion.div>
);
}
function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-dark-800/50">
<svg
className="h-10 w-10 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('landing.pollTimedOut', 'Taking longer than expected')}
</h1>
<p className="mt-2 text-sm text-dark-400">
{t(
'landing.pollTimedOutDesc',
'Payment processing is taking longer than usual. You can try checking again.',
)}
</p>
</div>
<button
type="button"
onClick={onRetry}
className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('common.retry', 'Retry')}
</button>
</motion.div>
);
}
// ============================================================
// Main Component
// ============================================================
export default function PurchaseSuccess() {
const { t } = useTranslation();
const { token } = useParams<{ token: string }>();
const [searchParams] = useSearchParams();
const isActivateHint = searchParams.get('activate') === '1';
const pollStart = useRef(Date.now());
const [pollTimedOut, setPollTimedOut] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [activationError, setActivationError] = useState(false);
const activatingRef = useRef(false);
// Referrer-Policy: prevent leaking payment token via referer header
useEffect(() => {
const meta = document.createElement('meta');
meta.name = 'referrer';
meta.content = 'no-referrer';
document.head.appendChild(meta);
return () => {
document.head.removeChild(meta);
};
}, []);
const queryClient = useQueryClient();
const {
data: purchaseStatus,
isError,
refetch,
} = useQuery({
queryKey: ['purchase-status', token],
queryFn: () => landingApi.getPurchaseStatus(token!),
enabled: !!token && !pollTimedOut,
refetchInterval: (query) => {
const currentStatus = query.state.data?.status;
if (currentStatus === 'pending' || currentStatus === 'paid') {
if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true);
return false;
}
return 3_000;
}
return false;
},
retry: 2,
});
const handleRetryPoll = useCallback(() => {
pollStart.current = Date.now();
setPollTimedOut(false);
refetch();
}, [refetch]);
const handleActivate = useCallback(async () => {
if (!token || activatingRef.current) return;
activatingRef.current = true;
setIsActivating(true);
setActivationError(false);
try {
const result = await landingApi.activatePurchase(token);
queryClient.setQueryData(['purchase-status', token], result);
} catch {
setActivationError(true);
} finally {
activatingRef.current = false;
setIsActivating(false);
}
}, [token, queryClient]);
const isSuccess = purchaseStatus?.status === 'delivered';
const isPendingActivation = purchaseStatus?.status === 'pending_activation';
const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired';
// Gift pending activation → buyer sees "gift sent" message, not the activate button.
// Recipient arrives via email link with ?activate=1 and sees the activate button instead.
const isGiftPendingActivation = isPendingActivation && purchaseStatus?.is_gift && !isActivateHint;
// Email self-purchase delivered → show cabinet credentials
const isEmailSelfPurchase =
isSuccess &&
purchaseStatus.contact_type === 'email' &&
!purchaseStatus.is_gift &&
purchaseStatus.cabinet_email;
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
<div
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
aria-live="polite"
aria-atomic="true"
>
{isError ? (
<FailedState />
) : isEmailSelfPurchase ? (
<CabinetCredentialsState
cabinetEmail={purchaseStatus.cabinet_email!}
cabinetPassword={purchaseStatus.cabinet_password}
autoLoginToken={purchaseStatus.auto_login_token}
tariffName={purchaseStatus.tariff_name}
periodDays={purchaseStatus.period_days}
/>
) : isSuccess ? (
<SuccessState
subscriptionUrl={purchaseStatus.subscription_url}
cryptoLink={purchaseStatus.subscription_crypto_link}
contactValue={purchaseStatus.contact_value}
recipientContactValue={purchaseStatus.recipient_contact_value}
tariffName={purchaseStatus.tariff_name}
periodDays={purchaseStatus.period_days}
isGift={purchaseStatus.is_gift}
giftMessage={purchaseStatus.gift_message}
recipientInBot={purchaseStatus.recipient_in_bot}
botLink={purchaseStatus.bot_link}
contactType={purchaseStatus.contact_type}
/>
) : isGiftPendingActivation ? (
<GiftPendingActivationState
tariffName={purchaseStatus.tariff_name}
periodDays={purchaseStatus.period_days}
recipientContactValue={purchaseStatus.recipient_contact_value}
giftMessage={purchaseStatus.gift_message}
recipientInBot={purchaseStatus.recipient_in_bot}
botLink={purchaseStatus.bot_link}
contactType={purchaseStatus.contact_type}
/>
) : isPendingActivation ? (
<div className="space-y-4">
<PendingActivationState
tariffName={purchaseStatus.tariff_name}
periodDays={purchaseStatus.period_days}
giftMessage={purchaseStatus.gift_message}
isGift={purchaseStatus.is_gift}
isActivating={isActivating}
onActivate={handleActivate}
autoLoginToken={purchaseStatus.auto_login_token}
/>
{activationError && (
<p className="text-center text-sm text-error-400">{t('landing.activationFailed')}</p>
)}
</div>
) : isFailed ? (
<FailedState />
) : pollTimedOut ? (
<PollTimedOutState onRetry={handleRetryPoll} />
) : (
<PendingState />
)}
</div>
</div>
);
}

1126
src/pages/QuickPurchase.tsx Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,8 @@ export default function SubscriptionPurchase() {
const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({
queryKey: ['purchase-options'],
queryFn: subscriptionApi.getPurchaseOptions,
staleTime: 0,
refetchOnMount: 'always',
});
// Active promo discount
@@ -772,7 +774,9 @@ export default function SubscriptionPurchase() {
/>
</svg>
<span className="text-dark-300">
{t('subscription.devices', { count: tariff.device_limit })}
{tariff.device_limit === 0
? '∞'
: t('subscription.devices', { count: tariff.device_limit })}
</span>
</div>
{tariff.traffic_reset_mode &&
@@ -959,7 +963,7 @@ export default function SubscriptionPurchase() {
<div>
<span className="text-dark-500">{t('subscription.devices')}:</span>
<span className="ml-2 text-dark-200">
{selectedTariff.device_limit}
{selectedTariff.device_limit === 0 ? '∞' : selectedTariff.device_limit}
{selectedTariff.extra_devices_count > 0 && (
<span className="ml-1 text-xs text-accent-400">
(+{selectedTariff.extra_devices_count})
@@ -1119,6 +1123,33 @@ export default function SubscriptionPurchase() {
</div>
)}
{/* No periods available fallback */}
{selectedTariff.periods.length === 0 &&
!useCustomDays &&
!(
selectedTariff.custom_days_enabled &&
(selectedTariff.price_per_day_kopeks ?? 0) > 0
) && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-center">
<div className="mb-2 text-sm font-medium text-amber-400">
{t('subscription.noPeriodsAvailable')}
</div>
<div className="text-xs text-dark-400">
{t('subscription.noPeriodsAvailableHint')}
</div>
<button
onClick={() => {
setShowTariffPurchase(false);
setSelectedTariff(null);
setSelectedTariffPeriod(null);
}}
className="btn-secondary mt-3 px-4 py-2 text-sm"
>
{t('subscription.chooseDifferentTariff')}
</button>
</div>
)}
{/* Custom days option */}
{selectedTariff.custom_days_enabled &&
(selectedTariff.price_per_day_kopeks ?? 0) > 0 && (

View File

@@ -12,6 +12,7 @@ import { useHaptic, usePlatform } from '@/platform';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import type { PaymentMethod } from '../types';
import BentoCard from '../components/ui/BentoCard';
import { saveTopUpPendingInfo } from '../utils/topUpStorage';
// Icons
const StarIcon = () => (
@@ -202,6 +203,20 @@ export default function TopUpAmount() {
const redirectUrl = data.payment_url || data.invoice_url;
if (redirectUrl) {
setPaymentUrl(redirectUrl);
// Save payment info for the result page
if (method && data.payment_id) {
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
const displayName =
t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name;
saveTopUpPendingInfo({
amount_kopeks: data.amount_kopeks,
method_id: method.id,
method_name: displayName,
payment_id: data.payment_id,
created_at: Date.now(),
});
}
}
},
onError: (err: unknown) => {
@@ -296,8 +311,8 @@ export default function TopUpAmount() {
await navigator.clipboard.writeText(paymentUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.warn('Failed to copy:', e);
} catch {
// Clipboard write failed silently
}
};

348
src/pages/TopUpResult.tsx Normal file
View File

@@ -0,0 +1,348 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { balanceApi } from '../api/balance';
import { useAuthStore } from '../store/auth';
import { useCurrency } from '../hooks/useCurrency';
import { useHaptic } from '@/platform';
import { Spinner } from '@/components/ui/Spinner';
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
import { loadTopUpPendingInfo, clearTopUpPendingInfo } from '../utils/topUpStorage';
import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus';
// ── Constants ────────────────────────────────────────────────
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
const POLL_INTERVAL_MS = 3_000;
// ── Sub-components ───────────────────────────────────────────
function AmountDisplay({ amountKopeks, label }: { amountKopeks: number; label: string }) {
const { formatAmount, currencySymbol } = useCurrency();
const amountRubles = amountKopeks / 100;
return (
<div className="mt-4 rounded-xl bg-dark-800/50 px-6 py-4">
<p className="text-xs text-dark-400">{label}</p>
<p className="mt-1 text-2xl font-bold text-dark-50">
{formatAmount(amountRubles)} <span className="text-lg text-dark-400">{currencySymbol}</span>
</p>
</div>
);
}
function PendingState({ amountKopeks }: { amountKopeks: number | null }) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<Spinner className="h-16 w-16 border-[3px]" />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('balance.topUpResult.awaitingPayment')}
</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.awaitingPaymentDesc')}</p>
</div>
{amountKopeks != null && amountKopeks > 0 && (
<AmountDisplay amountKopeks={amountKopeks} label={t('balance.topUpResult.topUpAmount')} />
)}
</motion.div>
);
}
function SuccessState({ amountKopeks }: { amountKopeks: number | null }) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleGoToBalance = useCallback(() => {
navigate('/balance', { replace: true });
}, [navigate]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('balance.topUpResult.success')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.successDesc')}</p>
</div>
{amountKopeks != null && amountKopeks > 0 && (
<AmountDisplay amountKopeks={amountKopeks} label={t('balance.topUpResult.topUpAmount')} />
)}
<button
type="button"
onClick={handleGoToBalance}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('balance.topUpResult.goToBalance')}
</button>
</motion.div>
);
}
function FailedState({ amountKopeks }: { amountKopeks: number | null }) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleTryAgain = useCallback(() => {
navigate('/balance', { replace: true });
}, [navigate]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCrossmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('balance.topUpResult.failed')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.failedDesc')}</p>
</div>
{amountKopeks != null && amountKopeks > 0 && (
<AmountDisplay amountKopeks={amountKopeks} label={t('balance.topUpResult.topUpAmount')} />
)}
<button
type="button"
onClick={handleTryAgain}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-dark-800/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-700/50"
>
{t('balance.topUpResult.tryAgain')}
</button>
</motion.div>
);
}
function TimeoutState({ onRetry, onGoBack }: { onRetry: () => void; onGoBack: () => void }) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-dark-800/50">
<svg
className="h-10 w-10 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">{t('balance.topUpResult.timeout')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.timeoutDesc')}</p>
</div>
<div className="flex w-full flex-col gap-3">
<button
type="button"
onClick={onRetry}
className="w-full rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('common.retry')}
</button>
<button
type="button"
onClick={onGoBack}
className="w-full rounded-xl bg-dark-800/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-700/50"
>
{t('balance.topUpResult.goToBalance')}
</button>
</div>
</motion.div>
);
}
// ── Main Component ───────────────────────────────────────────
export default function TopUpResult() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const refreshUser = useAuthStore((state) => state.refreshUser);
const haptic = useHaptic();
const pollStart = useRef(Date.now());
const [pollTimedOut, setPollTimedOut] = useState(false);
const hapticFiredRef = useRef(false);
const cleanedUpRef = useRef(false);
// Load saved payment info from sessionStorage (once on mount)
const [pendingInfo] = useState(() => loadTopUpPendingInfo());
// Fallback: read method from query params (for external browser redirects where sessionStorage is unavailable)
const methodFromUrl = searchParams.get('method');
// Detect if user arrived via redirect with success param (no polling needed)
const redirectStatus = searchParams.get('status') || searchParams.get('payment');
const isRedirectSuccess = redirectStatus
? isPaidStatus(redirectStatus)
: searchParams.get('success') === 'true';
const isRedirectFailed = redirectStatus ? isFailedStatus(redirectStatus) : false;
// Determine if we can poll by specific payment_id (need method + numeric payment_id)
const parsedPaymentId = pendingInfo?.payment_id ? parseInt(pendingInfo.payment_id, 10) : NaN;
const canPollById =
!!(pendingInfo?.method_id && !isNaN(parsedPaymentId)) &&
!isRedirectSuccess &&
!isRedirectFailed;
// Fallback: poll by method via /latest endpoint when no sessionStorage data
const canPollByMethod =
!canPollById && !!methodFromUrl && !isRedirectSuccess && !isRedirectFailed;
// Poll payment status by specific ID (primary path — sessionStorage available)
const { data: paymentStatus, refetch } = useQuery({
queryKey: ['topup-status', pendingInfo?.method_id, parsedPaymentId],
queryFn: () => balanceApi.getPendingPayment(pendingInfo!.method_id, parsedPaymentId),
enabled: canPollById && !pollTimedOut,
refetchInterval: (query) => {
const payment = query.state.data;
if (!payment) return POLL_INTERVAL_MS;
if (payment.is_paid || isPaidStatus(payment.status) || isFailedStatus(payment.status)) {
return false;
}
if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true);
return false;
}
return POLL_INTERVAL_MS;
},
retry: 2,
});
// Poll payment status by method latest (fallback — external browser, no sessionStorage)
const { data: latestPayment, refetch: refetchLatest } = useQuery({
queryKey: ['topup-status-latest', methodFromUrl],
queryFn: () => balanceApi.getLatestPayment(methodFromUrl!),
enabled: canPollByMethod && !pollTimedOut,
refetchInterval: (query) => {
const payment = query.state.data;
if (!payment) return POLL_INTERVAL_MS;
if (payment.is_paid || isPaidStatus(payment.status) || isFailedStatus(payment.status)) {
return false;
}
if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true);
return false;
}
return POLL_INTERVAL_MS;
},
retry: 2,
});
// Merge both polling sources
const effectivePayment = paymentStatus ?? latestPayment;
const handleRetryPoll = useCallback(() => {
pollStart.current = Date.now();
setPollTimedOut(false);
if (canPollById) {
refetch();
} else {
refetchLatest();
}
}, [canPollById, setPollTimedOut, refetch, refetchLatest]);
const handleGoBack = useCallback(() => {
clearTopUpPendingInfo();
navigate('/balance', { replace: true });
}, [navigate]);
// Redirect to balance if absolutely no data source available
useEffect(() => {
if (!pendingInfo && !redirectStatus && !methodFromUrl) {
navigate('/balance', { replace: true });
}
}, [pendingInfo, redirectStatus, methodFromUrl, navigate]);
// Determine current visual state
const amountKopeks = effectivePayment?.amount_kopeks ?? pendingInfo?.amount_kopeks ?? null;
const resolvedPaid =
isRedirectSuccess ||
effectivePayment?.is_paid ||
(effectivePayment && isPaidStatus(effectivePayment.status));
const resolvedFailed =
isRedirectFailed || (effectivePayment && isFailedStatus(effectivePayment.status));
// Clean up sessionStorage and invalidate queries when payment resolves
useEffect(() => {
if (cleanedUpRef.current) return;
if (resolvedPaid) {
cleanedUpRef.current = true;
clearTopUpPendingInfo();
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
} else if (resolvedFailed) {
cleanedUpRef.current = true;
clearTopUpPendingInfo();
}
}, [resolvedPaid, resolvedFailed, queryClient, refreshUser]);
// Haptic feedback on status resolution (fire once)
useEffect(() => {
if (hapticFiredRef.current) return;
if (resolvedPaid) {
hapticFiredRef.current = true;
haptic.notification('success');
} else if (resolvedFailed) {
hapticFiredRef.current = true;
haptic.notification('error');
}
}, [resolvedPaid, resolvedFailed, haptic]);
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
<div
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
aria-live="polite"
aria-atomic="true"
>
{resolvedPaid ? (
<SuccessState amountKopeks={amountKopeks} />
) : resolvedFailed ? (
<FailedState amountKopeks={amountKopeks} />
) : pollTimedOut ? (
<TimeoutState onRetry={handleRetryPoll} onGoBack={handleGoBack} />
) : (
<PendingState amountKopeks={amountKopeks} />
)}
</div>
</div>
);
}