feat: account linking and merge UI for cabinet

Add Connected Accounts page (link/unlink OAuth providers), Link OAuth
Callback handler, and Merge Accounts page with subscription comparison
and user choice. Includes API methods, TypeScript types, routes in
App.tsx, navigation from Profile, and i18n for all 4 locales (ru, en,
zh, fa). Merge page works without JWT auth (validated by merge token).
This commit is contained in:
Fringg
2026-03-04 07:25:45 +03:00
parent 8157ca5f02
commit 93f97d45be
12 changed files with 1253 additions and 2 deletions

View File

@@ -0,0 +1,205 @@
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { authApi } from '../api/auth';
import { useToast } from '../components/Toast';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import OAuthProviderIcon from '../components/OAuthProviderIcon';
import type { LinkedProvider } from '../types';
const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk'];
const isOAuthProvider = (provider: string): boolean => OAUTH_PROVIDERS.includes(provider);
// Icons for providers not covered by OAuthProviderIcon
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>
);
}
function ProviderIcon({ provider }: { provider: string }) {
switch (provider) {
case 'telegram':
return <TelegramIcon className="h-6 w-6" />;
case 'email':
return <EmailIcon className="h-6 w-6 text-dark-300" />;
default:
return <OAuthProviderIcon provider={provider} className="h-6 w-6" />;
}
}
function LoadingSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<div className="flex animate-pulse items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-6 w-6 rounded-full bg-dark-700" />
<div className="space-y-2">
<div className="h-4 w-24 rounded bg-dark-700" />
<div className="h-3 w-32 rounded bg-dark-700" />
</div>
</div>
<div className="h-8 w-20 rounded bg-dark-700" />
</div>
</Card>
))}
</div>
);
}
export default function ConnectedAccounts() {
const { t } = useTranslation();
const { showToast } = useToast();
const { data, isLoading, refetch } = useQuery({
queryKey: ['linked-providers'],
queryFn: () => authApi.getLinkedProviders(),
});
const unlinkMutation = useMutation({
mutationFn: (provider: string) => authApi.unlinkProvider(provider),
onSuccess: () => {
refetch();
showToast({
type: 'success',
message: t('profile.accounts.unlinkSuccess'),
});
},
onError: () => {
showToast({
type: 'error',
message: t('profile.accounts.unlinkError'),
});
},
});
const canUnlink = (provider: LinkedProvider): boolean => {
if (!provider.linked) return false;
if (!isOAuthProvider(provider.provider)) return false;
const linkedCount = data?.providers.filter((p) => p.linked).length ?? 0;
return linkedCount > 1;
};
const handleLink = async (provider: string) => {
try {
const { authorize_url, state } = await authApi.linkProviderInit(provider);
sessionStorage.setItem('link_oauth_state', state);
sessionStorage.setItem('link_oauth_provider', provider);
window.location.href = authorize_url;
} catch {
showToast({
type: 'error',
message: t('profile.accounts.linkError'),
});
}
};
const handleUnlink = (provider: string) => {
if (window.confirm(t('profile.accounts.unlinkConfirm'))) {
unlinkMutation.mutate(provider);
}
};
return (
<motion.div
className="space-y-6"
variants={staggerContainer}
initial="initial"
animate="animate"
>
{/* Page title */}
<motion.div variants={staggerItem}>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{t('profile.accounts.title')}
</h1>
<p className="mt-1 text-dark-400">{t('profile.accounts.subtitle')}</p>
</motion.div>
{/* Loading state */}
{isLoading && (
<motion.div variants={staggerItem}>
<LoadingSkeleton />
</motion.div>
)}
{/* Provider cards */}
{data?.providers.map((provider) => (
<motion.div key={provider.provider} variants={staggerItem}>
<Card>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ProviderIcon provider={provider.provider} />
<div>
<p className="font-medium text-dark-100">
{t(`profile.accounts.providers.${provider.provider}`)}
</p>
{provider.identifier && (
<p className="text-sm text-dark-400">{provider.identifier}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{provider.linked ? (
<>
<span className="text-sm text-success-500">{t('profile.accounts.linked')}</span>
{canUnlink(provider) && (
<Button
variant="outline"
size="sm"
disabled={unlinkMutation.isPending}
loading={
unlinkMutation.isPending && unlinkMutation.variables === provider.provider
}
onClick={() => handleUnlink(provider.provider)}
>
{t('profile.accounts.unlink')}
</Button>
)}
</>
) : (
isOAuthProvider(provider.provider) && (
<Button
variant="primary"
size="sm"
onClick={() => handleLink(provider.provider)}
>
{t('profile.accounts.link')}
</Button>
)
)}
</div>
</div>
</Card>
</motion.div>
))}
</motion.div>
);
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { authApi } from '../api/auth';
// SessionStorage keys (set by ConnectedAccounts.tsx handleLink)
const LINK_OAUTH_STATE_KEY = 'link_oauth_state';
const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider';
function getAndClearLinkOAuthState(): { state: string; provider: string } | null {
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
const provider = sessionStorage.getItem(LINK_OAUTH_PROVIDER_KEY);
sessionStorage.removeItem(LINK_OAUTH_STATE_KEY);
sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY);
if (!state || !provider) return null;
return { state, provider };
}
export default function LinkOAuthCallback() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const hasRun = useRef(false);
useEffect(() => {
// Prevent double-fire from React StrictMode
if (hasRun.current) return;
hasRun.current = true;
const linkAccount = async () => {
const code = searchParams.get('code');
const urlState = searchParams.get('state');
// VK ID returns device_id in callback URL
const deviceId = searchParams.get('device_id');
if (!code || !urlState) {
navigate('/profile/accounts', { replace: true });
return;
}
// Get saved state from sessionStorage
const saved = getAndClearLinkOAuthState();
if (!saved) {
navigate('/profile/accounts', { replace: true });
return;
}
// Validate state match
if (saved.state !== urlState) {
navigate('/profile/accounts', { replace: true });
return;
}
try {
const response = await authApi.linkProviderCallback(
saved.provider,
code,
urlState,
deviceId ?? undefined,
);
if (response.merge_required && response.merge_token) {
// Redirect to merge page
navigate(`/merge/${response.merge_token}`, { replace: true });
} else {
// Success - redirect back to accounts
navigate('/profile/accounts', { replace: true });
}
} catch {
navigate('/profile/accounts', { replace: true });
}
};
linkAccount();
}, [searchParams, navigate]);
return (
<div className="flex min-h-screen items-center justify-center">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="relative text-center">
<div className="mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
<h2 className="text-lg font-semibold text-dark-50">{t('profile.accounts.linking')}</h2>
<p className="mt-2 text-sm text-dark-400">{t('common.loading')}</p>
</div>
</div>
);
}

577
src/pages/MergeAccounts.tsx Normal file
View File

@@ -0,0 +1,577 @@
import { useState, useEffect, useCallback } 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 min = Math.floor(seconds / 60);
const sec = seconds % 60;
if (min > 0) {
return `${min} ${min === 1 ? 'min' : 'min'} ${sec.toString().padStart(2, '0')} ${sec === 1 ? 'sec' : 'sec'}`;
}
return `${sec} sec`;
}
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.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>
);
}
// -- 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: () => authApi.getMergePreview(mergeToken!),
enabled: !!mergeToken,
retry: 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: () => authApi.executeMerge(mergeToken!, selectedUserId!),
onSuccess: (response) => {
if (response.success && response.access_token && response.refresh_token) {
const { setTokens, setUser } = useAuthStore.getState();
setTokens(response.access_token, response.refresh_token);
if (response.user) {
setUser(response.user);
}
showToast({
type: 'success',
message: t('merge.success'),
});
navigate('/', { replace: true });
}
},
onError: () => {
showToast({
type: 'error',
message: t('merge.error'),
});
},
});
const handleMerge = useCallback(() => {
if (!selectedUserId || mergeMutation.isPending || isExpired) return;
mergeMutation.mutate();
}, [selectedUserId, mergeMutation, isExpired]);
const handleCancel = useCallback(() => {
navigate('/profile/accounts', { replace: true });
}, [navigate]);
// 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;
// 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>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
@@ -61,6 +61,7 @@ const PencilIcon = () => (
export default function Profile() {
const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthStore((state) => state.user);
const setUser = useAuthStore((state) => state.setUser);
const queryClient = useQueryClient();
@@ -394,6 +395,29 @@ export default function Profile() {
</Card>
</motion.div>
{/* Connected Accounts Link */}
<motion.div variants={staggerItem}>
<Card interactive onClick={() => navigate('/profile/accounts')}>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-dark-100">
{t('profile.accounts.goToAccounts')}
</h2>
<p className="text-sm text-dark-400">{t('profile.accounts.subtitle')}</p>
</div>
<svg
className="h-5 w-5 text-dark-400"
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>
</div>
</Card>
</motion.div>
{/* Referral Link Widget */}
{referralTerms?.is_enabled && referralLink && (
<motion.div variants={staggerItem}>