mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
Merge branch 'BEDOLAGA-DEV:main' into dev_nikita
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
1117
src/pages/AdminLandingEditor.tsx
Normal file
1117
src/pages/AdminLandingEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
747
src/pages/AdminLandingStats.tsx
Normal file
747
src/pages/AdminLandingStats.tsx
Normal 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">
|
||||
{' '}
|
||||
· {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
514
src/pages/AdminLandings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
82
src/pages/AutoLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 } } };
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function Connection() {
|
||||
|
||||
const handleOpenQR = useCallback(() => {
|
||||
navigate('/connection/qr', {
|
||||
replace: !isTelegramWebApp,
|
||||
state: {
|
||||
url: appConfig?.subscriptionUrl,
|
||||
hideLink: appConfig?.hideLink ?? false,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
457
src/pages/GiftResult.tsx
Normal 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">“{giftMessage}”</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>
|
||||
);
|
||||
}
|
||||
985
src/pages/GiftSubscription.tsx
Normal file
985
src/pages/GiftSubscription.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -485,10 +485,7 @@ export default function Login() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<TelegramLoginButton
|
||||
botUsername={botUsername}
|
||||
referralCode={referralCode || undefined}
|
||||
/>
|
||||
<TelegramLoginButton referralCode={referralCode || undefined} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
756
src/pages/PurchaseSuccess.tsx
Normal file
756
src/pages/PurchaseSuccess.tsx
Normal 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
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
@@ -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 && (
|
||||
|
||||
@@ -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
348
src/pages/TopUpResult.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user