mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -11,6 +11,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<title>Loading...</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
33
src/App.tsx
33
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 LinkTelegramCallback = lazy(() => import('./pages/LinkTelegramCallback'));
|
||||
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/telegram/callback"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<LinkTelegramCallback />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contests"
|
||||
element={
|
||||
@@ -748,7 +779,7 @@ function App() {
|
||||
<Route
|
||||
path="/admin/sales-stats"
|
||||
element={
|
||||
<PermissionRoute permission="stats:read">
|
||||
<PermissionRoute permission="sales_stats:read">
|
||||
<LazyPage>
|
||||
<AdminSalesStats />
|
||||
</LazyPage>
|
||||
|
||||
112
src/api/auth.ts
112
src/api/auth.ts
@@ -1,5 +1,16 @@
|
||||
import apiClient from './client';
|
||||
import type { AuthResponse, OAuthProvider, RegisterResponse, TokenResponse, User } from '../types';
|
||||
import type {
|
||||
AuthResponse,
|
||||
LinkCallbackResponse,
|
||||
LinkedProvidersResponse,
|
||||
MergePreviewResponse,
|
||||
MergeResponse,
|
||||
OAuthProvider,
|
||||
RegisterResponse,
|
||||
ServerCompleteResponse,
|
||||
TokenResponse,
|
||||
User,
|
||||
} from '../types';
|
||||
|
||||
export const authApi = {
|
||||
// Telegram WebApp authentication
|
||||
@@ -190,4 +201,103 @@ 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;
|
||||
},
|
||||
|
||||
// Link Telegram account (Mini App initData or Login Widget data)
|
||||
linkTelegram: async (
|
||||
data:
|
||||
| { init_data: string }
|
||||
| {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
photo_url?: string;
|
||||
auth_date: number;
|
||||
hash: string;
|
||||
},
|
||||
): Promise<LinkCallbackResponse> => {
|
||||
const response = await apiClient.post<LinkCallbackResponse>(
|
||||
'/cabinet/auth/account/link/telegram',
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Server-side OAuth linking completion (no JWT needed — auth via state token)
|
||||
// Used when OAuth opens in external browser from Telegram Mini App
|
||||
linkServerComplete: async (
|
||||
code: string,
|
||||
state: string,
|
||||
deviceId?: string,
|
||||
): Promise<ServerCompleteResponse> => {
|
||||
const response = await apiClient.post<ServerCompleteResponse>(
|
||||
'/cabinet/auth/account/link/server-complete',
|
||||
{
|
||||
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,11 +75,13 @@ const AUTH_ENDPOINTS = [
|
||||
'/cabinet/auth/password/forgot',
|
||||
'/cabinet/auth/password/reset',
|
||||
'/cabinet/auth/oauth/',
|
||||
'/cabinet/auth/merge/',
|
||||
'/cabinet/auth/account/link/server-complete',
|
||||
];
|
||||
|
||||
function isAuthEndpoint(url: string | undefined): boolean {
|
||||
if (!url) return false;
|
||||
return AUTH_ENDPOINTS.some((endpoint) => url.includes(endpoint));
|
||||
return AUTH_ENDPOINTS.some((endpoint) => url.startsWith(endpoint));
|
||||
}
|
||||
|
||||
// Request interceptor - add auth token with expiration check
|
||||
@@ -212,15 +214,10 @@ apiClient.interceptors.response.use(
|
||||
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента
|
||||
const authEndpoints = [
|
||||
'/cabinet/auth/email/login',
|
||||
'/cabinet/auth/telegram',
|
||||
'/cabinet/auth/telegram/widget',
|
||||
];
|
||||
const requestUrl = originalRequest.url || '';
|
||||
const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint));
|
||||
const isLoginEndpoint = isAuthEndpoint(requestUrl);
|
||||
|
||||
if (isAuthEndpoint) {
|
||||
if (isLoginEndpoint) {
|
||||
// Пробрасываем ошибку в компонент для показа сообщения пользователю
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function OAuthProviderIcon({
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
@@ -32,7 +32,7 @@ export default function OAuthProviderIcon({
|
||||
|
||||
case 'yandex':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12z"
|
||||
fill="#FC3F1D"
|
||||
@@ -46,7 +46,7 @@ export default function OAuthProviderIcon({
|
||||
|
||||
case 'discord':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028 14.09 14.09 0 001.226-1.994.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
fill="#5865F2"
|
||||
@@ -56,7 +56,7 @@ export default function OAuthProviderIcon({
|
||||
|
||||
case 'vk':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z"
|
||||
fill="#0077FF"
|
||||
|
||||
49
src/components/ProviderIcon.tsx
Normal file
49
src/components/ProviderIcon.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import OAuthProviderIcon from './OAuthProviderIcon';
|
||||
|
||||
export function TelegramIcon({ className = 'h-5 w-5' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export 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}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProviderIcon({
|
||||
provider,
|
||||
className,
|
||||
}: {
|
||||
provider: string;
|
||||
className?: string;
|
||||
}) {
|
||||
switch (provider) {
|
||||
case 'telegram':
|
||||
return <TelegramIcon className={className ?? 'h-6 w-6'} />;
|
||||
case 'email':
|
||||
return <EmailIcon className={cn('text-dark-300', className ?? 'h-6 w-6')} />;
|
||||
default:
|
||||
return <OAuthProviderIcon provider={provider} className={className ?? 'h-6 w-6'} />;
|
||||
}
|
||||
}
|
||||
@@ -365,7 +365,7 @@ export function BackgroundEditor() {
|
||||
className={cn(
|
||||
'w-full rounded-xl py-3 text-sm font-medium transition-colors',
|
||||
saveStatus === 'saved'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -17,7 +17,7 @@ type StyleValue = 'primary' | 'success' | 'danger' | 'default';
|
||||
const STYLE_OPTIONS: { value: StyleValue; colorClass: string }[] = [
|
||||
{ value: 'default', colorClass: 'bg-dark-500' },
|
||||
{ value: 'primary', colorClass: 'bg-blue-500' },
|
||||
{ value: 'success', colorClass: 'bg-green-500' },
|
||||
{ value: 'success', colorClass: 'bg-success-500' },
|
||||
{ value: 'danger', colorClass: 'bg-red-500' },
|
||||
];
|
||||
|
||||
@@ -222,7 +222,7 @@ export function ButtonsTab() {
|
||||
cfg.style === 'default'
|
||||
? 'bg-dark-600 text-dark-300'
|
||||
: cfg.style === 'success'
|
||||
? 'bg-green-500 text-white'
|
||||
? 'bg-success-500 text-white'
|
||||
: cfg.style === 'danger'
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-blue-500 text-white'
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router';
|
||||
import type { Subscription } from '../../types';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useTrafficZone } from '../../hooks/useTrafficZone';
|
||||
import { getGlassColors } from '../../utils/glassTheme';
|
||||
|
||||
interface StatsGridProps {
|
||||
balanceRubles: number;
|
||||
subscription: Subscription | null;
|
||||
referralCount: number;
|
||||
earningsRubles: number;
|
||||
refLoading: boolean;
|
||||
@@ -35,7 +32,6 @@ const ChevronIcon = ({ color }: { color: string }) => (
|
||||
|
||||
export default function StatsGrid({
|
||||
balanceRubles,
|
||||
subscription,
|
||||
referralCount,
|
||||
earningsRubles,
|
||||
refLoading,
|
||||
@@ -45,13 +41,14 @@ export default function StatsGrid({
|
||||
const { isDark } = useTheme();
|
||||
const g = getGlassColors(isDark);
|
||||
|
||||
const zone = useTrafficZone(subscription?.traffic_used_percent ?? 0);
|
||||
const accentColor = 'rgb(var(--color-accent-400))';
|
||||
const accentBg = 'rgba(var(--color-accent-400), 0.07)';
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: t('dashboard.stats.balance'),
|
||||
value: `${formatAmount(balanceRubles)} ${currencySymbol}`,
|
||||
valueColor: zone.mainHex,
|
||||
valueColor: accentColor,
|
||||
to: '/balance',
|
||||
icon: (color: string) => (
|
||||
<svg
|
||||
@@ -70,8 +67,8 @@ export default function StatsGrid({
|
||||
<path d="M6 14h.01M10 14h.01" />
|
||||
</svg>
|
||||
),
|
||||
iconBg: `${zone.mainHex}12`,
|
||||
iconColor: zone.mainHex,
|
||||
iconBg: accentBg,
|
||||
iconColor: accentColor,
|
||||
loading: false,
|
||||
onboarding: 'balance',
|
||||
},
|
||||
@@ -80,7 +77,7 @@ export default function StatsGrid({
|
||||
value: `${referralCount}`,
|
||||
valueColor: g.text,
|
||||
subtitle: `+${formatAmount(earningsRubles)} ${currencySymbol}`,
|
||||
subtitleColor: zone.mainHex,
|
||||
subtitleColor: accentColor,
|
||||
to: '/referral',
|
||||
icon: (color: string) => (
|
||||
<svg
|
||||
|
||||
@@ -71,12 +71,14 @@ export default function SubscriptionCardActive({
|
||||
style={{
|
||||
background: g.cardBg,
|
||||
border: subscription.is_trial
|
||||
? '1px solid rgba(62,219,176,0.15)'
|
||||
? '1px solid rgba(var(--color-accent-400), 0.15)'
|
||||
: isDark
|
||||
? `1px solid ${g.cardBorder}`
|
||||
: `1px solid ${zone.mainHex}25`,
|
||||
: `1px solid rgba(${zone.mainVarRaw}, 0.14)`,
|
||||
padding: '28px 28px 24px',
|
||||
boxShadow: isDark ? g.shadow : `0 2px 16px ${zone.mainHex}12, 0 0 0 1px ${zone.mainHex}08`,
|
||||
boxShadow: isDark
|
||||
? g.shadow
|
||||
: `0 2px 16px rgba(${zone.mainVarRaw}, 0.07), 0 0 0 1px rgba(${zone.mainVarRaw}, 0.03)`,
|
||||
}}
|
||||
>
|
||||
{/* Trial shimmer border */}
|
||||
@@ -96,7 +98,7 @@ export default function SubscriptionCardActive({
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${zone.mainHex}${g.glowAlpha} 0%, transparent 70%)`,
|
||||
background: `radial-gradient(circle, rgba(${zone.mainVarRaw}, ${isDark ? 0.08 : 0.03}) 0%, transparent 70%)`,
|
||||
transition: 'background 0.8s ease',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
@@ -110,15 +112,15 @@ export default function SubscriptionCardActive({
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{
|
||||
background: zone.mainHex,
|
||||
boxShadow: `0 0 8px ${zone.mainHex}80`,
|
||||
background: zone.mainVar,
|
||||
boxShadow: `0 0 8px rgba(${zone.mainVarRaw}, 0.5)`,
|
||||
transition: 'all 0.6s ease',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="font-mono text-[11px] font-semibold uppercase tracking-widest"
|
||||
style={{ color: zone.mainHex, transition: 'color 0.6s ease' }}
|
||||
style={{ color: zone.mainVar, transition: 'color 0.6s ease' }}
|
||||
>
|
||||
{isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)}
|
||||
</span>
|
||||
@@ -127,9 +129,9 @@ export default function SubscriptionCardActive({
|
||||
className="inline-flex animate-trial-glow items-center gap-1 rounded-md px-2 py-0.5 font-mono text-[9px] font-bold uppercase tracking-widest"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(62,219,176,0.15), rgba(62,219,176,0.06))',
|
||||
border: '1px solid rgba(62,219,176,0.2)',
|
||||
color: '#3EDBB0',
|
||||
'linear-gradient(135deg, rgba(var(--color-accent-400), 0.15), rgba(var(--color-accent-400), 0.06))',
|
||||
border: '1px solid rgba(var(--color-accent-400), 0.2)',
|
||||
color: 'rgb(var(--color-accent-400))',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
@@ -164,7 +166,7 @@ export default function SubscriptionCardActive({
|
||||
<>
|
||||
<div
|
||||
className="font-display text-[28px] font-extrabold leading-none tracking-tight"
|
||||
style={{ color: zone.mainHex }}
|
||||
style={{ color: zone.mainVar }}
|
||||
>
|
||||
∞
|
||||
</div>
|
||||
@@ -209,14 +211,14 @@ export default function SubscriptionCardActive({
|
||||
{/* Monitor icon */}
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
|
||||
style={{ background: `${zone.mainHex}12` }}
|
||||
style={{ background: `rgba(${zone.mainVarRaw}, 0.07)` }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={zone.mainHex}
|
||||
stroke={zone.mainVar}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -249,8 +251,9 @@ export default function SubscriptionCardActive({
|
||||
key={i}
|
||||
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
|
||||
style={{
|
||||
background: i < connectedDevices ? zone.mainHex : g.textGhost,
|
||||
boxShadow: i < connectedDevices ? `0 0 6px ${zone.mainHex}50` : 'none',
|
||||
background: i < connectedDevices ? zone.mainVar : g.textGhost,
|
||||
boxShadow:
|
||||
i < connectedDevices ? `0 0 6px rgba(${zone.mainVarRaw}, 0.31)` : 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -265,8 +268,8 @@ export default function SubscriptionCardActive({
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.round((connectedDevices / subscription.device_limit) * 100)}%`,
|
||||
background: zone.mainHex,
|
||||
boxShadow: `0 0 8px ${zone.mainHex}40`,
|
||||
background: zone.mainVar,
|
||||
boxShadow: `0 0 8px rgba(${zone.mainVarRaw}, 0.25)`,
|
||||
minWidth: connectedDevices > 0 ? '4px' : '0px',
|
||||
}}
|
||||
/>
|
||||
@@ -283,13 +286,13 @@ export default function SubscriptionCardActive({
|
||||
to="/subscription"
|
||||
className="flex-1 rounded-[14px] p-3.5 transition-all duration-500"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${zone.mainHex}12, ${zone.mainHex}06)`,
|
||||
border: `1px solid ${zone.mainHex}18`,
|
||||
background: `linear-gradient(135deg, rgba(${zone.mainVarRaw}, 0.07), rgba(${zone.mainVarRaw}, 0.02))`,
|
||||
border: `1px solid rgba(${zone.mainVarRaw}, 0.09)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider opacity-70 transition-colors duration-500"
|
||||
style={{ color: zone.mainHex }}
|
||||
style={{ color: zone.mainVar }}
|
||||
>
|
||||
{t('dashboard.tariff')}
|
||||
</div>
|
||||
@@ -306,14 +309,17 @@ export default function SubscriptionCardActive({
|
||||
className="flex-1 rounded-[14px] p-3.5 transition-colors duration-300"
|
||||
style={{
|
||||
background: g.innerBg,
|
||||
border: daysLeft <= 3 ? '1px solid rgba(255,184,0,0.2)' : `1px solid ${g.innerBorder}`,
|
||||
border:
|
||||
daysLeft <= 3
|
||||
? '1px solid rgba(var(--color-warning-400), 0.2)'
|
||||
: `1px solid ${g.innerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center rounded-[7px] transition-colors duration-300"
|
||||
style={{
|
||||
background: daysLeft <= 3 ? 'rgba(255,184,0,0.1)' : g.hoverBg,
|
||||
background: daysLeft <= 3 ? 'rgba(var(--color-warning-400), 0.1)' : g.hoverBg,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
@@ -321,7 +327,7 @@ export default function SubscriptionCardActive({
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={daysLeft <= 3 ? '#FFB800' : g.textSecondary}
|
||||
stroke={daysLeft <= 3 ? 'rgb(var(--color-warning-400))' : g.textSecondary}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -336,7 +342,7 @@ export default function SubscriptionCardActive({
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className="text-[22px] font-bold tracking-tight transition-colors duration-300"
|
||||
style={{ color: daysLeft <= 3 ? '#FFB800' : g.text }}
|
||||
style={{ color: daysLeft <= 3 ? 'rgb(var(--color-warning-400))' : g.text }}
|
||||
>
|
||||
{daysLeft}
|
||||
</span>
|
||||
@@ -382,7 +388,7 @@ export default function SubscriptionCardActive({
|
||||
{t('dashboard.maxUsage', { amount: formatTraffic(Math.max(...dailyUsage)) })}
|
||||
</span>
|
||||
</div>
|
||||
<Sparkline data={dailyUsage} width={440} height={44} color={zone.mainHex} />
|
||||
<Sparkline data={dailyUsage} width={440} height={44} color={zone.mainVar} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,19 +26,23 @@ export default function TrafficProgressBar({
|
||||
const { isDark } = useTheme();
|
||||
const g = getGlassColors(isDark);
|
||||
const zone = useTrafficZone(percent);
|
||||
|
||||
// Gradient always starts from the accent color (normal zone)
|
||||
const startHex = zone.colors.accent || '#3b82f6';
|
||||
const startColor = 'rgb(var(--color-accent-400))';
|
||||
const clampedPercent = Math.min(percent, 100);
|
||||
const barHeight = compact ? 8 : 14;
|
||||
|
||||
// Multi-segment gradient matching prototype
|
||||
// Warning/danger hex colors are intentional — they represent fixed threshold tints,
|
||||
// not theme-dynamic values, and must remain distinct from the zone-based mainVar.
|
||||
const fillGradient = useMemo(() => {
|
||||
if (percent < 50) return `linear-gradient(90deg, ${startHex}, ${zone.mainHex})`;
|
||||
if (percent < 75) return `linear-gradient(90deg, ${startHex}, #FFB800, ${zone.mainHex})`;
|
||||
if (percent < 50) return `linear-gradient(90deg, ${startColor}, ${zone.mainVar})`;
|
||||
if (percent < 75)
|
||||
return `linear-gradient(90deg, ${startColor}, rgb(var(--color-warning-400)), ${zone.mainVar})`;
|
||||
if (percent < 90)
|
||||
return `linear-gradient(90deg, ${startHex}, #FFB800, #FF6B35, ${zone.mainHex})`;
|
||||
return `linear-gradient(90deg, ${startHex}, #FFB800, #FF6B35, #FF3B5C)`;
|
||||
}, [percent, zone.mainHex, startHex]);
|
||||
return `linear-gradient(90deg, ${startColor}, rgb(var(--color-warning-400)), rgb(var(--color-warning-300)), ${zone.mainVar})`;
|
||||
return `linear-gradient(90deg, ${startColor}, rgb(var(--color-warning-400)), rgb(var(--color-warning-300)), rgb(var(--color-error-400)))`;
|
||||
}, [percent, zone.mainVar, startColor]);
|
||||
|
||||
if (isUnlimited) {
|
||||
return (
|
||||
@@ -50,13 +54,13 @@ export default function TrafficProgressBar({
|
||||
height: barHeight,
|
||||
borderRadius: 10,
|
||||
background: g.trackBg,
|
||||
border: `1px solid ${zone.mainHex}20`,
|
||||
border: `1px solid rgba(${zone.mainVarRaw}, 0.12)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 animate-unlimited-flow"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, ${zone.mainHex}50, ${zone.mainHex}, ${zone.mainHex}50)`,
|
||||
background: `linear-gradient(90deg, rgba(${zone.mainVarRaw}, 0.31), ${zone.mainVar}, rgba(${zone.mainVarRaw}, 0.31))`,
|
||||
backgroundSize: '200% 100%',
|
||||
}}
|
||||
/>
|
||||
@@ -85,13 +89,13 @@ export default function TrafficProgressBar({
|
||||
<div className="mt-2 flex items-center justify-between px-0.5">
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold"
|
||||
style={{ color: zone.mainHex }}
|
||||
style={{ color: zone.mainVar }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-1.5 w-1.5 animate-unlimited-pulse rounded-full"
|
||||
style={{
|
||||
background: zone.mainHex,
|
||||
boxShadow: `0 0 8px ${zone.mainHex}`,
|
||||
background: zone.mainVar,
|
||||
boxShadow: `0 0 8px ${zone.mainVar}`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -127,9 +131,9 @@ export default function TrafficProgressBar({
|
||||
{/* Warning zone tint backgrounds */}
|
||||
<div className="absolute inset-0 flex" aria-hidden="true">
|
||||
<div style={{ flex: '50 0 0', background: 'transparent' }} />
|
||||
<div style={{ flex: '25 0 0', background: 'rgba(255,184,0,0.03)' }} />
|
||||
<div style={{ flex: '15 0 0', background: 'rgba(255,107,53,0.04)' }} />
|
||||
<div style={{ flex: '10 0 0', background: 'rgba(255,59,92,0.05)' }} />
|
||||
<div style={{ flex: '25 0 0', background: 'rgba(var(--color-warning-400), 0.03)' }} />
|
||||
<div style={{ flex: '15 0 0', background: 'rgba(var(--color-warning-300), 0.04)' }} />
|
||||
<div style={{ flex: '10 0 0', background: 'rgba(var(--color-error-400), 0.05)' }} />
|
||||
</div>
|
||||
|
||||
{/* Fill bar */}
|
||||
@@ -194,7 +198,7 @@ export default function TrafficProgressBar({
|
||||
left: `calc(${clampedPercent}% - 8px)`,
|
||||
width: 16,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${zone.mainHex}60, transparent)`,
|
||||
background: `radial-gradient(circle, rgba(${zone.mainVarRaw}, 0.38), transparent)`,
|
||||
filter: 'blur(4px)',
|
||||
transition: 'left 1.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
|
||||
@@ -36,12 +36,12 @@ export default function TrialOfferCard({
|
||||
border: isDark
|
||||
? `1px solid ${g.cardBorder}`
|
||||
: isFree
|
||||
? '1px solid rgba(62,219,176,0.2)'
|
||||
? '1px solid rgba(var(--color-accent-400), 0.2)'
|
||||
: '1px solid rgba(255,184,0,0.2)',
|
||||
boxShadow: isDark
|
||||
? g.shadow
|
||||
: isFree
|
||||
? '0 2px 16px rgba(62,219,176,0.12), 0 0 0 1px rgba(62,219,176,0.06)'
|
||||
? '0 2px 16px rgba(var(--color-accent-400), 0.12), 0 0 0 1px rgba(var(--color-accent-400), 0.06)'
|
||||
: '0 2px 16px rgba(255,184,0,0.12), 0 0 0 1px rgba(255,184,0,0.06)',
|
||||
padding: '32px 28px 28px',
|
||||
}}
|
||||
@@ -55,7 +55,7 @@ export default function TrialOfferCard({
|
||||
height: 300,
|
||||
borderRadius: '50%',
|
||||
background: isFree
|
||||
? 'radial-gradient(circle, rgba(62,219,176,0.08) 0%, transparent 70%)'
|
||||
? 'radial-gradient(circle, rgba(var(--color-accent-400), 0.08) 0%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,184,0,0.07) 0%, transparent 70%)',
|
||||
transition: 'background 0.5s ease',
|
||||
}}
|
||||
@@ -82,12 +82,14 @@ export default function TrialOfferCard({
|
||||
style={{
|
||||
background: isDark
|
||||
? isFree
|
||||
? 'linear-gradient(135deg, #1a3a30, #142824)'
|
||||
? 'linear-gradient(135deg, rgba(var(--color-accent-900), 0.5), rgba(var(--color-accent-950), 0.6))'
|
||||
: 'linear-gradient(135deg, #3a3020, #282418)'
|
||||
: isFree
|
||||
? 'linear-gradient(135deg, rgba(62,219,176,0.15), rgba(62,219,176,0.08))'
|
||||
? 'linear-gradient(135deg, rgba(var(--color-accent-400), 0.15), rgba(var(--color-accent-400), 0.08))'
|
||||
: 'linear-gradient(135deg, rgba(255,184,0,0.15), rgba(255,184,0,0.08))',
|
||||
border: isFree ? '1px solid rgba(62,219,176,0.25)' : '1px solid rgba(255,184,0,0.25)',
|
||||
border: isFree
|
||||
? '1px solid rgba(var(--color-accent-400), 0.25)'
|
||||
: '1px solid rgba(255,184,0,0.25)',
|
||||
transition: 'all 0.5s ease',
|
||||
}}
|
||||
>
|
||||
@@ -97,7 +99,7 @@ export default function TrialOfferCard({
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#3EDBB0"
|
||||
stroke="rgb(var(--color-accent-400))"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@@ -128,7 +130,9 @@ export default function TrialOfferCard({
|
||||
<div
|
||||
className="absolute inset-[-1px] animate-trial-glow rounded-2xl"
|
||||
style={{
|
||||
boxShadow: isFree ? '0 0 20px rgba(62,219,176,0.15)' : '0 0 20px rgba(255,184,0,0.12)',
|
||||
boxShadow: isFree
|
||||
? '0 0 20px rgba(var(--color-accent-400), 0.15)'
|
||||
: '0 0 20px rgba(255,184,0,0.12)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -250,14 +254,15 @@ export default function TrialOfferCard({
|
||||
isDark
|
||||
? {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(62,219,176,0.12) 0%, rgba(62,219,176,0.04) 100%)',
|
||||
border: '1px solid rgba(62,219,176,0.25)',
|
||||
'linear-gradient(135deg, rgba(var(--color-accent-400), 0.12) 0%, rgba(var(--color-accent-400), 0.04) 100%)',
|
||||
border: '1px solid rgba(var(--color-accent-400), 0.25)',
|
||||
color: '#fff',
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, #3EDBB0, #2BC49A)',
|
||||
background:
|
||||
'linear-gradient(135deg, rgb(var(--color-accent-400)), rgb(var(--color-accent-500)))',
|
||||
color: '#0a2a1e',
|
||||
boxShadow: '0 4px 20px rgba(62,219,176,0.25)',
|
||||
boxShadow: '0 4px 20px rgba(var(--color-accent-400), 0.25)',
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonPro
|
||||
const isExpired = !subscription || (!subscription.is_active && !subscription.is_trial);
|
||||
const isTrial = subscription?.is_trial;
|
||||
|
||||
const accentColor = isExpired ? '#FF3B5C' : '#3EDBB0';
|
||||
const accentColor = isExpired ? '#FF3B5C' : 'rgb(var(--color-accent-400))';
|
||||
|
||||
const buttonText = isExpired
|
||||
? t('subscription.getSubscription')
|
||||
@@ -39,7 +39,7 @@ export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonPro
|
||||
style={{
|
||||
background: isExpired
|
||||
? 'linear-gradient(135deg, rgba(255,59,92,0.08), rgba(255,107,53,0.06))'
|
||||
: 'linear-gradient(135deg, rgba(62,219,176,0.08), rgba(0,229,160,0.06))',
|
||||
: 'linear-gradient(135deg, rgba(var(--color-accent-400), 0.08), rgba(var(--color-accent-400), 0.06))',
|
||||
}}
|
||||
>
|
||||
{/* Left: icon + text */}
|
||||
@@ -48,7 +48,9 @@ export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonPro
|
||||
<div
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background: isExpired ? 'rgba(255,59,92,0.12)' : 'rgba(62,219,176,0.12)',
|
||||
background: isExpired
|
||||
? 'rgba(255,59,92,0.12)'
|
||||
: 'rgba(var(--color-accent-400), 0.12)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -2843,6 +2843,7 @@
|
||||
"users": "Users",
|
||||
"tickets": "Tickets",
|
||||
"stats": "Statistics",
|
||||
"sales_stats": "Sales statistics",
|
||||
"broadcasts": "Broadcasts",
|
||||
"tariffs": "Tariffs",
|
||||
"promocodes": "Promo codes",
|
||||
@@ -3598,6 +3599,36 @@
|
||||
"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.",
|
||||
"unlinkConfirmBtn": "Confirm disconnect?",
|
||||
"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...",
|
||||
"linkingTelegram": "Connecting Telegram...",
|
||||
"returnToTelegram": "Return to Telegram to continue",
|
||||
"openTelegram": "Open Telegram",
|
||||
"continueInBrowser": "Continue authorization in the opened browser",
|
||||
"pollingTimeout": "Authorization check timed out. Refresh the page if you completed linking.",
|
||||
"providers": {
|
||||
"telegram": "Telegram",
|
||||
"email": "Email",
|
||||
"google": "Google",
|
||||
"yandex": "Yandex",
|
||||
"discord": "Discord",
|
||||
"vk": "VK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
@@ -3807,5 +3838,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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2576,6 +2576,7 @@
|
||||
"users": "کاربران",
|
||||
"tickets": "تیکتها",
|
||||
"stats": "آمار",
|
||||
"sales_stats": "آمار فروش",
|
||||
"broadcasts": "پیامهای همگانی",
|
||||
"tariffs": "تعرفهها",
|
||||
"promocodes": "کدهای تخفیف",
|
||||
@@ -3034,6 +3035,36 @@
|
||||
"promoOffers": "پیشنهادات تبلیغاتی",
|
||||
"promoOffersDesc": "دریافت پیشنهادات ویژه و تخفیفها",
|
||||
"unavailable": "تنظیمات اعلان در دسترس نیست"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "حسابهای متصل",
|
||||
"subtitle": "مدیریت روشهای ورود",
|
||||
"linked": "متصل",
|
||||
"notLinked": "متصل نیست",
|
||||
"link": "اتصال",
|
||||
"unlink": "قطع اتصال",
|
||||
"unlinkConfirm": "آیا مطمئن هستید؟ پس از قطع اتصال نمیتوانید از طریق این سرویس وارد شوید.",
|
||||
"unlinkConfirmBtn": "تأیید قطع اتصال؟",
|
||||
"cannotUnlinkLast": "نمیتوان آخرین روش ورود را قطع کرد",
|
||||
"linkSuccess": "حساب با موفقیت متصل شد",
|
||||
"unlinkSuccess": "اتصال حساب با موفقیت قطع شد",
|
||||
"linkError": "اتصال حساب ناموفق بود",
|
||||
"unlinkError": "قطع اتصال ناموفق بود",
|
||||
"goToAccounts": "حسابهای متصل",
|
||||
"linking": "در حال اتصال حساب...",
|
||||
"linkingTelegram": "در حال اتصال تلگرام...",
|
||||
"returnToTelegram": "برای ادامه به تلگرام بازگردید",
|
||||
"openTelegram": "باز کردن تلگرام",
|
||||
"continueInBrowser": "ادامه احراز هویت در مرورگر باز شده",
|
||||
"pollingTimeout": "بررسی احراز هویت منقضی شد. اگر اتصال را تکمیل کردهاید صفحه را بارگذاری مجدد کنید.",
|
||||
"providers": {
|
||||
"telegram": "تلگرام",
|
||||
"email": "ایمیل",
|
||||
"google": "گوگل",
|
||||
"yandex": "یاندکس",
|
||||
"discord": "دیسکورد",
|
||||
"vk": "VK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
@@ -3358,5 +3389,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": "در حال ادغام..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3391,6 +3391,7 @@
|
||||
"users": "Пользователи",
|
||||
"tickets": "Тикеты",
|
||||
"stats": "Статистика",
|
||||
"sales_stats": "Статистика продаж",
|
||||
"broadcasts": "Рассылки",
|
||||
"tariffs": "Тарифы",
|
||||
"promocodes": "Промокоды",
|
||||
@@ -4158,6 +4159,36 @@
|
||||
"promoOffers": "Промо-предложения",
|
||||
"promoOffersDesc": "Получать специальные предложения и скидки",
|
||||
"unavailable": "Настройки уведомлений недоступны"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Привязанные аккаунты",
|
||||
"subtitle": "Управляйте способами входа в кабинет",
|
||||
"linked": "Привязан",
|
||||
"notLinked": "Не привязан",
|
||||
"link": "Привязать",
|
||||
"unlink": "Отвязать",
|
||||
"unlinkConfirm": "Вы уверены? После отвязки вы не сможете входить через этот сервис.",
|
||||
"unlinkConfirmBtn": "Точно отвязать?",
|
||||
"cannotUnlinkLast": "Нельзя отвязать последний способ входа",
|
||||
"linkSuccess": "Аккаунт успешно привязан",
|
||||
"unlinkSuccess": "Аккаунт успешно отвязан",
|
||||
"linkError": "Не удалось привязать аккаунт",
|
||||
"unlinkError": "Не удалось отвязать аккаунт",
|
||||
"goToAccounts": "Привязанные аккаунты",
|
||||
"linking": "Привязка аккаунта...",
|
||||
"linkingTelegram": "Привязка Telegram...",
|
||||
"returnToTelegram": "Вернитесь в Telegram, чтобы продолжить",
|
||||
"openTelegram": "Открыть Telegram",
|
||||
"continueInBrowser": "Продолжите авторизацию в открывшемся браузере",
|
||||
"pollingTimeout": "Проверка авторизации истекла. Обновите страницу, если привязка завершена.",
|
||||
"providers": {
|
||||
"telegram": "Telegram",
|
||||
"email": "Email",
|
||||
"google": "Google",
|
||||
"yandex": "Яндекс",
|
||||
"discord": "Discord",
|
||||
"vk": "VK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
@@ -4370,5 +4401,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": "Объединение..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2575,6 +2575,7 @@
|
||||
"users": "用户",
|
||||
"tickets": "工单",
|
||||
"stats": "统计",
|
||||
"sales_stats": "销售统计",
|
||||
"broadcasts": "广播",
|
||||
"tariffs": "套餐",
|
||||
"promocodes": "促销码",
|
||||
@@ -3033,6 +3034,36 @@
|
||||
"promoOffers": "促销活动",
|
||||
"promoOffersDesc": "接收特别优惠和折扣",
|
||||
"unavailable": "通知设置不可用"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "关联账户",
|
||||
"subtitle": "管理您的登录方式",
|
||||
"linked": "已关联",
|
||||
"notLinked": "未关联",
|
||||
"link": "关联",
|
||||
"unlink": "取消关联",
|
||||
"unlinkConfirm": "确定吗?取消关联后,您将无法通过此服务登录。",
|
||||
"unlinkConfirmBtn": "确认取消关联?",
|
||||
"cannotUnlinkLast": "无法取消最后一种登录方式",
|
||||
"linkSuccess": "账户关联成功",
|
||||
"unlinkSuccess": "账户取消关联成功",
|
||||
"linkError": "关联账户失败",
|
||||
"unlinkError": "取消关联失败",
|
||||
"goToAccounts": "关联账户",
|
||||
"linking": "正在关联账户...",
|
||||
"linkingTelegram": "正在关联 Telegram...",
|
||||
"returnToTelegram": "返回 Telegram 继续操作",
|
||||
"openTelegram": "打开 Telegram",
|
||||
"continueInBrowser": "请在打开的浏览器中继续授权",
|
||||
"pollingTimeout": "授权检查超时。如果您已完成关联,请刷新页面。",
|
||||
"providers": {
|
||||
"telegram": "Telegram",
|
||||
"email": "邮箱",
|
||||
"google": "Google",
|
||||
"yandex": "Yandex",
|
||||
"discord": "Discord",
|
||||
"vk": "VK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
@@ -3357,5 +3388,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": "合并中..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ interface StatusBadgeProps {
|
||||
|
||||
function StatusBadge({ status, label }: StatusBadgeProps) {
|
||||
const colorMap: Record<string, string> = {
|
||||
success: 'bg-green-500/20 text-green-400',
|
||||
success: 'bg-success-500/20 text-success-400',
|
||||
denied: 'bg-red-500/20 text-red-400',
|
||||
error: 'bg-amber-500/20 text-amber-400',
|
||||
};
|
||||
@@ -205,7 +205,7 @@ interface MethodBadgeProps {
|
||||
function MethodBadge({ method }: MethodBadgeProps) {
|
||||
const colorMap: Record<string, string> = {
|
||||
GET: 'bg-blue-500/20 text-blue-400',
|
||||
POST: 'bg-green-500/20 text-green-400',
|
||||
POST: 'bg-success-500/20 text-success-400',
|
||||
PUT: 'bg-amber-500/20 text-amber-400',
|
||||
PATCH: 'bg-amber-500/20 text-amber-400',
|
||||
DELETE: 'bg-red-500/20 text-red-400',
|
||||
|
||||
@@ -93,7 +93,7 @@ function ChannelBadge({ channel }: { channel?: BroadcastChannel }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1 rounded-full bg-green-500/20 px-2 py-0.5 text-xs text-green-400">
|
||||
<span className="flex items-center gap-1 rounded-full bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
<TelegramIcon />
|
||||
<span className="mx-0.5">+</span>
|
||||
<EmailIcon />
|
||||
|
||||
@@ -1062,12 +1062,17 @@ export default function AdminDashboard() {
|
||||
className="border-b border-dark-700/50 transition-colors hover:bg-dark-800/50"
|
||||
>
|
||||
<td className="px-2 py-3">
|
||||
<div className="text-sm font-medium text-dark-100">
|
||||
{payment.display_name}
|
||||
</div>
|
||||
{payment.username && (
|
||||
<div className="text-xs text-dark-500">@{payment.username}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/admin/users/${payment.user_id}`)}
|
||||
className="text-left transition-colors hover:opacity-80"
|
||||
>
|
||||
<div className="text-sm font-medium text-dark-100 underline decoration-dark-600 underline-offset-2 hover:decoration-dark-400">
|
||||
{payment.display_name}
|
||||
</div>
|
||||
{payment.username && (
|
||||
<div className="text-xs text-dark-500">@{payment.username}</div>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
<span
|
||||
@@ -1119,9 +1124,12 @@ export default function AdminDashboard() {
|
||||
>
|
||||
{payment.type_display}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-dark-100">
|
||||
<button
|
||||
onClick={() => navigate(`/admin/users/${payment.user_id}`)}
|
||||
className="truncate text-sm font-medium text-dark-100 underline decoration-dark-600 underline-offset-2 transition-colors hover:decoration-dark-400"
|
||||
>
|
||||
{payment.display_name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="ml-2 whitespace-nowrap text-sm font-semibold text-dark-100">
|
||||
{formatAmount(payment.amount_rubles)} {currencySymbol}
|
||||
|
||||
@@ -419,7 +419,7 @@ export default function AdminPanel() {
|
||||
icon: <ChartBarIcon />,
|
||||
title: t('admin.nav.salesStats'),
|
||||
description: t('admin.panel.salesStatsDesc'),
|
||||
permission: 'stats:read',
|
||||
permission: 'sales_stats:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ function EffectBadge({ effect, className }: EffectBadgeProps) {
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold ${
|
||||
isAllow
|
||||
? 'border-green-500/30 bg-green-500/10 text-green-400'
|
||||
? 'border-success-500/30 bg-success-500/10 text-success-400'
|
||||
: 'border-red-500/30 bg-red-500/10 text-red-400'
|
||||
} ${className ?? ''}`}
|
||||
>
|
||||
@@ -329,7 +329,7 @@ export default function AdminPolicies() {
|
||||
<div className="text-xs text-dark-400">{t('admin.policies.stats.total')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{sortedPolicies.filter((p) => p.effect === 'allow').length}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.policies.stats.allow')}</div>
|
||||
|
||||
@@ -444,7 +444,7 @@ export default function AdminPolicyEdit() {
|
||||
onClick={() => setFormData((prev) => ({ ...prev, effect: 'allow' }))}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
formData.effect === 'allow'
|
||||
? 'border-green-500/50 bg-green-500/10 text-green-400'
|
||||
? 'border-success-500/50 bg-success-500/10 text-success-400'
|
||||
: 'border-dark-600 bg-dark-900 text-dark-400 hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -45,6 +45,7 @@ const PRESETS: Record<string, string[]> = {
|
||||
'promo_offers:*',
|
||||
'promo_groups:*',
|
||||
'stats:read',
|
||||
'sales_stats:read',
|
||||
'pinned_messages:*',
|
||||
'wheel:*',
|
||||
],
|
||||
|
||||
@@ -183,8 +183,8 @@ function VersionBadge({ hasUpdate }: { hasUpdate: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2.5 py-0.5 text-xs font-medium text-emerald-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-success-500/20 px-2.5 py-0.5 text-xs font-medium text-success-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success-400" />
|
||||
{t('adminUpdates.upToDate')}
|
||||
</span>
|
||||
);
|
||||
|
||||
378
src/pages/ConnectedAccounts.tsx
Normal file
378
src/pages/ConnectedAccounts.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } 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 ProviderIcon from '../components/ProviderIcon';
|
||||
import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from './OAuthCallback';
|
||||
import { getTelegramInitData } from '../hooks/useTelegramSDK';
|
||||
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
|
||||
import type { LinkedProvider } from '../types';
|
||||
|
||||
const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk'];
|
||||
|
||||
const isOAuthProvider = (provider: string): boolean => OAUTH_PROVIDERS.includes(provider);
|
||||
|
||||
const isLinkableProvider = (provider: string): boolean =>
|
||||
isOAuthProvider(provider) || provider === 'telegram';
|
||||
|
||||
// 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). */
|
||||
function TelegramLinkWidget() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!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 script = document.createElement('script');
|
||||
script.src = 'https://telegram.org/js/telegram-widget.js?22';
|
||||
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-request-access', 'write');
|
||||
script.async = true;
|
||||
|
||||
container.appendChild(script);
|
||||
|
||||
return () => {
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
};
|
||||
}, [botUsername]);
|
||||
|
||||
if (!botUsername) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div ref={containerRef} className="flex items-center" />;
|
||||
}
|
||||
|
||||
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 queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [confirmingUnlink, setConfirmingUnlink] = useState<string | null>(null);
|
||||
const [linkingProvider, setLinkingProvider] = useState<string | null>(null);
|
||||
const [waitingExternalLink, setWaitingExternalLink] = useState(false);
|
||||
const pendingLinkProvider = useRef<string | null>(null);
|
||||
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const inTelegram = useIsTelegram();
|
||||
const platform = usePlatform();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['linked-providers'],
|
||||
queryFn: () => authApi.getLinkedProviders(),
|
||||
refetchOnWindowFocus: true,
|
||||
// Poll every 5s while waiting for external browser OAuth to complete
|
||||
refetchInterval: waitingExternalLink ? 5000 : false,
|
||||
});
|
||||
|
||||
// Stop polling after 90 seconds with timeout feedback
|
||||
useEffect(() => {
|
||||
if (!waitingExternalLink) return;
|
||||
const timeout = setTimeout(() => {
|
||||
setWaitingExternalLink(false);
|
||||
pendingLinkProvider.current = null;
|
||||
// Final refresh in case link succeeded during the last polling interval
|
||||
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
|
||||
showToast({ type: 'warning', message: t('profile.accounts.pollingTimeout') });
|
||||
}, 90_000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [waitingExternalLink, showToast, t, queryClient]);
|
||||
|
||||
// Detect successful external link: stop polling when the target provider becomes linked
|
||||
useEffect(() => {
|
||||
if (!waitingExternalLink || !data || !pendingLinkProvider.current) return;
|
||||
const target = data.providers.find((p) => p.provider === pendingLinkProvider.current);
|
||||
if (target?.linked) {
|
||||
setWaitingExternalLink(false);
|
||||
pendingLinkProvider.current = null;
|
||||
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
||||
}
|
||||
}, [data, waitingExternalLink, showToast, t]);
|
||||
|
||||
const unlinkMutation = useMutation({
|
||||
mutationFn: (provider: string) => authApi.unlinkProvider(provider),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: t('profile.accounts.unlinkSuccess'),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
type: 'error',
|
||||
message: t('profile.accounts.unlinkError'),
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setConfirmingUnlink(null);
|
||||
},
|
||||
});
|
||||
|
||||
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 handleLinkOAuth = async (provider: string) => {
|
||||
if (linkingProvider) return;
|
||||
setLinkingProvider(provider);
|
||||
try {
|
||||
const { authorize_url, state } = await authApi.linkProviderInit(provider);
|
||||
if (!authorize_url || !state) {
|
||||
throw new Error('Invalid response from server');
|
||||
}
|
||||
|
||||
// Validate redirect URL — only allow HTTPS to prevent open redirect
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(authorize_url);
|
||||
} catch {
|
||||
throw new Error('Invalid OAuth redirect URL');
|
||||
}
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new Error('Invalid OAuth redirect URL');
|
||||
}
|
||||
|
||||
if (inTelegram) {
|
||||
// Mini App: open in external browser to avoid WebView OAuth restrictions.
|
||||
// The callback will use server-complete flow (auth via state token, no JWT).
|
||||
platform.openLink(authorize_url);
|
||||
setLinkingProvider(null);
|
||||
// Track which provider we're waiting to become linked
|
||||
pendingLinkProvider.current = provider;
|
||||
// Start polling for linked providers (external browser has no way to notify Mini App)
|
||||
setWaitingExternalLink(true);
|
||||
showToast({
|
||||
type: 'info',
|
||||
message: t('profile.accounts.continueInBrowser'),
|
||||
});
|
||||
} else {
|
||||
// Regular browser: navigate within the same tab.
|
||||
// Save state in sessionStorage for the callback page to verify.
|
||||
sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state);
|
||||
sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider);
|
||||
window.location.href = authorize_url;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
message: getErrorDetail(err) || t('profile.accounts.linkError'),
|
||||
});
|
||||
setLinkingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinkTelegram = async () => {
|
||||
if (linkingProvider) return;
|
||||
const initData = getTelegramInitData();
|
||||
if (!initData) return;
|
||||
|
||||
setLinkingProvider('telegram');
|
||||
try {
|
||||
const response = await authApi.linkTelegram({ init_data: initData });
|
||||
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') });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError') });
|
||||
} finally {
|
||||
setLinkingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLink = async (provider: string) => {
|
||||
if (provider === 'telegram') {
|
||||
await handleLinkTelegram();
|
||||
} else {
|
||||
await handleLinkOAuth(provider);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlink = (provider: string) => {
|
||||
if (confirmingUnlink === provider) {
|
||||
setConfirmingUnlink(null);
|
||||
unlinkMutation.mutate(provider);
|
||||
} else {
|
||||
setConfirmingUnlink(provider);
|
||||
}
|
||||
};
|
||||
|
||||
const renderLinkButton = (provider: LinkedProvider) => {
|
||||
if (provider.provider === 'telegram') {
|
||||
if (inTelegram && getTelegramInitData()) {
|
||||
// Mini App: one-click button
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={linkingProvider !== null || waitingExternalLink}
|
||||
loading={linkingProvider === 'telegram'}
|
||||
onClick={() => handleLink('telegram')}
|
||||
>
|
||||
{t('profile.accounts.link')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// Browser: Telegram Login Widget
|
||||
return <TelegramLinkWidget />;
|
||||
}
|
||||
|
||||
if (isOAuthProvider(provider.provider)) {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={linkingProvider !== null || waitingExternalLink}
|
||||
loading={linkingProvider === provider.provider}
|
||||
onClick={() => handleLink(provider.provider)}
|
||||
>
|
||||
{t('profile.accounts.link')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{isError && (
|
||||
<motion.div variants={staggerItem}>
|
||||
<Card>
|
||||
<p className="text-center text-dark-400">{t('common.error')}</p>
|
||||
</Card>
|
||||
</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={confirmingUnlink === provider.provider ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
disabled={unlinkMutation.isPending}
|
||||
loading={
|
||||
unlinkMutation.isPending && unlinkMutation.variables === provider.provider
|
||||
}
|
||||
onClick={() => handleUnlink(provider.provider)}
|
||||
onBlur={() => {
|
||||
blurTimeoutRef.current = setTimeout(() => {
|
||||
setConfirmingUnlink((cur) => (cur === provider.provider ? null : cur));
|
||||
}, 150);
|
||||
}}
|
||||
>
|
||||
{confirmingUnlink === provider.provider
|
||||
? t('profile.accounts.unlinkConfirmBtn')
|
||||
: t('profile.accounts.unlink')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
isLinkableProvider(provider.provider) && renderLinkButton(provider)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -263,7 +263,6 @@ export default function Dashboard() {
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
balanceRubles={balanceData?.balance_rubles || 0}
|
||||
subscription={subscription}
|
||||
referralCount={referralInfo?.total_referrals || 0}
|
||||
earningsRubles={referralInfo?.available_balance_rubles || 0}
|
||||
refLoading={refLoading}
|
||||
|
||||
97
src/pages/LinkTelegramCallback.tsx
Normal file
97
src/pages/LinkTelegramCallback.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { LINK_TELEGRAM_STATE_KEY } from './ConnectedAccounts';
|
||||
import { getErrorDetail } from './OAuthCallback';
|
||||
|
||||
export default function LinkTelegramCallback() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { showToast } = useToast();
|
||||
const hasRun = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRun.current) return;
|
||||
hasRun.current = true;
|
||||
|
||||
// Clear sensitive data from URL immediately
|
||||
window.history.replaceState({}, '', '/auth/link/telegram/callback');
|
||||
|
||||
const linkAccount = async () => {
|
||||
// 1. Validate CSRF state
|
||||
const csrfState = searchParams.get('csrf_state');
|
||||
const savedState = sessionStorage.getItem(LINK_TELEGRAM_STATE_KEY);
|
||||
sessionStorage.removeItem(LINK_TELEGRAM_STATE_KEY);
|
||||
|
||||
if (!csrfState || !savedState || csrfState !== savedState) {
|
||||
showToast({ type: 'error', message: t('profile.accounts.linkError') });
|
||||
navigate('/profile/accounts', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Validate required Telegram fields
|
||||
const id = searchParams.get('id');
|
||||
const firstName = searchParams.get('first_name');
|
||||
const authDate = searchParams.get('auth_date');
|
||||
const hash = searchParams.get('hash');
|
||||
|
||||
if (!id || !firstName || !authDate || !hash) {
|
||||
showToast({ type: 'error', message: t('profile.accounts.linkError') });
|
||||
navigate('/profile/accounts', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedId = parseInt(id, 10);
|
||||
const parsedAuthDate = parseInt(authDate, 10);
|
||||
|
||||
if (Number.isNaN(parsedId) || Number.isNaN(parsedAuthDate)) {
|
||||
showToast({ type: 'error', message: t('profile.accounts.linkError') });
|
||||
navigate('/profile/accounts', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authApi.linkTelegram({
|
||||
id: parsedId,
|
||||
first_name: firstName,
|
||||
last_name: searchParams.get('last_name') || undefined,
|
||||
username: searchParams.get('username') || undefined,
|
||||
photo_url: searchParams.get('photo_url') || undefined,
|
||||
auth_date: parsedAuthDate,
|
||||
hash,
|
||||
});
|
||||
|
||||
if (response.merge_required && response.merge_token) {
|
||||
navigate(`/merge/${response.merge_token}`, { replace: true });
|
||||
} else {
|
||||
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
|
||||
navigate('/profile/accounts', { replace: true });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
message: getErrorDetail(err) || t('profile.accounts.linkError'),
|
||||
});
|
||||
navigate('/profile/accounts', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
linkAccount();
|
||||
}, [searchParams, navigate, showToast, t]);
|
||||
|
||||
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.linkingTelegram')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-dark-400">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
582
src/pages/MergeAccounts.tsx
Normal file
582
src/pages/MergeAccounts.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } 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 ProviderIcon from '../components/ProviderIcon';
|
||||
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}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckCircleIcon({ className = 'h-5 w-5' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
function ProviderBadgeIcon({ provider }: { provider: string }) {
|
||||
return <ProviderIcon 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)} ₽
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Radio selection */}
|
||||
{showRadio && account.subscription && (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
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 queryClient = useQueryClient();
|
||||
|
||||
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 (only once)
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
// Don't overwrite if user already made a selection
|
||||
if (selectedUserId !== null) 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, selectedUserId]);
|
||||
|
||||
// Countdown timer (wall-clock based to avoid drift)
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const startTime = Date.now();
|
||||
const totalSeconds = data.expires_in_seconds;
|
||||
|
||||
const tick = () => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const remaining = totalSeconds - elapsed;
|
||||
if (remaining <= 0) {
|
||||
setExpiresIn(0);
|
||||
setIsExpired(true);
|
||||
clearInterval(interval);
|
||||
} else {
|
||||
setExpiresIn(remaining);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
const interval = setInterval(tick, 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) {
|
||||
showToast({ type: 'error', message: t('merge.error') });
|
||||
return;
|
||||
}
|
||||
|
||||
const { setTokens, setUser, checkAdminStatus } = useAuthStore.getState();
|
||||
setTokens(response.access_token, response.refresh_token);
|
||||
if (response.user) {
|
||||
setUser(response.user);
|
||||
}
|
||||
try {
|
||||
await checkAdminStatus();
|
||||
} catch {
|
||||
// Non-critical — admin status will be checked on next navigation
|
||||
}
|
||||
|
||||
queryClient.clear();
|
||||
showToast({ type: 'success', message: t('merge.success') });
|
||||
navigate('/profile/accounts', { 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>
|
||||
)}
|
||||
|
||||
{/* Account cards */}
|
||||
<div
|
||||
role={bothHaveSubscriptions ? 'radiogroup' : undefined}
|
||||
aria-label={bothHaveSubscriptions ? t('merge.chooseSubscription') : undefined}
|
||||
>
|
||||
<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>
|
||||
|
||||
<motion.div variants={staggerItem} className="mt-6">
|
||||
<AccountCard
|
||||
account={data.secondary}
|
||||
label={t('merge.foundAccount')}
|
||||
isSelected={selectedUserId === data.secondary.id}
|
||||
onSelect={() => setSelectedUserId(data.secondary.id)}
|
||||
showRadio={!!bothHaveSubscriptions}
|
||||
/>
|
||||
</motion.div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,14 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { authApi } from '../api/auth';
|
||||
import type { ServerCompleteResponse } from '../types';
|
||||
|
||||
// SessionStorage helpers for OAuth state
|
||||
// SessionStorage keys for OAuth LINK state (shared with ConnectedAccounts)
|
||||
export const LINK_OAUTH_STATE_KEY = 'link_oauth_state';
|
||||
export const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider';
|
||||
|
||||
// SessionStorage helpers for OAuth LOGIN state
|
||||
const OAUTH_STATE_KEY = 'oauth_state';
|
||||
const OAUTH_PROVIDER_KEY = 'oauth_provider';
|
||||
|
||||
@@ -12,73 +18,227 @@ export function saveOAuthState(state: string, provider: string): void {
|
||||
sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider);
|
||||
}
|
||||
|
||||
export function getAndClearOAuthState(): { state: string; provider: string } | null {
|
||||
const state = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||
const provider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
|
||||
/** Read link OAuth state without clearing (cleared only after successful match). */
|
||||
function peekLinkOAuthState(): { state: string; provider: string } | null {
|
||||
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
|
||||
const provider = sessionStorage.getItem(LINK_OAUTH_PROVIDER_KEY);
|
||||
if (!state || !provider) return null;
|
||||
return { state, provider };
|
||||
}
|
||||
|
||||
function clearLinkOAuthState(): void {
|
||||
sessionStorage.removeItem(LINK_OAUTH_STATE_KEY);
|
||||
sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY);
|
||||
}
|
||||
|
||||
type CallbackMode = 'login' | 'link-browser' | 'link-server';
|
||||
|
||||
export function getErrorDetail(err: unknown): string | null {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const resp = (err as { response?: { data?: { detail?: unknown } } }).response;
|
||||
const detail = resp?.data?.detail;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (detail && typeof detail === 'object' && 'message' in detail) {
|
||||
const msg = (detail as Record<string, unknown>).message;
|
||||
if (typeof msg === 'string') return msg;
|
||||
}
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function OAuthCallback() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [error, setError] = useState('');
|
||||
const [errorMode, setErrorMode] = useState<CallbackMode>('login');
|
||||
const [serverLinkResult, setServerCompleteResponse] = useState<ServerCompleteResponse | null>(
|
||||
null,
|
||||
);
|
||||
const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const hasRun = useRef(false);
|
||||
|
||||
// Handle merge redirect via useEffect (not in render)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
if (serverLinkResult?.merge_required && serverLinkResult.merge_token) {
|
||||
navigate(`/merge/${serverLinkResult.merge_token}`, { replace: true });
|
||||
}
|
||||
}, [serverLinkResult, navigate]);
|
||||
|
||||
// Prevent double-fire from React StrictMode or dependency changes
|
||||
useEffect(() => {
|
||||
// Prevent double-fire from React StrictMode
|
||||
if (hasRun.current) return;
|
||||
hasRun.current = true;
|
||||
|
||||
const authenticate = async () => {
|
||||
const code = searchParams.get('code');
|
||||
const urlState = searchParams.get('state');
|
||||
// VK ID returns device_id in callback URL (required for token exchange)
|
||||
const deviceId = searchParams.get('device_id');
|
||||
const code = searchParams.get('code');
|
||||
const urlState = searchParams.get('state');
|
||||
const deviceId = searchParams.get('device_id');
|
||||
|
||||
if (!code || !urlState) {
|
||||
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||
if (!code || !urlState) {
|
||||
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine callback mode:
|
||||
// 1. Link state in sessionStorage → browser linking flow
|
||||
// 2. Login state in sessionStorage → login flow
|
||||
// 3. Neither → opened from external browser (Mini App flow) → server-complete
|
||||
let mode: CallbackMode = 'link-server';
|
||||
let provider: string | undefined;
|
||||
let state: string | undefined;
|
||||
|
||||
const linkSaved = peekLinkOAuthState();
|
||||
if (linkSaved && linkSaved.state === urlState) {
|
||||
clearLinkOAuthState();
|
||||
mode = 'link-browser';
|
||||
provider = linkSaved.provider;
|
||||
state = linkSaved.state;
|
||||
} else {
|
||||
// Peek at login state first; only clear if it matches URL state
|
||||
const loginState = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||
const loginProvider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
|
||||
if (loginState && loginProvider && loginState === urlState) {
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
|
||||
mode = 'login';
|
||||
provider = loginProvider;
|
||||
state = loginState;
|
||||
}
|
||||
}
|
||||
|
||||
const handle = async () => {
|
||||
// Clear sensitive OAuth params (code, state) from URL immediately for all modes
|
||||
window.history.replaceState({}, '', '/auth/oauth/callback');
|
||||
|
||||
if (mode === 'link-browser' && provider && state) {
|
||||
// Browser linking: user is authenticated, complete via JWT-protected endpoint
|
||||
try {
|
||||
const response = await authApi.linkProviderCallback(
|
||||
provider,
|
||||
code,
|
||||
state,
|
||||
deviceId ?? undefined,
|
||||
);
|
||||
if (response.merge_required && response.merge_token) {
|
||||
navigate(`/merge/${response.merge_token}`, { replace: true });
|
||||
} else {
|
||||
navigate('/profile/accounts', { replace: true });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setErrorMode('link-browser');
|
||||
setError(getErrorDetail(err) || t('profile.accounts.linkError'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get saved state from sessionStorage
|
||||
const saved = getAndClearOAuthState();
|
||||
if (!saved) {
|
||||
setError(t('auth.oauthExpired', 'OAuth session expired. Please try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate state match
|
||||
if (saved.state !== urlState) {
|
||||
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||
if (mode === 'login' && provider && state) {
|
||||
// Login flow
|
||||
if (isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loginWithOAuth(provider, code, state, deviceId);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const detail = getErrorDetail(err);
|
||||
setError(detail || t('auth.oauthError', 'Authorization was denied or failed'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// mode === 'link-server': No sessionStorage state found.
|
||||
// This happens when OAuth was opened in external browser from Mini App.
|
||||
// Complete linking via state-token-authenticated server endpoint.
|
||||
try {
|
||||
await loginWithOAuth(saved.provider, code, urlState, deviceId);
|
||||
navigate('/', { replace: true });
|
||||
// Provider is resolved server-side from the state token in Redis.
|
||||
const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined);
|
||||
setServerCompleteResponse(response);
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||
(err instanceof Error ? err.message : null);
|
||||
setError(detail || t('auth.oauthError', 'Authorization was denied or failed'));
|
||||
setErrorMode('link-server');
|
||||
setError(getErrorDetail(err) || t('profile.accounts.linkError'));
|
||||
}
|
||||
};
|
||||
|
||||
authenticate();
|
||||
handle();
|
||||
}, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]);
|
||||
|
||||
// Server-complete result: show success with "Return to Telegram" link
|
||||
// (merge redirect is handled by the useEffect above)
|
||||
if (
|
||||
serverLinkResult &&
|
||||
serverLinkResult.success &&
|
||||
!(serverLinkResult.merge_required && serverLinkResult.merge_token)
|
||||
) {
|
||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
||||
const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
||||
<div className="relative w-full max-w-md text-center">
|
||||
<div className="card">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-success-500/20">
|
||||
<svg
|
||||
className="h-8 w-8 text-success-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mb-2 text-lg font-semibold text-dark-50">
|
||||
{t('profile.accounts.linkSuccess')}
|
||||
</h2>
|
||||
<p className="mb-6 text-sm text-dark-400">{t('profile.accounts.returnToTelegram')}</p>
|
||||
{telegramLink && (
|
||||
<a
|
||||
href={telegramLink}
|
||||
className="btn-primary inline-block w-full rounded-lg bg-accent-500 px-6 py-3 text-center font-medium text-dark-950 no-underline transition-colors hover:bg-accent-400"
|
||||
>
|
||||
{t('profile.accounts.openTelegram')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const isServerMode = errorMode === 'link-server';
|
||||
const isLinkBrowserMode = errorMode === 'link-browser';
|
||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
||||
const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
|
||||
|
||||
const errorAction =
|
||||
isServerMode && telegramLink ? (
|
||||
<a
|
||||
href={telegramLink}
|
||||
className="btn-primary inline-block w-full rounded-lg bg-accent-500 px-6 py-3 text-center font-medium text-dark-950 no-underline transition-colors hover:bg-accent-400"
|
||||
>
|
||||
{t('profile.accounts.openTelegram')}
|
||||
</a>
|
||||
) : isLinkBrowserMode ? (
|
||||
<button
|
||||
onClick={() => navigate('/profile/accounts', { replace: true })}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{t('profile.accounts.backToAccounts', 'Back to accounts')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => navigate('/login', { replace: true })}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{t('auth.backToLogin', 'Back to login')}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
||||
@@ -101,12 +261,7 @@ export default function OAuthCallback() {
|
||||
</div>
|
||||
<h2 className="mb-2 text-lg font-semibold text-dark-50">{t('auth.loginFailed')}</h2>
|
||||
<p className="mb-6 text-sm text-dark-400">{error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/login', { replace: true })}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{t('auth.backToLogin', 'Back to login')}
|
||||
</button>
|
||||
{errorAction}
|
||||
</div>
|
||||
</div>
|
||||
</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}>
|
||||
|
||||
@@ -447,7 +447,7 @@ export default function Subscription() {
|
||||
style={{
|
||||
background: g.cardBg,
|
||||
border: subscription.is_trial
|
||||
? '1px solid rgba(62,219,176,0.15)'
|
||||
? '1px solid rgba(var(--color-accent-400), 0.15)'
|
||||
: isDark
|
||||
? `1px solid ${g.cardBorder}`
|
||||
: `1px solid ${zone.mainHex}25`,
|
||||
@@ -535,21 +535,21 @@ export default function Subscription() {
|
||||
className="mb-6 rounded-[14px] p-4"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(62,219,176,0.08), rgba(62,219,176,0.03))',
|
||||
border: '1px solid rgba(62,219,176,0.12)',
|
||||
'linear-gradient(135deg, rgba(var(--color-accent-400), 0.08), rgba(var(--color-accent-400), 0.03))',
|
||||
border: '1px solid rgba(var(--color-accent-400), 0.12)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
|
||||
style={{ background: 'rgba(62,219,176,0.12)' }}
|
||||
style={{ background: 'rgba(var(--color-accent-400), 0.12)' }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#3EDBB0"
|
||||
stroke="rgb(var(--color-accent-400))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -559,7 +559,10 @@ export default function Subscription() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold" style={{ color: '#3EDBB0' }}>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'rgb(var(--color-accent-400))' }}
|
||||
>
|
||||
{t('subscription.trialInfo.title')}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-dark-50/40">
|
||||
@@ -569,7 +572,7 @@ export default function Subscription() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="font-mono text-[12px] font-semibold"
|
||||
style={{ color: '#3EDBB0' }}
|
||||
style={{ color: 'rgb(var(--color-accent-400))' }}
|
||||
>
|
||||
{subscription.days_left > 0
|
||||
? t('subscription.days', { count: subscription.days_left })
|
||||
@@ -582,7 +585,7 @@ export default function Subscription() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="font-mono text-[12px] font-semibold"
|
||||
style={{ color: '#3EDBB0' }}
|
||||
style={{ color: 'rgb(var(--color-accent-400))' }}
|
||||
>
|
||||
{subscription.traffic_limit_gb || '∞'} {t('common.units.gb')}
|
||||
</span>
|
||||
@@ -593,7 +596,7 @@ export default function Subscription() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="font-mono text-[12px] font-semibold"
|
||||
style={{ color: '#3EDBB0' }}
|
||||
style={{ color: 'rgb(var(--color-accent-400))' }}
|
||||
>
|
||||
{subscription.device_limit}
|
||||
</span>
|
||||
@@ -749,9 +752,11 @@ export default function Subscription() {
|
||||
onClick={copyUrl}
|
||||
className="flex h-auto items-center rounded-[10px] px-3 transition-colors duration-300"
|
||||
style={{
|
||||
background: copied ? 'rgba(62,219,176,0.12)' : g.innerBorder,
|
||||
border: copied ? '1px solid rgba(62,219,176,0.2)' : `1px solid ${g.trackBg}`,
|
||||
color: copied ? '#3EDBB0' : g.textMuted,
|
||||
background: copied ? 'rgba(var(--color-accent-400), 0.12)' : g.innerBorder,
|
||||
border: copied
|
||||
? '1px solid rgba(var(--color-accent-400), 0.2)'
|
||||
: `1px solid ${g.trackBg}`,
|
||||
color: copied ? 'rgb(var(--color-accent-400))' : g.textMuted,
|
||||
}}
|
||||
title={t('subscription.copyLink')}
|
||||
>
|
||||
@@ -979,12 +984,12 @@ export default function Subscription() {
|
||||
className="rounded-[10px] px-4 py-2 text-sm font-semibold transition-colors duration-300"
|
||||
style={{
|
||||
background: subscription.is_daily_paused
|
||||
? 'rgba(62,219,176,0.12)'
|
||||
? 'rgba(var(--color-accent-400), 0.12)'
|
||||
: 'rgba(255,184,0,0.12)',
|
||||
border: subscription.is_daily_paused
|
||||
? '1px solid rgba(62,219,176,0.2)'
|
||||
? '1px solid rgba(var(--color-accent-400), 0.2)'
|
||||
: '1px solid rgba(255,184,0,0.2)',
|
||||
color: subscription.is_daily_paused ? '#3EDBB0' : '#FFB800',
|
||||
color: subscription.is_daily_paused ? 'rgb(var(--color-accent-400))' : '#FFB800',
|
||||
}}
|
||||
>
|
||||
{pauseMutation.isPending ? (
|
||||
|
||||
@@ -412,7 +412,8 @@ export default function SubscriptionPurchase() {
|
||||
<div
|
||||
className="mb-6 rounded-[14px] p-4"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(255,184,0,0.08), rgba(62,219,176,0.06))',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(255,184,0,0.08), rgba(var(--color-accent-400),0.06))',
|
||||
border: '1px solid rgba(255,184,0,0.15)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function VerifyEmail() {
|
||||
|
||||
{status === 'success' && (
|
||||
<div>
|
||||
<div className="mb-4 text-5xl text-green-500 sm:text-6xl">✓</div>
|
||||
<div className="mb-4 text-5xl text-success-500 sm:text-6xl">✓</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 sm:text-xl">
|
||||
{t('emailVerification.success')}
|
||||
</h2>
|
||||
|
||||
@@ -636,3 +636,61 @@ 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;
|
||||
}
|
||||
|
||||
export interface ServerCompleteResponse extends LinkCallbackResponse {
|
||||
provider: string;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ interface TrafficZoneResult {
|
||||
labelKey: string;
|
||||
gradientFrom: string;
|
||||
gradientTo: string;
|
||||
/** CSS variable for the main zone color: `rgb(var(--color-accent-400))` */
|
||||
mainVar: string;
|
||||
/** Raw CSS variable reference for opacity manipulation: `var(--color-accent-400)` */
|
||||
mainVarRaw: string;
|
||||
/** Key into ThemeColors for resolving mainHex at runtime */
|
||||
colorKey: TrafficColorKey;
|
||||
}
|
||||
@@ -22,6 +26,8 @@ const ZONES: Record<TrafficZone, Omit<TrafficZoneResult, 'zone'>> = {
|
||||
labelKey: 'dashboard.zone.normal',
|
||||
gradientFrom: 'rgb(var(--color-accent-500))',
|
||||
gradientTo: 'rgb(var(--color-accent-400))',
|
||||
mainVar: 'rgb(var(--color-accent-400))',
|
||||
mainVarRaw: 'var(--color-accent-400)',
|
||||
colorKey: 'accent',
|
||||
},
|
||||
warning: {
|
||||
@@ -31,6 +37,8 @@ const ZONES: Record<TrafficZone, Omit<TrafficZoneResult, 'zone'>> = {
|
||||
labelKey: 'dashboard.zone.warning',
|
||||
gradientFrom: 'rgb(var(--color-warning-500))',
|
||||
gradientTo: 'rgb(var(--color-warning-400))',
|
||||
mainVar: 'rgb(var(--color-warning-400))',
|
||||
mainVarRaw: 'var(--color-warning-400)',
|
||||
colorKey: 'warning',
|
||||
},
|
||||
danger: {
|
||||
@@ -40,6 +48,8 @@ const ZONES: Record<TrafficZone, Omit<TrafficZoneResult, 'zone'>> = {
|
||||
labelKey: 'dashboard.zone.danger',
|
||||
gradientFrom: 'rgb(var(--color-warning-600))',
|
||||
gradientTo: 'rgb(var(--color-warning-400))',
|
||||
mainVar: 'rgb(var(--color-warning-400))',
|
||||
mainVarRaw: 'var(--color-warning-400)',
|
||||
colorKey: 'warning',
|
||||
},
|
||||
critical: {
|
||||
@@ -49,6 +59,8 @@ const ZONES: Record<TrafficZone, Omit<TrafficZoneResult, 'zone'>> = {
|
||||
labelKey: 'dashboard.zone.critical',
|
||||
gradientFrom: 'rgb(var(--color-error-500))',
|
||||
gradientTo: 'rgb(var(--color-error-400))',
|
||||
mainVar: 'rgb(var(--color-error-400))',
|
||||
mainVarRaw: 'var(--color-error-400)',
|
||||
colorKey: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -273,8 +273,8 @@ export default {
|
||||
'50%': { opacity: '0.5', transform: 'scale(0.7)' },
|
||||
},
|
||||
trialGlow: {
|
||||
'0%, 100%': { boxShadow: '0 0 15px rgba(62, 219, 176, 0.06)' },
|
||||
'50%': { boxShadow: '0 0 30px rgba(62, 219, 176, 0.12)' },
|
||||
'0%, 100%': { boxShadow: '0 0 15px rgba(var(--color-accent-400), 0.06)' },
|
||||
'50%': { boxShadow: '0 0 30px rgba(var(--color-accent-400), 0.12)' },
|
||||
},
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
|
||||
Reference in New Issue
Block a user