mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
31
src/App.tsx
31
src/App.tsx
@@ -39,6 +39,9 @@ const Connection = lazy(() => import('./pages/Connection'));
|
||||
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
|
||||
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
|
||||
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
|
||||
const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts'));
|
||||
const LinkOAuthCallback = lazy(() => import('./pages/LinkOAuthCallback'));
|
||||
const MergeAccounts = lazy(() => import('./pages/MergeAccounts'));
|
||||
|
||||
// Admin pages - lazy load (only for admins)
|
||||
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
|
||||
@@ -183,6 +186,14 @@ function App() {
|
||||
<Route path="/auth/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route
|
||||
path="/merge/:mergeToken"
|
||||
element={
|
||||
<LazyPage>
|
||||
<MergeAccounts />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
@@ -295,6 +306,26 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile/accounts"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<ConnectedAccounts />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/auth/link/callback"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<LinkOAuthCallback />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contests"
|
||||
element={
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import apiClient from './client';
|
||||
import type { AuthResponse, OAuthProvider, RegisterResponse, TokenResponse, User } from '../types';
|
||||
import type {
|
||||
AuthResponse,
|
||||
LinkCallbackResponse,
|
||||
LinkedProvidersResponse,
|
||||
MergePreviewResponse,
|
||||
MergeResponse,
|
||||
OAuthProvider,
|
||||
RegisterResponse,
|
||||
TokenResponse,
|
||||
User,
|
||||
} from '../types';
|
||||
|
||||
export const authApi = {
|
||||
// Telegram WebApp authentication
|
||||
@@ -190,4 +200,64 @@ export const authApi = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Account linking
|
||||
getLinkedProviders: async (): Promise<LinkedProvidersResponse> => {
|
||||
const response = await apiClient.get<LinkedProvidersResponse>(
|
||||
'/cabinet/auth/account/linked-providers',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
linkProviderInit: async (provider: string): Promise<{ authorize_url: string; state: string }> => {
|
||||
const response = await apiClient.get<{ authorize_url: string; state: string }>(
|
||||
`/cabinet/auth/account/link/${encodeURIComponent(provider)}/init`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
linkProviderCallback: async (
|
||||
provider: string,
|
||||
code: string,
|
||||
state: string,
|
||||
deviceId?: string,
|
||||
): Promise<LinkCallbackResponse> => {
|
||||
const response = await apiClient.post<LinkCallbackResponse>(
|
||||
`/cabinet/auth/account/link/${encodeURIComponent(provider)}/callback`,
|
||||
{
|
||||
code,
|
||||
state,
|
||||
device_id: deviceId,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
unlinkProvider: async (provider: string): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.post<{ success: boolean }>(
|
||||
`/cabinet/auth/account/unlink/${encodeURIComponent(provider)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Account merge (no JWT required)
|
||||
getMergePreview: async (mergeToken: string): Promise<MergePreviewResponse> => {
|
||||
const response = await apiClient.get<MergePreviewResponse>(
|
||||
`/cabinet/auth/merge/${encodeURIComponent(mergeToken)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
executeMerge: async (
|
||||
mergeToken: string,
|
||||
keepSubscriptionFrom: number,
|
||||
): Promise<MergeResponse> => {
|
||||
const response = await apiClient.post<MergeResponse>(
|
||||
`/cabinet/auth/merge/${encodeURIComponent(mergeToken)}`,
|
||||
{
|
||||
keep_subscription_from: keepSubscriptionFrom,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ const AUTH_ENDPOINTS = [
|
||||
'/cabinet/auth/password/forgot',
|
||||
'/cabinet/auth/password/reset',
|
||||
'/cabinet/auth/oauth/',
|
||||
'/cabinet/auth/merge/',
|
||||
];
|
||||
|
||||
function isAuthEndpoint(url: string | undefined): boolean {
|
||||
|
||||
@@ -3598,6 +3598,30 @@
|
||||
"promoOffers": "Promo Offers",
|
||||
"promoOffersDesc": "Receive special offers and discounts",
|
||||
"unavailable": "Notification settings unavailable"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Connected Accounts",
|
||||
"subtitle": "Manage your sign-in methods",
|
||||
"linked": "Connected",
|
||||
"notLinked": "Not connected",
|
||||
"link": "Connect",
|
||||
"unlink": "Disconnect",
|
||||
"unlinkConfirm": "Are you sure? You won't be able to sign in with this service after disconnecting.",
|
||||
"cannotUnlinkLast": "Cannot disconnect the last sign-in method",
|
||||
"linkSuccess": "Account connected successfully",
|
||||
"unlinkSuccess": "Account disconnected successfully",
|
||||
"linkError": "Failed to connect account",
|
||||
"unlinkError": "Failed to disconnect account",
|
||||
"goToAccounts": "Connected Accounts",
|
||||
"linking": "Linking account...",
|
||||
"providers": {
|
||||
"telegram": "Telegram",
|
||||
"email": "Email",
|
||||
"google": "Google",
|
||||
"yandex": "Yandex",
|
||||
"discord": "Discord",
|
||||
"vk": "VK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
@@ -3807,5 +3831,32 @@
|
||||
"reason": "Reason",
|
||||
"contactSupport": "If you believe this is an error, please contact support."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
"title": "Merge Accounts",
|
||||
"description": "This sign-in method is already linked to another account. You can merge both accounts into one.",
|
||||
"currentAccount": "Your current account",
|
||||
"foundAccount": "Found account",
|
||||
"authMethods": "Sign-in methods",
|
||||
"subscription": "Subscription",
|
||||
"noSubscription": "No subscription",
|
||||
"traffic": "Traffic",
|
||||
"devices": "Devices",
|
||||
"balance": "Balance",
|
||||
"until": "until {{date}}",
|
||||
"keepThisSubscription": "Keep this subscription",
|
||||
"chooseSubscription": "Choose which subscription to keep",
|
||||
"afterMerge": "After merging",
|
||||
"allAuthMethodsMerged": "All sign-in methods will be combined",
|
||||
"balanceSummed": "Balance: {{amount}} ₽ (combined)",
|
||||
"unselectedSubscriptionDeleted": "The unselected subscription will be deleted",
|
||||
"historyPreserved": "Transaction history will be preserved",
|
||||
"confirm": "Merge Accounts",
|
||||
"cancel": "Cancel",
|
||||
"expired": "The merge link has expired. Please try again.",
|
||||
"success": "Accounts merged successfully!",
|
||||
"error": "Failed to merge accounts. Please try again later.",
|
||||
"expiresIn": "Expires in {{minutes}}",
|
||||
"merging": "Merging..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3034,6 +3034,29 @@
|
||||
"promoOffers": "پیشنهادات تبلیغاتی",
|
||||
"promoOffersDesc": "دریافت پیشنهادات ویژه و تخفیفها",
|
||||
"unavailable": "تنظیمات اعلان در دسترس نیست"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "حسابهای متصل",
|
||||
"subtitle": "مدیریت روشهای ورود",
|
||||
"linked": "متصل",
|
||||
"notLinked": "متصل نیست",
|
||||
"link": "اتصال",
|
||||
"unlink": "قطع اتصال",
|
||||
"unlinkConfirm": "آیا مطمئن هستید؟ پس از قطع اتصال نمیتوانید از طریق این سرویس وارد شوید.",
|
||||
"cannotUnlinkLast": "نمیتوان آخرین روش ورود را قطع کرد",
|
||||
"linkSuccess": "حساب با موفقیت متصل شد",
|
||||
"unlinkSuccess": "اتصال حساب با موفقیت قطع شد",
|
||||
"linkError": "اتصال حساب ناموفق بود",
|
||||
"unlinkError": "قطع اتصال ناموفق بود",
|
||||
"goToAccounts": "حسابهای متصل",
|
||||
"providers": {
|
||||
"telegram": "تلگرام",
|
||||
"email": "ایمیل",
|
||||
"google": "گوگل",
|
||||
"yandex": "یاندکس",
|
||||
"discord": "دیسکورد",
|
||||
"vk": "VK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
@@ -3358,5 +3381,32 @@
|
||||
"reason": "دلیل",
|
||||
"contactSupport": "اگر فکر میکنید این اشتباه است، با پشتیبانی تماس بگیرید."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
"title": "ادغام حسابها",
|
||||
"description": "این روش ورود به حساب دیگری متصل است. میتوانید هر دو حساب را ادغام کنید.",
|
||||
"currentAccount": "حساب فعلی شما",
|
||||
"foundAccount": "حساب یافت شده",
|
||||
"authMethods": "روشهای ورود",
|
||||
"subscription": "اشتراک",
|
||||
"noSubscription": "بدون اشتراک",
|
||||
"traffic": "ترافیک",
|
||||
"devices": "دستگاهها",
|
||||
"balance": "موجودی",
|
||||
"until": "تا {{date}}",
|
||||
"keepThisSubscription": "نگه داشتن این اشتراک",
|
||||
"chooseSubscription": "اشتراک مورد نظر را انتخاب کنید",
|
||||
"afterMerge": "پس از ادغام",
|
||||
"allAuthMethodsMerged": "تمام روشهای ورود ادغام خواهند شد",
|
||||
"balanceSummed": "موجودی: {{amount}} ₽ (مجموع)",
|
||||
"unselectedSubscriptionDeleted": "اشتراک انتخاب نشده حذف خواهد شد",
|
||||
"historyPreserved": "تاریخچه تراکنشها حفظ خواهد شد",
|
||||
"confirm": "ادغام حسابها",
|
||||
"cancel": "لغو",
|
||||
"expired": "لینک ادغام منقضی شده است. لطفاً دوباره تلاش کنید.",
|
||||
"success": "حسابها با موفقیت ادغام شدند!",
|
||||
"error": "ادغام ناموفق بود. لطفاً بعداً دوباره تلاش کنید.",
|
||||
"expiresIn": "{{minutes}} دقیقه باقی مانده",
|
||||
"merging": "در حال ادغام..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4158,6 +4158,30 @@
|
||||
"promoOffers": "Промо-предложения",
|
||||
"promoOffersDesc": "Получать специальные предложения и скидки",
|
||||
"unavailable": "Настройки уведомлений недоступны"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Привязанные аккаунты",
|
||||
"subtitle": "Управляйте способами входа в кабинет",
|
||||
"linked": "Привязан",
|
||||
"notLinked": "Не привязан",
|
||||
"link": "Привязать",
|
||||
"unlink": "Отвязать",
|
||||
"unlinkConfirm": "Вы уверены? После отвязки вы не сможете входить через этот сервис.",
|
||||
"cannotUnlinkLast": "Нельзя отвязать последний способ входа",
|
||||
"linkSuccess": "Аккаунт успешно привязан",
|
||||
"unlinkSuccess": "Аккаунт успешно отвязан",
|
||||
"linkError": "Не удалось привязать аккаунт",
|
||||
"unlinkError": "Не удалось отвязать аккаунт",
|
||||
"goToAccounts": "Привязанные аккаунты",
|
||||
"linking": "Привязка аккаунта...",
|
||||
"providers": {
|
||||
"telegram": "Telegram",
|
||||
"email": "Email",
|
||||
"google": "Google",
|
||||
"yandex": "Яндекс",
|
||||
"discord": "Discord",
|
||||
"vk": "VK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
@@ -4370,5 +4394,32 @@
|
||||
"reason": "Причина",
|
||||
"contactSupport": "Если вы считаете, что это ошибка, обратитесь в поддержку."
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
"title": "Объединение аккаунтов",
|
||||
"description": "Этот способ входа уже привязан к другому аккаунту. Вы можете объединить два аккаунта в один.",
|
||||
"currentAccount": "Ваш текущий аккаунт",
|
||||
"foundAccount": "Найденный аккаунт",
|
||||
"authMethods": "Способы входа",
|
||||
"subscription": "Подписка",
|
||||
"noSubscription": "Нет подписки",
|
||||
"traffic": "Трафик",
|
||||
"devices": "Устройства",
|
||||
"balance": "Баланс",
|
||||
"until": "до {{date}}",
|
||||
"keepThisSubscription": "Оставить эту подписку",
|
||||
"chooseSubscription": "Выберите подписку, которую хотите сохранить",
|
||||
"afterMerge": "После объединения",
|
||||
"allAuthMethodsMerged": "Все способы входа объединятся",
|
||||
"balanceSummed": "Баланс: {{amount}} ₽ (сумма)",
|
||||
"unselectedSubscriptionDeleted": "Невыбранная подписка будет удалена",
|
||||
"historyPreserved": "История операций сохранится",
|
||||
"confirm": "Объединить аккаунты",
|
||||
"cancel": "Отмена",
|
||||
"expired": "Время на объединение истекло. Попробуйте снова.",
|
||||
"success": "Аккаунты успешно объединены!",
|
||||
"error": "Ошибка при объединении. Попробуйте позже.",
|
||||
"expiresIn": "Действует ещё {{minutes}}",
|
||||
"merging": "Объединение..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3033,6 +3033,29 @@
|
||||
"promoOffers": "促销活动",
|
||||
"promoOffersDesc": "接收特别优惠和折扣",
|
||||
"unavailable": "通知设置不可用"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "关联账户",
|
||||
"subtitle": "管理您的登录方式",
|
||||
"linked": "已关联",
|
||||
"notLinked": "未关联",
|
||||
"link": "关联",
|
||||
"unlink": "取消关联",
|
||||
"unlinkConfirm": "确定吗?取消关联后,您将无法通过此服务登录。",
|
||||
"cannotUnlinkLast": "无法取消最后一种登录方式",
|
||||
"linkSuccess": "账户关联成功",
|
||||
"unlinkSuccess": "账户取消关联成功",
|
||||
"linkError": "关联账户失败",
|
||||
"unlinkError": "取消关联失败",
|
||||
"goToAccounts": "关联账户",
|
||||
"providers": {
|
||||
"telegram": "Telegram",
|
||||
"email": "邮箱",
|
||||
"google": "Google",
|
||||
"yandex": "Yandex",
|
||||
"discord": "Discord",
|
||||
"vk": "VK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
@@ -3357,5 +3380,32 @@
|
||||
"requests": "请求数",
|
||||
"ipHistory": "IP历史"
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
"title": "合并账户",
|
||||
"description": "此登录方式已关联到另一个账户。您可以将两个账户合并为一个。",
|
||||
"currentAccount": "您的当前账户",
|
||||
"foundAccount": "发现的账户",
|
||||
"authMethods": "登录方式",
|
||||
"subscription": "订阅",
|
||||
"noSubscription": "无订阅",
|
||||
"traffic": "流量",
|
||||
"devices": "设备",
|
||||
"balance": "余额",
|
||||
"until": "至 {{date}}",
|
||||
"keepThisSubscription": "保留此订阅",
|
||||
"chooseSubscription": "选择要保留的订阅",
|
||||
"afterMerge": "合并后",
|
||||
"allAuthMethodsMerged": "所有登录方式将合并",
|
||||
"balanceSummed": "余额:{{amount}} ₽(合计)",
|
||||
"unselectedSubscriptionDeleted": "未选择的订阅将被删除",
|
||||
"historyPreserved": "交易记录将保留",
|
||||
"confirm": "合并账户",
|
||||
"cancel": "取消",
|
||||
"expired": "合并链接已过期,请重试。",
|
||||
"success": "账户合并成功!",
|
||||
"error": "合并失败,请稍后重试。",
|
||||
"expiresIn": "剩余 {{minutes}} 分钟",
|
||||
"merging": "合并中..."
|
||||
}
|
||||
}
|
||||
|
||||
205
src/pages/ConnectedAccounts.tsx
Normal file
205
src/pages/ConnectedAccounts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/pages/LinkOAuthCallback.tsx
Normal file
87
src/pages/LinkOAuthCallback.tsx
Normal 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
577
src/pages/MergeAccounts.tsx
Normal 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)} ₽
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -636,3 +636,57 @@ export interface PromoGroupSimple {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Account Linking
|
||||
export interface LinkedProvider {
|
||||
provider: string;
|
||||
linked: boolean;
|
||||
identifier: string | null;
|
||||
}
|
||||
|
||||
export interface LinkedProvidersResponse {
|
||||
providers: LinkedProvider[];
|
||||
}
|
||||
|
||||
export interface LinkCallbackResponse {
|
||||
success: boolean;
|
||||
message: string | null;
|
||||
merge_required: boolean;
|
||||
merge_token: string | null;
|
||||
}
|
||||
|
||||
// Account Merge
|
||||
export interface MergeSubscriptionPreview {
|
||||
status: string;
|
||||
is_trial: boolean;
|
||||
end_date: string | null;
|
||||
traffic_limit_gb: number;
|
||||
traffic_used_gb: number;
|
||||
device_limit: number;
|
||||
tariff_name: string | null;
|
||||
autopay_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MergeAccountPreview {
|
||||
id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
email: string | null;
|
||||
auth_methods: string[];
|
||||
balance_kopeks: number;
|
||||
subscription: MergeSubscriptionPreview | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface MergePreviewResponse {
|
||||
primary: MergeAccountPreview;
|
||||
secondary: MergeAccountPreview;
|
||||
expires_in_seconds: number;
|
||||
}
|
||||
|
||||
export interface MergeResponse {
|
||||
success: boolean;
|
||||
access_token: string | null;
|
||||
refresh_token: string | null;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user