Files
bedolaga-cabinet/src/pages/MergeAccounts.tsx
Fringg aa26059e00 fix: second round review fixes for merge UI
- Fix false success toast when response.success is false (CRITICAL)
- Remove mergeToken! non-null assertion in queryFn
- Add early return for missing mergeToken param
- Zero-pad minutes in formatCountdown (MM:SS format)
- Clamp negative seconds in formatCountdown
- Block all unlink buttons while any unlink mutation is pending
- Clear OAuth state only after validation succeeds (not before)
- Split getAndClearLinkOAuthState into read + clear functions
2026-03-04 07:56:19 +03:00

595 lines
19 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { authApi } from '../api/auth';
import { useAuthStore } from '../store/auth';
import { useToast } from '../components/Toast';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import { cn } from '@/lib/utils';
import OAuthProviderIcon from '../components/OAuthProviderIcon';
import type { MergeAccountPreview } from '../types';
// -- Icons --
function WarningIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
);
}
function ClockIcon({ className = 'h-4 w-4' }: { className?: string }) {
return (
<svg
className={className}
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>
);
}
function CheckCircleIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
function TelegramIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69.01-.03.01-.14-.07-.2-.08-.06-.19-.04-.28-.02-.12.03-2.02 1.28-5.69 3.77-.54.37-1.03.55-1.47.54-.48-.01-1.41-.27-2.1-.5-.85-.28-1.52-.43-1.46-.91.03-.25.38-.51 1.05-.78 4.12-1.79 6.87-2.97 8.26-3.54 3.93-1.62 4.75-1.9 5.28-1.91.12 0 .37.03.54.17.14.12.18.28.2.46-.01.06.01.24 0 .37z"
fill="#29B6F6"
/>
</svg>
);
}
function EmailIcon({ className = 'h-5 w-5' }: { className?: string }) {
return (
<svg
className={className}
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>
);
}
// -- Helpers --
function ProviderBadgeIcon({ provider }: { provider: string }) {
switch (provider) {
case 'telegram':
return <TelegramIcon className="h-4 w-4" />;
case 'email':
return <EmailIcon className="h-4 w-4 text-dark-300" />;
default:
return <OAuthProviderIcon provider={provider} className="h-4 w-4" />;
}
}
function formatCountdown(seconds: number): string {
const clamped = Math.max(0, seconds);
const min = Math.floor(clamped / 60);
const sec = clamped % 60;
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
try {
return new Date(dateStr).toLocaleDateString(undefined, {
day: 'numeric',
month: 'long',
year: 'numeric',
});
} catch {
return dateStr;
}
}
function formatBalance(kopeks: number): string {
return Math.floor(kopeks / 100).toLocaleString();
}
// -- Radio Indicator --
function RadioIndicator({ selected }: { selected: boolean }) {
return (
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
selected ? 'border-accent-500 bg-accent-500' : 'border-dark-500',
)}
>
{selected && <div className="h-2 w-2 rounded-full bg-white" />}
</div>
);
}
// -- Account Card --
interface AccountCardProps {
account: MergeAccountPreview;
label: string;
isSelected: boolean;
onSelect: () => void;
showRadio: boolean;
}
function AccountCard({ account, label, isSelected, onSelect, showRadio }: AccountCardProps) {
const { t } = useTranslation();
return (
<Card className={cn('transition-colors', isSelected && 'border-accent-500/50')}>
<CardHeader>
<CardTitle>{label}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Auth methods */}
<div>
<span className="text-sm text-dark-400">{t('merge.authMethods')}:</span>
<div className="mt-1.5 flex flex-wrap gap-2">
{account.auth_methods.map((method) => (
<span
key={method}
className="inline-flex items-center gap-1.5 rounded-md bg-dark-800 px-2.5 py-1 text-xs text-dark-200"
>
<ProviderBadgeIcon provider={method} />
{t(`profile.accounts.providers.${method}`)}
</span>
))}
</div>
</div>
{/* Subscription */}
{account.subscription ? (
<div className="space-y-1">
<span className="text-sm text-dark-400">{t('merge.subscription')}:</span>
<p className="font-medium text-dark-100">
{account.subscription.tariff_name ?? account.subscription.status}
</p>
{account.subscription.end_date && (
<p className="text-sm text-dark-400">
{t('merge.until', { date: formatDate(account.subscription.end_date) })}
</p>
)}
<p className="text-sm text-dark-400">
{t('merge.traffic')}: {account.subscription.traffic_limit_gb} GB, {t('merge.devices')}
: {account.subscription.device_limit}
</p>
</div>
) : (
<div>
<span className="text-sm text-dark-400">{t('merge.subscription')}:</span>
<p className="text-sm text-dark-500">{t('merge.noSubscription')}</p>
</div>
)}
{/* Balance */}
<div className="flex items-baseline gap-1.5">
<span className="text-sm text-dark-400">{t('merge.balance')}:</span>
<span className="font-medium text-dark-100">
{formatBalance(account.balance_kopeks)} &#8381;
</span>
</div>
{/* Radio selection */}
{showRadio && account.subscription && (
<button
type="button"
onClick={onSelect}
className="mt-2 flex w-full items-center gap-2.5 rounded-lg bg-dark-800/50 px-3 py-2.5 text-left transition-colors hover:bg-dark-800"
>
<RadioIndicator selected={isSelected} />
<span className="text-sm text-dark-200">{t('merge.keepThisSubscription')}</span>
</button>
)}
</CardContent>
</Card>
);
}
// -- Loading Skeleton --
function LoadingSkeleton() {
return (
<motion.div
className="space-y-6"
variants={staggerContainer}
initial="initial"
animate="animate"
>
<motion.div variants={staggerItem}>
<div className="flex items-center gap-3">
<div className="h-7 w-7 animate-pulse rounded bg-dark-700" />
<div className="h-7 w-48 animate-pulse rounded bg-dark-700" />
</div>
</motion.div>
{Array.from({ length: 3 }).map((_, i) => (
<motion.div key={i} variants={staggerItem}>
<Card>
<div className="space-y-4">
<div className="h-5 w-40 animate-pulse rounded bg-dark-700" />
<div className="h-4 w-64 animate-pulse rounded bg-dark-700" />
<div className="h-4 w-48 animate-pulse rounded bg-dark-700" />
<div className="h-4 w-32 animate-pulse rounded bg-dark-700" />
</div>
</Card>
</motion.div>
))}
<motion.div variants={staggerItem}>
<div className="h-12 w-full animate-pulse rounded-xl bg-dark-700" />
</motion.div>
<motion.div variants={staggerItem} className="flex justify-center">
<div className="h-4 w-32 animate-pulse rounded bg-dark-700" />
</motion.div>
</motion.div>
);
}
// -- Expired State --
function ExpiredState() {
const { t } = useTranslation();
return (
<motion.div
className="flex min-h-[60vh] flex-col items-center justify-center space-y-6 px-4"
variants={staggerContainer}
initial="initial"
animate="animate"
>
<motion.div variants={staggerItem}>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-warning-500/20">
<ClockIcon className="h-8 w-8 text-warning-400" />
</div>
</motion.div>
<motion.div variants={staggerItem} className="text-center">
<p className="text-lg font-medium text-dark-100">{t('merge.expired')}</p>
</motion.div>
<motion.div variants={staggerItem}>
<Link
to="/profile/accounts"
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('profile.accounts.goToAccounts')}
</Link>
</motion.div>
</motion.div>
);
}
// -- Error State --
function ErrorState() {
const { t } = useTranslation();
return (
<motion.div
className="flex min-h-[60vh] flex-col items-center justify-center space-y-6 px-4"
variants={staggerContainer}
initial="initial"
animate="animate"
>
<motion.div variants={staggerItem}>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-error-500/20">
<WarningIcon className="h-8 w-8 text-error-400" />
</div>
</motion.div>
<motion.div variants={staggerItem} className="text-center">
<p className="text-lg font-medium text-dark-100">{t('merge.error')}</p>
</motion.div>
<motion.div variants={staggerItem}>
<Link
to="/profile/accounts"
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('profile.accounts.goToAccounts')}
</Link>
</motion.div>
</motion.div>
);
}
// -- Main Component --
export default function MergeAccounts() {
const { t } = useTranslation();
const { mergeToken } = useParams<{ mergeToken: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [expiresIn, setExpiresIn] = useState(0);
const [isExpired, setIsExpired] = useState(false);
// Fetch merge preview (no auth required)
const { data, isLoading, error } = useQuery({
queryKey: ['merge-preview', mergeToken],
queryFn: () => {
if (!mergeToken) return Promise.reject(new Error('Missing merge token'));
return authApi.getMergePreview(mergeToken);
},
enabled: !!mergeToken,
retry: false,
staleTime: Infinity,
refetchOnWindowFocus: false,
});
// Auto-select subscription when data loads
useEffect(() => {
if (!data) return;
const primaryHasSub = !!data.primary.subscription;
const secondaryHasSub = !!data.secondary.subscription;
if (primaryHasSub && !secondaryHasSub) {
setSelectedUserId(data.primary.id);
} else if (!primaryHasSub && secondaryHasSub) {
setSelectedUserId(data.secondary.id);
} else if (!primaryHasSub && !secondaryHasSub) {
// Neither has subscription — default to primary
setSelectedUserId(data.primary.id);
}
// If both have subs — null until user picks
}, [data]);
// Countdown timer
useEffect(() => {
if (!data) return;
setExpiresIn(data.expires_in_seconds);
const interval = setInterval(() => {
setExpiresIn((prev) => {
if (prev <= 1) {
clearInterval(interval);
setIsExpired(true);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [data]);
// Execute merge
const mergeMutation = useMutation({
mutationFn: () => {
if (!mergeToken || !selectedUserId) {
return Promise.reject(new Error('Missing merge token or user selection'));
}
return authApi.executeMerge(mergeToken, selectedUserId);
},
onSuccess: async (response) => {
if (!response.success) {
showToast({ type: 'error', message: t('merge.error') });
return;
}
if (response.access_token && response.refresh_token) {
const { setTokens, setUser, checkAdminStatus } = useAuthStore.getState();
setTokens(response.access_token, response.refresh_token);
if (response.user) {
setUser(response.user);
}
await checkAdminStatus();
}
showToast({ type: 'success', message: t('merge.success') });
navigate('/', { replace: true });
},
onError: () => {
showToast({
type: 'error',
message: t('merge.error'),
});
},
});
const handleMerge = () => {
if (!selectedUserId || mergeMutation.isPending || isExpired) return;
mergeMutation.mutate();
};
const handleCancel = () => {
navigate('/profile/accounts', { replace: true });
};
// Derived state
const bothHaveSubscriptions =
data && !!data.primary.subscription && !!data.secondary.subscription;
const canConfirm = selectedUserId !== null && !isExpired && !mergeMutation.isPending;
const combinedBalance = data ? data.primary.balance_kopeks + data.secondary.balance_kopeks : 0;
// Missing token param
if (!mergeToken) {
return <ErrorState />;
}
// Loading
if (isLoading) {
return <LoadingSkeleton />;
}
// Fetch error (404 = expired/invalid token)
if (error || !data) {
return <ErrorState />;
}
// Timer expired
if (isExpired) {
return <ExpiredState />;
}
return (
<motion.div
className="mx-auto max-w-lg space-y-6"
variants={staggerContainer}
initial="initial"
animate="animate"
>
{/* Header with warning */}
<motion.div variants={staggerItem}>
<Card className="border-warning-500/30 bg-warning-500/5">
<div className="flex items-start gap-3">
<WarningIcon className="mt-0.5 h-6 w-6 shrink-0 text-warning-400" />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('merge.title')}</h1>
<p className="mt-1 text-sm text-dark-400">{t('merge.description')}</p>
</div>
</div>
</Card>
</motion.div>
{/* Subscription choice prompt (when both have subs) */}
{bothHaveSubscriptions && !selectedUserId && (
<motion.div variants={staggerItem}>
<div className="rounded-xl border border-accent-500/30 bg-accent-500/10 px-4 py-3">
<p className="text-sm font-medium text-accent-400">{t('merge.chooseSubscription')}</p>
</div>
</motion.div>
)}
{/* Primary account card */}
<motion.div variants={staggerItem}>
<AccountCard
account={data.primary}
label={t('merge.currentAccount')}
isSelected={selectedUserId === data.primary.id}
onSelect={() => setSelectedUserId(data.primary.id)}
showRadio={!!bothHaveSubscriptions}
/>
</motion.div>
{/* Secondary account card */}
<motion.div variants={staggerItem}>
<AccountCard
account={data.secondary}
label={t('merge.foundAccount')}
isSelected={selectedUserId === data.secondary.id}
onSelect={() => setSelectedUserId(data.secondary.id)}
showRadio={!!bothHaveSubscriptions}
/>
</motion.div>
{/* After merge summary */}
<motion.div variants={staggerItem}>
<Card>
<CardHeader>
<CardTitle>{t('merge.afterMerge')}</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-3">
<li className="flex items-start gap-2.5">
<CheckCircleIcon className="mt-0.5 h-4 w-4 shrink-0 text-success-400" />
<span className="text-sm text-dark-200">{t('merge.allAuthMethodsMerged')}</span>
</li>
<li className="flex items-start gap-2.5">
<CheckCircleIcon className="mt-0.5 h-4 w-4 shrink-0 text-success-400" />
<span className="text-sm text-dark-200">
{t('merge.balanceSummed', { amount: formatBalance(combinedBalance) })}
</span>
</li>
{bothHaveSubscriptions && (
<li className="flex items-start gap-2.5">
<WarningIcon className="mt-0.5 h-4 w-4 shrink-0 text-warning-400" />
<span className="text-sm text-dark-200">
{t('merge.unselectedSubscriptionDeleted')}
</span>
</li>
)}
<li className="flex items-start gap-2.5">
<CheckCircleIcon className="mt-0.5 h-4 w-4 shrink-0 text-success-400" />
<span className="text-sm text-dark-200">{t('merge.historyPreserved')}</span>
</li>
</ul>
</CardContent>
</Card>
</motion.div>
{/* Confirm button */}
<motion.div variants={staggerItem}>
<Button
fullWidth
disabled={!canConfirm}
loading={mergeMutation.isPending}
onClick={handleMerge}
>
{mergeMutation.isPending ? t('merge.merging') : t('merge.confirm')}
</Button>
</motion.div>
{/* Cancel link */}
<motion.div variants={staggerItem} className="flex justify-center">
<button
type="button"
onClick={handleCancel}
className="text-sm text-dark-400 transition-colors hover:text-dark-200"
>
{t('merge.cancel')}
</button>
</motion.div>
{/* Countdown timer */}
<motion.div variants={staggerItem} className="flex items-center justify-center gap-1.5 pb-6">
<ClockIcon className="h-4 w-4 text-dark-500" />
<span className="text-sm text-dark-500">
{t('merge.expiresIn', { minutes: formatCountdown(expiresIn) })}
</span>
</motion.div>
</motion.div>
);
}