mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge pull request #292 from BEDOLAGA-DEV/dev
fix: resolve telegram auth token expiration and clean up codebase
This commit is contained in:
@@ -31,7 +31,7 @@ export default tseslint.config(
|
|||||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
'no-empty': 'warn',
|
'no-empty': ['warn', { allowEmptyCatch: true }],
|
||||||
'no-eval': 'error',
|
'no-eval': 'error',
|
||||||
'no-implied-eval': 'error',
|
'no-implied-eval': 'error',
|
||||||
'no-new-func': 'error',
|
'no-new-func': 'error',
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ function TelegramBackButton() {
|
|||||||
} else {
|
} else {
|
||||||
showBackButton();
|
showBackButton();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Back button not mounted
|
|
||||||
}
|
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
// Stable handler — ref prevents re-subscription on every render
|
// Stable handler — ref prevents re-subscription on every render
|
||||||
@@ -52,15 +50,11 @@ function TelegramBackButton() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
onBackButtonClick(handler);
|
onBackButtonClick(handler);
|
||||||
} catch {
|
} catch {}
|
||||||
// Back button not mounted
|
|
||||||
}
|
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
try {
|
||||||
offBackButtonClick(handler);
|
offBackButtonClick(handler);
|
||||||
} catch {
|
} catch {}
|
||||||
// Back button not mounted
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [handler]);
|
}, [handler]);
|
||||||
|
|
||||||
|
|||||||
@@ -147,8 +147,6 @@ export const adminApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ Dashboard Stats Types ============
|
|
||||||
|
|
||||||
export interface NodeStatus {
|
export interface NodeStatus {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -239,8 +237,6 @@ export interface DashboardStats {
|
|||||||
tariff_stats?: TariffStats;
|
tariff_stats?: TariffStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Extended Stats Types ============
|
|
||||||
|
|
||||||
export interface TopReferrerItem {
|
export interface TopReferrerItem {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
telegram_id: number | null;
|
telegram_id: number | null;
|
||||||
@@ -318,8 +314,6 @@ export interface SystemInfo {
|
|||||||
subscriptions_active: number;
|
subscriptions_active: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Dashboard Stats API ============
|
|
||||||
|
|
||||||
export const statsApi = {
|
export const statsApi = {
|
||||||
// Get system info
|
// Get system info
|
||||||
getSystemInfo: async (): Promise<SystemInfo> => {
|
getSystemInfo: async (): Promise<SystemInfo> => {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
|
||||||
// ============ Types ============
|
|
||||||
|
|
||||||
// Status & Connection
|
// Status & Connection
|
||||||
export interface ConnectionStatus {
|
export interface ConnectionStatus {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -224,8 +222,6 @@ export interface SyncResponse {
|
|||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ API ============
|
|
||||||
|
|
||||||
export const adminRemnawaveApi = {
|
export const adminRemnawaveApi = {
|
||||||
// Status & Connection
|
// Status & Connection
|
||||||
getStatus: async (): Promise<RemnaWaveStatusResponse> => {
|
getStatus: async (): Promise<RemnaWaveStatusResponse> => {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
// ============ Types ============
|
|
||||||
|
|
||||||
export interface ReleaseItem {
|
export interface ReleaseItem {
|
||||||
tag_name: string;
|
tag_name: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -22,8 +20,6 @@ export interface ReleasesResponse {
|
|||||||
cabinet: ProjectReleasesInfo;
|
cabinet: ProjectReleasesInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ API ============
|
|
||||||
|
|
||||||
export const adminUpdatesApi = {
|
export const adminUpdatesApi = {
|
||||||
getReleases: async (): Promise<ReleasesResponse> => {
|
getReleases: async (): Promise<ReleasesResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/updates/releases');
|
const response = await apiClient.get('/cabinet/admin/updates/releases');
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
// ============ Types ============
|
|
||||||
|
|
||||||
export interface TrafficPurchaseInfo {
|
export interface TrafficPurchaseInfo {
|
||||||
id: number;
|
id: number;
|
||||||
traffic_gb: number;
|
traffic_gb: number;
|
||||||
@@ -381,8 +379,6 @@ export interface AdminUserGiftsResponse {
|
|||||||
received_total: number;
|
received_total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ API ============
|
|
||||||
|
|
||||||
export const adminUsersApi = {
|
export const adminUsersApi = {
|
||||||
// List users
|
// List users
|
||||||
getUsers: async (
|
getUsers: async (
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
// Telegram WebApp authentication
|
|
||||||
loginTelegram: async (
|
loginTelegram: async (
|
||||||
initData: string,
|
initData: string,
|
||||||
campaignSlug?: string | null,
|
campaignSlug?: string | null,
|
||||||
@@ -27,7 +26,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Telegram Login Widget authentication
|
|
||||||
loginTelegramWidget: async (
|
loginTelegramWidget: async (
|
||||||
data: {
|
data: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -49,7 +47,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Telegram OIDC authentication (popup flow with id_token)
|
|
||||||
loginTelegramOIDC: async (
|
loginTelegramOIDC: async (
|
||||||
idToken: string,
|
idToken: string,
|
||||||
campaignSlug?: string | null,
|
campaignSlug?: string | null,
|
||||||
@@ -63,7 +60,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Email login
|
|
||||||
loginEmail: async (
|
loginEmail: async (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
@@ -79,7 +75,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Register email (link to existing Telegram account)
|
|
||||||
registerEmail: async (
|
registerEmail: async (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
@@ -91,8 +86,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Register standalone email account (no Telegram required)
|
|
||||||
// Returns message - user must verify email before login
|
|
||||||
registerEmailStandalone: async (data: {
|
registerEmailStandalone: async (data: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -107,7 +100,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verify email and get auth tokens
|
|
||||||
verifyEmail: async (token: string, campaignSlug?: string | null): Promise<AuthResponse> => {
|
verifyEmail: async (token: string, campaignSlug?: string | null): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', {
|
||||||
token,
|
token,
|
||||||
@@ -116,13 +108,11 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Resend verification email
|
|
||||||
resendVerification: async (): Promise<{ message: string }> => {
|
resendVerification: async (): Promise<{ message: string }> => {
|
||||||
const response = await apiClient.post('/cabinet/auth/email/resend');
|
const response = await apiClient.post('/cabinet/auth/email/resend');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Refresh token
|
|
||||||
refreshToken: async (refreshToken: string): Promise<TokenResponse> => {
|
refreshToken: async (refreshToken: string): Promise<TokenResponse> => {
|
||||||
const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', {
|
const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', {
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
@@ -130,20 +120,17 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Logout
|
|
||||||
logout: async (refreshToken: string): Promise<void> => {
|
logout: async (refreshToken: string): Promise<void> => {
|
||||||
await apiClient.post('/cabinet/auth/logout', {
|
await apiClient.post('/cabinet/auth/logout', {
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Forgot password
|
|
||||||
forgotPassword: async (email: string): Promise<{ message: string }> => {
|
forgotPassword: async (email: string): Promise<{ message: string }> => {
|
||||||
const response = await apiClient.post('/cabinet/auth/password/forgot', { email });
|
const response = await apiClient.post('/cabinet/auth/password/forgot', { email });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reset password
|
|
||||||
resetPassword: async (token: string, password: string): Promise<{ message: string }> => {
|
resetPassword: async (token: string, password: string): Promise<{ message: string }> => {
|
||||||
const response = await apiClient.post('/cabinet/auth/password/reset', {
|
const response = await apiClient.post('/cabinet/auth/password/reset', {
|
||||||
token,
|
token,
|
||||||
@@ -152,13 +139,11 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get current user
|
|
||||||
getMe: async (): Promise<User> => {
|
getMe: async (): Promise<User> => {
|
||||||
const response = await apiClient.get<User>('/cabinet/auth/me');
|
const response = await apiClient.get<User>('/cabinet/auth/me');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Request email change - sends verification code to new email
|
|
||||||
requestEmailChange: async (
|
requestEmailChange: async (
|
||||||
newEmail: string,
|
newEmail: string,
|
||||||
): Promise<{ message: string; new_email: string; expires_in_minutes: number }> => {
|
): Promise<{ message: string; new_email: string; expires_in_minutes: number }> => {
|
||||||
@@ -168,7 +153,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verify email change with code
|
|
||||||
verifyEmailChange: async (code: string): Promise<{ message: string; email: string }> => {
|
verifyEmailChange: async (code: string): Promise<{ message: string; email: string }> => {
|
||||||
const response = await apiClient.post('/cabinet/auth/email/change/verify', {
|
const response = await apiClient.post('/cabinet/auth/email/change/verify', {
|
||||||
code,
|
code,
|
||||||
@@ -176,7 +160,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// OAuth: get enabled providers
|
|
||||||
getOAuthProviders: async (): Promise<{ providers: OAuthProvider[] }> => {
|
getOAuthProviders: async (): Promise<{ providers: OAuthProvider[] }> => {
|
||||||
const response = await apiClient.get<{ providers: OAuthProvider[] }>(
|
const response = await apiClient.get<{ providers: OAuthProvider[] }>(
|
||||||
'/cabinet/auth/oauth/providers',
|
'/cabinet/auth/oauth/providers',
|
||||||
@@ -184,7 +167,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// OAuth: get authorization URL
|
|
||||||
getOAuthAuthorizeUrl: async (
|
getOAuthAuthorizeUrl: async (
|
||||||
provider: string,
|
provider: string,
|
||||||
): Promise<{ authorize_url: string; state: string }> => {
|
): Promise<{ authorize_url: string; state: string }> => {
|
||||||
@@ -194,7 +176,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// OAuth: callback (exchange code for tokens)
|
|
||||||
oauthCallback: async (
|
oauthCallback: async (
|
||||||
provider: string,
|
provider: string,
|
||||||
code: string,
|
code: string,
|
||||||
@@ -216,7 +197,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Account linking
|
|
||||||
getLinkedProviders: async (): Promise<LinkedProvidersResponse> => {
|
getLinkedProviders: async (): Promise<LinkedProvidersResponse> => {
|
||||||
const response = await apiClient.get<LinkedProvidersResponse>(
|
const response = await apiClient.get<LinkedProvidersResponse>(
|
||||||
'/cabinet/auth/account/linked-providers',
|
'/cabinet/auth/account/linked-providers',
|
||||||
@@ -248,7 +228,6 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Link Telegram account (Mini App initData, OIDC id_token, or Login Widget data)
|
|
||||||
linkTelegram: async (
|
linkTelegram: async (
|
||||||
data:
|
data:
|
||||||
| { init_data: string }
|
| { init_data: string }
|
||||||
@@ -270,8 +249,6 @@ export const authApi = {
|
|||||||
return response.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 (
|
linkServerComplete: async (
|
||||||
code: string,
|
code: string,
|
||||||
state: string,
|
state: string,
|
||||||
@@ -295,13 +272,11 @@ export const authApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Auto-login from guest purchase success page
|
|
||||||
autoLogin: async (token: string): Promise<AuthResponse> => {
|
autoLogin: async (token: string): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/login/auto', { token });
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/login/auto', { token });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Account merge (no JWT required)
|
|
||||||
getMergePreview: async (mergeToken: string): Promise<MergePreviewResponse> => {
|
getMergePreview: async (mergeToken: string): Promise<MergePreviewResponse> => {
|
||||||
const response = await apiClient.get<MergePreviewResponse>(
|
const response = await apiClient.get<MergePreviewResponse>(
|
||||||
`/cabinet/auth/merge/${encodeURIComponent(mergeToken)}`,
|
`/cabinet/auth/merge/${encodeURIComponent(mergeToken)}`,
|
||||||
|
|||||||
@@ -88,9 +88,7 @@ export const getCachedBranding = (): BrandingInfo | null => {
|
|||||||
export const setCachedBranding = (branding: BrandingInfo) => {
|
export const setCachedBranding = (branding: BrandingInfo) => {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding));
|
sessionStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding));
|
||||||
} catch {
|
} catch {}
|
||||||
// sessionStorage not available
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Preload logo image as blob to hide backend URL
|
// Preload logo image as blob to hide backend URL
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ import { API } from '../config/constants';
|
|||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
// Настраиваем endpoint для refresh
|
|
||||||
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`);
|
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`);
|
||||||
|
|
||||||
// CSRF token management
|
|
||||||
const CSRF_COOKIE_NAME = 'csrf_token';
|
const CSRF_COOKIE_NAME = 'csrf_token';
|
||||||
const CSRF_HEADER_NAME = 'X-CSRF-Token';
|
const CSRF_HEADER_NAME = 'X-CSRF-Token';
|
||||||
|
|
||||||
@@ -34,7 +32,6 @@ function ensureCsrfToken(): string {
|
|||||||
let token = getCsrfToken();
|
let token = getCsrfToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
token = generateCsrfToken();
|
token = generateCsrfToken();
|
||||||
// Set cookie with SameSite=Strict for CSRF protection
|
|
||||||
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`;
|
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
@@ -49,9 +46,7 @@ const getTelegramInitData = (): string | null => {
|
|||||||
tokenStorage.setTelegramInitData(raw);
|
tokenStorage.setTelegramInitData(raw);
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Not in Telegram or SDK not initialized
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokenStorage.getTelegramInitData();
|
return tokenStorage.getTelegramInitData();
|
||||||
};
|
};
|
||||||
@@ -64,7 +59,6 @@ export const apiClient = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth endpoints that don't need Bearer token or token refresh
|
|
||||||
const AUTH_ENDPOINTS = [
|
const AUTH_ENDPOINTS = [
|
||||||
'/cabinet/auth/telegram',
|
'/cabinet/auth/telegram',
|
||||||
'/cabinet/auth/telegram/widget',
|
'/cabinet/auth/telegram/widget',
|
||||||
@@ -85,15 +79,11 @@ function isAuthEndpoint(url: string | undefined): boolean {
|
|||||||
return AUTH_ENDPOINTS.some((endpoint) => url.startsWith(endpoint));
|
return AUTH_ENDPOINTS.some((endpoint) => url.startsWith(endpoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request interceptor - add auth token with expiration check
|
|
||||||
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||||
// Skip token refresh and Bearer header for auth endpoints
|
|
||||||
// These endpoints authenticate via init_data/credentials, not Bearer tokens
|
|
||||||
if (!isAuthEndpoint(config.url)) {
|
if (!isAuthEndpoint(config.url)) {
|
||||||
let token = tokenStorage.getAccessToken();
|
let token = tokenStorage.getAccessToken();
|
||||||
|
|
||||||
if (token && isTokenExpired(token)) {
|
if (token && isTokenExpired(token)) {
|
||||||
// Access token expired — try refresh
|
|
||||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
token = newToken;
|
token = newToken;
|
||||||
@@ -103,7 +93,6 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
} else if (!token && tokenStorage.getRefreshToken()) {
|
} else if (!token && tokenStorage.getRefreshToken()) {
|
||||||
// No access token (e.g. tab reopen) but refresh token exists — restore session
|
|
||||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
token = newToken;
|
token = newToken;
|
||||||
@@ -115,12 +104,16 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const telegramInitData = getTelegramInitData();
|
const isTelegramAuthEndpoint =
|
||||||
if (telegramInitData && config.headers) {
|
config.url?.startsWith('/cabinet/auth/telegram') ||
|
||||||
config.headers['X-Telegram-Init-Data'] = telegramInitData;
|
config.url?.startsWith('/cabinet/auth/account/link/telegram');
|
||||||
|
if (isTelegramAuthEndpoint) {
|
||||||
|
const telegramInitData = getTelegramInitData();
|
||||||
|
if (telegramInitData && config.headers) {
|
||||||
|
config.headers['X-Telegram-Init-Data'] = telegramInitData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add CSRF token for state-changing methods
|
|
||||||
const method = config.method?.toUpperCase();
|
const method = config.method?.toUpperCase();
|
||||||
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
|
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
|
||||||
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken();
|
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken();
|
||||||
@@ -129,7 +122,6 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom error types for special handling
|
|
||||||
export interface MaintenanceError {
|
export interface MaintenanceError {
|
||||||
code: 'maintenance';
|
code: 'maintenance';
|
||||||
message: string;
|
message: string;
|
||||||
@@ -180,13 +172,11 @@ export function isBlacklistedError(
|
|||||||
return err.response?.status === 403 && err.response?.data?.detail?.code === 'blacklisted';
|
return err.response?.status === 403 && err.response?.data?.detail?.code === 'blacklisted';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription)
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
// Handle maintenance mode (503)
|
|
||||||
if (isMaintenanceError(error)) {
|
if (isMaintenanceError(error)) {
|
||||||
const detail = (error.response?.data as { detail: MaintenanceError }).detail;
|
const detail = (error.response?.data as { detail: MaintenanceError }).detail;
|
||||||
useBlockingStore.getState().setMaintenance({
|
useBlockingStore.getState().setMaintenance({
|
||||||
@@ -196,7 +186,6 @@ apiClient.interceptors.response.use(
|
|||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle channel subscription required (403)
|
|
||||||
if (isChannelSubscriptionError(error)) {
|
if (isChannelSubscriptionError(error)) {
|
||||||
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail;
|
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail;
|
||||||
useBlockingStore.getState().setChannelSubscription({
|
useBlockingStore.getState().setChannelSubscription({
|
||||||
@@ -207,7 +196,6 @@ apiClient.interceptors.response.use(
|
|||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle blacklisted user (403)
|
|
||||||
if (isBlacklistedError(error)) {
|
if (isBlacklistedError(error)) {
|
||||||
const detail = (error.response?.data as { detail: BlacklistedError }).detail;
|
const detail = (error.response?.data as { detail: BlacklistedError }).detail;
|
||||||
useBlockingStore.getState().setBlacklisted({
|
useBlockingStore.getState().setBlacklisted({
|
||||||
@@ -216,14 +204,10 @@ apiClient.interceptors.response.use(
|
|||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента
|
|
||||||
const requestUrl = originalRequest.url || '';
|
const requestUrl = originalRequest.url || '';
|
||||||
const isLoginEndpoint = isAuthEndpoint(requestUrl);
|
|
||||||
|
|
||||||
if (isLoginEndpoint) {
|
if (isAuthEndpoint(requestUrl)) {
|
||||||
// Пробрасываем ошибку в компонент для показа сообщения пользователю
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +220,6 @@ apiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
return apiClient(originalRequest);
|
return apiClient(originalRequest);
|
||||||
} else {
|
} else {
|
||||||
// Refresh не удался
|
|
||||||
tokenStorage.clearTokens();
|
tokenStorage.clearTokens();
|
||||||
safeRedirectToLogin();
|
safeRedirectToLogin();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
|
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Public types
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export interface LandingFeature {
|
export interface LandingFeature {
|
||||||
icon: string;
|
icon: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -130,10 +126,6 @@ export interface PurchaseStatus {
|
|||||||
bot_link: string | null;
|
bot_link: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Locale helpers
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/** Locale dict for multi-language text fields (admin API) */
|
/** Locale dict for multi-language text fields (admin API) */
|
||||||
export type LocaleDict = Record<string, string>;
|
export type LocaleDict = Record<string, string>;
|
||||||
|
|
||||||
@@ -148,10 +140,6 @@ export const LOCALE_META: Record<SupportedLocale, { flag: string; name: string;
|
|||||||
fa: { flag: '\u{1F1EE}\u{1F1F7}', name: 'FA', rtl: true },
|
fa: { flag: '\u{1F1EE}\u{1F1F7}', name: 'FA', rtl: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Admin types
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/** Admin feature type with localized title/description */
|
/** Admin feature type with localized title/description */
|
||||||
export interface AdminLandingFeature {
|
export interface AdminLandingFeature {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -232,12 +220,7 @@ export interface LandingCreateRequest {
|
|||||||
|
|
||||||
export type LandingUpdateRequest = Partial<LandingCreateRequest>;
|
export type LandingUpdateRequest = Partial<LandingCreateRequest>;
|
||||||
|
|
||||||
/**
|
/** Extract best display string from a LocaleDict: ru -> en -> first available -> '' */
|
||||||
* Normalize a value that might be a plain string (old API) or a LocaleDict.
|
|
||||||
* If it's a string, wraps it as `{ ru: value }`.
|
|
||||||
* If null/undefined, returns the fallback.
|
|
||||||
*/
|
|
||||||
/** Extract best display string from a LocaleDict: ru → en → first available → '' */
|
|
||||||
export function resolveLocaleDisplay(dict: LocaleDict | string | null | undefined): string {
|
export function resolveLocaleDisplay(dict: LocaleDict | string | null | undefined): string {
|
||||||
if (!dict) return '';
|
if (!dict) return '';
|
||||||
if (typeof dict === 'string') return dict;
|
if (typeof dict === 'string') return dict;
|
||||||
@@ -253,10 +236,6 @@ export function toLocaleDict(
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Public API
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export const landingApi = {
|
export const landingApi = {
|
||||||
getConfig: async (slug: string, lang?: string): Promise<LandingConfig> => {
|
getConfig: async (slug: string, lang?: string): Promise<LandingConfig> => {
|
||||||
const params = lang ? `?lang=${lang}` : '';
|
const params = lang ? `?lang=${lang}` : '';
|
||||||
@@ -280,10 +259,6 @@ export const landingApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Admin stats types
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export interface LandingDailyStat {
|
export interface LandingDailyStat {
|
||||||
date: string;
|
date: string;
|
||||||
purchases: number;
|
purchases: number;
|
||||||
@@ -311,10 +286,6 @@ export interface LandingStatsResponse {
|
|||||||
tariff_stats: LandingTariffStat[];
|
tariff_stats: LandingTariffStat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Admin purchase list types
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export type PurchaseItemStatus =
|
export type PurchaseItemStatus =
|
||||||
| 'pending'
|
| 'pending'
|
||||||
| 'paid'
|
| 'paid'
|
||||||
@@ -346,10 +317,6 @@ export interface LandingPurchaseListResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Admin API
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export const adminLandingsApi = {
|
export const adminLandingsApi = {
|
||||||
list: async (): Promise<LandingListItem[]> => {
|
list: async (): Promise<LandingListItem[]> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/landings');
|
const response = await apiClient.get('/cabinet/admin/landings');
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
// === Types ===
|
|
||||||
|
|
||||||
export interface AdminRole {
|
export interface AdminRole {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -144,8 +142,6 @@ export interface PermissionSection {
|
|||||||
actions: string[];
|
actions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// === API ===
|
|
||||||
|
|
||||||
const BASE = '/cabinet/admin/rbac';
|
const BASE = '/cabinet/admin/rbac';
|
||||||
|
|
||||||
export const rbacApi = {
|
export const rbacApi = {
|
||||||
|
|||||||
@@ -12,19 +12,16 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
export const subscriptionApi = {
|
export const subscriptionApi = {
|
||||||
// Get current subscription status
|
|
||||||
getSubscription: async (): Promise<SubscriptionStatusResponse> => {
|
getSubscription: async (): Promise<SubscriptionStatusResponse> => {
|
||||||
const response = await apiClient.get<SubscriptionStatusResponse>('/cabinet/subscription');
|
const response = await apiClient.get<SubscriptionStatusResponse>('/cabinet/subscription');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get renewal options
|
|
||||||
getRenewalOptions: async (): Promise<RenewalOption[]> => {
|
getRenewalOptions: async (): Promise<RenewalOption[]> => {
|
||||||
const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options');
|
const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Renew subscription
|
|
||||||
renewSubscription: async (
|
renewSubscription: async (
|
||||||
periodDays: number,
|
periodDays: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -38,7 +35,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get traffic packages
|
|
||||||
getTrafficPackages: async (): Promise<TrafficPackage[]> => {
|
getTrafficPackages: async (): Promise<TrafficPackage[]> => {
|
||||||
const response = await apiClient.get<TrafficPackage[]>(
|
const response = await apiClient.get<TrafficPackage[]>(
|
||||||
'/cabinet/subscription/traffic-packages',
|
'/cabinet/subscription/traffic-packages',
|
||||||
@@ -46,7 +42,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Purchase traffic
|
|
||||||
purchaseTraffic: async (
|
purchaseTraffic: async (
|
||||||
gb: number,
|
gb: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -58,7 +53,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Purchase devices
|
|
||||||
purchaseDevices: async (
|
purchaseDevices: async (
|
||||||
devices: number,
|
devices: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -75,7 +69,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get device purchase price
|
|
||||||
getDevicePrice: async (
|
getDevicePrice: async (
|
||||||
devices: number = 1,
|
devices: number = 1,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -103,12 +96,10 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Save devices cart for later purchase after top-up
|
|
||||||
saveDevicesCart: async (devices: number): Promise<void> => {
|
saveDevicesCart: async (devices: number): Promise<void> => {
|
||||||
await apiClient.post('/cabinet/subscription/devices/save-cart', { devices });
|
await apiClient.post('/cabinet/subscription/devices/save-cart', { devices });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get device reduction info (for reducing device limit)
|
|
||||||
getDeviceReductionInfo: async (): Promise<{
|
getDeviceReductionInfo: async (): Promise<{
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -121,7 +112,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reduce device limit
|
|
||||||
reduceDevices: async (
|
reduceDevices: async (
|
||||||
newDeviceLimit: number,
|
newDeviceLimit: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -136,12 +126,10 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Save traffic cart for later purchase after top-up
|
|
||||||
saveTrafficCart: async (trafficGb: number): Promise<void> => {
|
saveTrafficCart: async (trafficGb: number): Promise<void> => {
|
||||||
await apiClient.post('/cabinet/subscription/traffic/save-cart', { gb: trafficGb });
|
await apiClient.post('/cabinet/subscription/traffic/save-cart', { gb: trafficGb });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update autopay settings
|
|
||||||
updateAutopay: async (
|
updateAutopay: async (
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
daysBefore?: number,
|
daysBefore?: number,
|
||||||
@@ -157,25 +145,21 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get trial info
|
|
||||||
getTrialInfo: async (): Promise<TrialInfo> => {
|
getTrialInfo: async (): Promise<TrialInfo> => {
|
||||||
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial');
|
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Activate trial
|
|
||||||
activateTrial: async (): Promise<Subscription> => {
|
activateTrial: async (): Promise<Subscription> => {
|
||||||
const response = await apiClient.post<Subscription>('/cabinet/subscription/trial');
|
const response = await apiClient.post<Subscription>('/cabinet/subscription/trial');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get purchase options (periods, servers, traffic, devices)
|
|
||||||
getPurchaseOptions: async (): Promise<PurchaseOptions> => {
|
getPurchaseOptions: async (): Promise<PurchaseOptions> => {
|
||||||
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options');
|
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Preview purchase price
|
|
||||||
previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => {
|
previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => {
|
||||||
const response = await apiClient.post<PurchasePreview>(
|
const response = await apiClient.post<PurchasePreview>(
|
||||||
'/cabinet/subscription/purchase-preview',
|
'/cabinet/subscription/purchase-preview',
|
||||||
@@ -186,7 +170,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Submit purchase
|
|
||||||
submitPurchase: async (
|
submitPurchase: async (
|
||||||
selection: PurchaseSelection,
|
selection: PurchaseSelection,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -201,7 +184,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Purchase tariff (for tariffs mode)
|
|
||||||
purchaseTariff: async (
|
purchaseTariff: async (
|
||||||
tariffId: number,
|
tariffId: number,
|
||||||
periodDays: number,
|
periodDays: number,
|
||||||
@@ -223,13 +205,11 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get app config for connection
|
|
||||||
getAppConfig: async (): Promise<AppConfig> => {
|
getAppConfig: async (): Promise<AppConfig> => {
|
||||||
const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config');
|
const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available countries/servers
|
|
||||||
getCountries: async (): Promise<{
|
getCountries: async (): Promise<{
|
||||||
countries: Array<{
|
countries: Array<{
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -253,7 +233,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update countries/servers
|
|
||||||
updateCountries: async (
|
updateCountries: async (
|
||||||
countries: string[],
|
countries: string[],
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -267,7 +246,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get connection link and instructions
|
|
||||||
getConnectionLink: async (): Promise<{
|
getConnectionLink: async (): Promise<{
|
||||||
subscription_url: string | null;
|
subscription_url: string | null;
|
||||||
display_link: string | null;
|
display_link: string | null;
|
||||||
@@ -283,7 +261,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get hApp download links
|
|
||||||
getHappDownloads: async (): Promise<{
|
getHappDownloads: async (): Promise<{
|
||||||
platforms: Record<
|
platforms: Record<
|
||||||
string,
|
string,
|
||||||
@@ -299,9 +276,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============ Device Management ============
|
|
||||||
|
|
||||||
// Get connected devices
|
|
||||||
getDevices: async (): Promise<{
|
getDevices: async (): Promise<{
|
||||||
devices: Array<{
|
devices: Array<{
|
||||||
hwid: string;
|
hwid: string;
|
||||||
@@ -316,7 +290,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete a specific device
|
|
||||||
deleteDevice: async (
|
deleteDevice: async (
|
||||||
hwid: string,
|
hwid: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -330,7 +303,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete all devices
|
|
||||||
deleteAllDevices: async (): Promise<{
|
deleteAllDevices: async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -340,9 +312,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============ Tariff Switch ============
|
|
||||||
|
|
||||||
// Preview tariff switch cost
|
|
||||||
previewTariffSwitch: async (
|
previewTariffSwitch: async (
|
||||||
tariffId: number,
|
tariffId: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -372,7 +341,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Switch to a different tariff
|
|
||||||
switchTariff: async (
|
switchTariff: async (
|
||||||
tariffId: number,
|
tariffId: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -393,9 +361,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============ Subscription Pause (Daily Tariffs) ============
|
|
||||||
|
|
||||||
// Toggle pause/resume for daily subscription
|
|
||||||
togglePause: async (): Promise<{
|
togglePause: async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -407,9 +372,6 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============ Traffic Switch ============
|
|
||||||
|
|
||||||
// Switch to a different traffic package
|
|
||||||
switchTraffic: async (
|
switchTraffic: async (
|
||||||
gb: number,
|
gb: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
// ==================== TYPES ====================
|
|
||||||
|
|
||||||
export interface WheelPrize {
|
export interface WheelPrize {
|
||||||
id: number;
|
id: number;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
@@ -79,7 +77,6 @@ export interface StarsInvoiceResponse {
|
|||||||
stars_amount: number;
|
stars_amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin types
|
|
||||||
export interface WheelPrizeAdmin {
|
export interface WheelPrizeAdmin {
|
||||||
id: number;
|
id: number;
|
||||||
config_id: number;
|
config_id: number;
|
||||||
@@ -99,7 +96,6 @@ export interface WheelPrizeAdmin {
|
|||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type for creating a new prize (excludes id, config_id which are auto-generated)
|
|
||||||
export interface CreateWheelPrizeData {
|
export interface CreateWheelPrizeData {
|
||||||
prize_type: string;
|
prize_type: string;
|
||||||
prize_value: number;
|
prize_value: number;
|
||||||
@@ -180,22 +176,17 @@ export interface AdminSpinsResponse {
|
|||||||
pages: number;
|
pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== USER API ====================
|
|
||||||
|
|
||||||
export const wheelApi = {
|
export const wheelApi = {
|
||||||
// Get wheel config
|
|
||||||
getConfig: async (): Promise<WheelConfig> => {
|
getConfig: async (): Promise<WheelConfig> => {
|
||||||
const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config');
|
const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Check spin availability
|
|
||||||
checkAvailability: async (): Promise<SpinAvailability> => {
|
checkAvailability: async (): Promise<SpinAvailability> => {
|
||||||
const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability');
|
const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Spin the wheel
|
|
||||||
spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => {
|
spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => {
|
||||||
const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', {
|
const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', {
|
||||||
payment_type: paymentType,
|
payment_type: paymentType,
|
||||||
@@ -203,7 +194,6 @@ export const wheelApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get spin history
|
|
||||||
getHistory: async (page = 1, perPage = 20): Promise<SpinHistoryResponse> => {
|
getHistory: async (page = 1, perPage = 20): Promise<SpinHistoryResponse> => {
|
||||||
const response = await apiClient.get<SpinHistoryResponse>('/cabinet/wheel/history', {
|
const response = await apiClient.get<SpinHistoryResponse>('/cabinet/wheel/history', {
|
||||||
params: { page, per_page: perPage },
|
params: { page, per_page: perPage },
|
||||||
@@ -211,41 +201,33 @@ export const wheelApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create Stars invoice for Mini App payment
|
|
||||||
createStarsInvoice: async (): Promise<StarsInvoiceResponse> => {
|
createStarsInvoice: async (): Promise<StarsInvoiceResponse> => {
|
||||||
const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice');
|
const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== ADMIN API ====================
|
|
||||||
|
|
||||||
export const adminWheelApi = {
|
export const adminWheelApi = {
|
||||||
// Get full config
|
|
||||||
getConfig: async (): Promise<AdminWheelConfig> => {
|
getConfig: async (): Promise<AdminWheelConfig> => {
|
||||||
const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config');
|
const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update config
|
|
||||||
updateConfig: async (data: Partial<AdminWheelConfig>): Promise<AdminWheelConfig> => {
|
updateConfig: async (data: Partial<AdminWheelConfig>): Promise<AdminWheelConfig> => {
|
||||||
const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data);
|
const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get prizes
|
|
||||||
getPrizes: async (): Promise<WheelPrizeAdmin[]> => {
|
getPrizes: async (): Promise<WheelPrizeAdmin[]> => {
|
||||||
const response = await apiClient.get<WheelPrizeAdmin[]>('/cabinet/admin/wheel/prizes');
|
const response = await apiClient.get<WheelPrizeAdmin[]>('/cabinet/admin/wheel/prizes');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create prize
|
|
||||||
createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => {
|
createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => {
|
||||||
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data);
|
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update prize
|
|
||||||
updatePrize: async (
|
updatePrize: async (
|
||||||
prizeId: number,
|
prizeId: number,
|
||||||
data: Partial<WheelPrizeAdmin>,
|
data: Partial<WheelPrizeAdmin>,
|
||||||
@@ -257,17 +239,14 @@ export const adminWheelApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete prize
|
|
||||||
deletePrize: async (prizeId: number): Promise<void> => {
|
deletePrize: async (prizeId: number): Promise<void> => {
|
||||||
await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`);
|
await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reorder prizes
|
|
||||||
reorderPrizes: async (prizeIds: number[]): Promise<void> => {
|
reorderPrizes: async (prizeIds: number[]): Promise<void> => {
|
||||||
await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds });
|
await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get statistics
|
|
||||||
getStatistics: async (dateFrom?: string, dateTo?: string): Promise<WheelStatistics> => {
|
getStatistics: async (dateFrom?: string, dateTo?: string): Promise<WheelStatistics> => {
|
||||||
const response = await apiClient.get<WheelStatistics>('/cabinet/admin/wheel/statistics', {
|
const response = await apiClient.get<WheelStatistics>('/cabinet/admin/wheel/statistics', {
|
||||||
params: { date_from: dateFrom, date_to: dateTo },
|
params: { date_from: dateFrom, date_to: dateTo },
|
||||||
@@ -275,7 +254,6 @@ export const adminWheelApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get all spins
|
|
||||||
getSpins: async (params?: {
|
getSpins: async (params?: {
|
||||||
user_id?: number;
|
user_id?: number;
|
||||||
date_from?: string;
|
date_from?: string;
|
||||||
|
|||||||
@@ -80,8 +80,6 @@ const XCircleIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ------- Main Component -------
|
|
||||||
|
|
||||||
interface PromoOffersSectionProps {
|
interface PromoOffersSectionProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ export default function SuccessNotificationModal() {
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [isOpen, handleClose]);
|
}, [isOpen, handleClose]);
|
||||||
|
|
||||||
// Haptic feedback on open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
haptic.notification('success');
|
haptic.notification('success');
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ import {
|
|||||||
import { Toggle } from './Toggle';
|
import { Toggle } from './Toggle';
|
||||||
import { useNotify } from '../../platform/hooks/useNotify';
|
import { useNotify } from '../../platform/hooks/useNotify';
|
||||||
|
|
||||||
// ============ Icons ============
|
|
||||||
|
|
||||||
const GripIcon = () => (
|
const GripIcon = () => (
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path
|
<path
|
||||||
@@ -110,8 +108,6 @@ const ArrowDownIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============ Helpers ============
|
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||||
}
|
}
|
||||||
@@ -126,8 +122,6 @@ function configsEqual(a: MenuConfig, b: MenuConfig): boolean {
|
|||||||
|
|
||||||
const DEFAULT_CONFIG: MenuConfig = { rows: [] };
|
const DEFAULT_CONFIG: MenuConfig = { rows: [] };
|
||||||
|
|
||||||
// ============ MaxPerRowSelector ============
|
|
||||||
|
|
||||||
interface MaxPerRowSelectorProps {
|
interface MaxPerRowSelectorProps {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
@@ -153,8 +147,6 @@ function MaxPerRowSelector({ value, onChange }: MaxPerRowSelectorProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ ButtonChip ============
|
|
||||||
|
|
||||||
interface ButtonChipProps {
|
interface ButtonChipProps {
|
||||||
button: MenuButtonConfig;
|
button: MenuButtonConfig;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
@@ -358,8 +350,6 @@ function ButtonChip({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ SortableRow ============
|
|
||||||
|
|
||||||
interface SortableRowProps {
|
interface SortableRowProps {
|
||||||
row: MenuRowConfig;
|
row: MenuRowConfig;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
@@ -471,8 +461,6 @@ function SortableRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ InlineAddPanel ============
|
|
||||||
|
|
||||||
interface InlineAddPanelProps {
|
interface InlineAddPanelProps {
|
||||||
rowId: string;
|
rowId: string;
|
||||||
usedBuiltinIds: Set<string>;
|
usedBuiltinIds: Set<string>;
|
||||||
@@ -540,8 +528,6 @@ function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: In
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ MenuEditorTab ============
|
|
||||||
|
|
||||||
export function MenuEditorTab() {
|
export function MenuEditorTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
@@ -66,9 +66,7 @@ if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none')
|
|||||||
function setCachedConfig(config: AnimationConfig) {
|
function setCachedConfig(config: AnimationConfig) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(ANIMATION_CACHE_KEY, JSON.stringify(config));
|
localStorage.setItem(ANIMATION_CACHE_KEY, JSON.stringify(config));
|
||||||
} catch {
|
} catch {}
|
||||||
// localStorage not available
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCachedAnimationConfig(config: AnimationConfig) {
|
export function setCachedAnimationConfig(config: AnimationConfig) {
|
||||||
|
|||||||
@@ -62,8 +62,6 @@ export default function InstallationGuide({
|
|||||||
const [activePlatformKey, setActivePlatformKey] = useState<string | null>(null);
|
const [activePlatformKey, setActivePlatformKey] = useState<string | null>(null);
|
||||||
const [selectedApp, setSelectedApp] = useState<RemnawaveAppClient | null>(null);
|
const [selectedApp, setSelectedApp] = useState<RemnawaveAppClient | null>(null);
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
const getLocalizedText = useCallback(
|
const getLocalizedText = useCallback(
|
||||||
(text: LocalizedText | undefined): string => {
|
(text: LocalizedText | undefined): string => {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
@@ -96,8 +94,6 @@ export default function InstallationGuide({
|
|||||||
[appConfig.svgLibrary],
|
[appConfig.svgLibrary],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Available platforms ---
|
|
||||||
|
|
||||||
const availablePlatforms = useMemo(() => {
|
const availablePlatforms = useMemo(() => {
|
||||||
if (!appConfig.platforms) return [];
|
if (!appConfig.platforms) return [];
|
||||||
const available = platformOrder.filter((key) => {
|
const available = platformOrder.filter((key) => {
|
||||||
@@ -110,8 +106,6 @@ export default function InstallationGuide({
|
|||||||
return available;
|
return available;
|
||||||
}, [appConfig.platforms, detectedPlatform]);
|
}, [appConfig.platforms, detectedPlatform]);
|
||||||
|
|
||||||
// --- Auto-select platform & app ---
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedApp || !availablePlatforms.length) return;
|
if (selectedApp || !availablePlatforms.length) return;
|
||||||
const platform = availablePlatforms[0];
|
const platform = availablePlatforms[0];
|
||||||
@@ -124,8 +118,6 @@ export default function InstallationGuide({
|
|||||||
}
|
}
|
||||||
}, [appConfig.platforms, availablePlatforms, selectedApp]);
|
}, [appConfig.platforms, availablePlatforms, selectedApp]);
|
||||||
|
|
||||||
// --- Button renderer (delegates to BlockButtons component) ---
|
|
||||||
|
|
||||||
const renderBlockButtons = useCallback(
|
const renderBlockButtons = useCallback(
|
||||||
(buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => (
|
(buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => (
|
||||||
<BlockButtons
|
<BlockButtons
|
||||||
@@ -153,8 +145,6 @@ export default function InstallationGuide({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Current platform data ---
|
|
||||||
|
|
||||||
const currentPlatformKey = activePlatformKey || availablePlatforms[0];
|
const currentPlatformKey = activePlatformKey || availablePlatforms[0];
|
||||||
const currentPlatformData = currentPlatformKey
|
const currentPlatformData = currentPlatformKey
|
||||||
? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined)
|
? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Storage keys
|
|
||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
THEME: 'cabinet-theme',
|
THEME: 'cabinet-theme',
|
||||||
ENABLED_THEMES: 'cabinet-enabled-themes',
|
ENABLED_THEMES: 'cabinet-enabled-themes',
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ import {
|
|||||||
|
|
||||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached fullscreen setting
|
|
||||||
*/
|
|
||||||
export const getCachedFullscreenEnabled = (): boolean => {
|
export const getCachedFullscreenEnabled = (): boolean => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
|
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
|
||||||
@@ -29,18 +26,12 @@ export const getCachedFullscreenEnabled = (): boolean => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Set cached fullscreen setting
|
|
||||||
*/
|
|
||||||
export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
|
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
|
||||||
} catch {
|
} catch {}
|
||||||
// localStorage not available
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cached detection result (evaluated once at module load)
|
|
||||||
let _isInTelegram: boolean | null = null;
|
let _isInTelegram: boolean | null = null;
|
||||||
function detectTelegram(): boolean {
|
function detectTelegram(): boolean {
|
||||||
if (_isInTelegram === null) {
|
if (_isInTelegram === null) {
|
||||||
@@ -54,16 +45,10 @@ function detectTelegram(): boolean {
|
|||||||
return _isInTelegram;
|
return _isInTelegram;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we're actually running inside Telegram Mini App
|
|
||||||
*/
|
|
||||||
export function isInTelegramWebApp(): boolean {
|
export function isInTelegramWebApp(): boolean {
|
||||||
return detectTelegram();
|
return detectTelegram();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if running on mobile Telegram client (iOS/Android)
|
|
||||||
*/
|
|
||||||
export function isTelegramMobile(): boolean {
|
export function isTelegramMobile(): boolean {
|
||||||
try {
|
try {
|
||||||
const { tgWebAppPlatform } = retrieveLaunchParams();
|
const { tgWebAppPlatform } = retrieveLaunchParams();
|
||||||
@@ -73,9 +58,6 @@ export function isTelegramMobile(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Telegram init data for authentication
|
|
||||||
*/
|
|
||||||
export function getTelegramInitData(): string | null {
|
export function getTelegramInitData(): string | null {
|
||||||
try {
|
try {
|
||||||
return retrieveRawInitData() || null;
|
return retrieveRawInitData() || null;
|
||||||
@@ -84,9 +66,6 @@ export function getTelegramInitData(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Type for platform values
|
|
||||||
*/
|
|
||||||
export type TelegramPlatform =
|
export type TelegramPlatform =
|
||||||
| 'android'
|
| 'android'
|
||||||
| 'ios'
|
| 'ios'
|
||||||
@@ -98,13 +77,8 @@ export type TelegramPlatform =
|
|||||||
| 'unknown'
|
| 'unknown'
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
// Default values
|
|
||||||
const defaultInsets = { top: 0, bottom: 0, left: 0, right: 0 };
|
const defaultInsets = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for Telegram WebApp integration
|
|
||||||
* Uses @telegram-apps/sdk-react v3 signals
|
|
||||||
*/
|
|
||||||
export function useTelegramSDK() {
|
export function useTelegramSDK() {
|
||||||
const inTelegram = detectTelegram();
|
const inTelegram = detectTelegram();
|
||||||
|
|
||||||
@@ -156,18 +130,14 @@ export function useTelegramSDK() {
|
|||||||
if (!inTelegram) return;
|
if (!inTelegram) return;
|
||||||
try {
|
try {
|
||||||
sdkRequestFullscreen();
|
sdkRequestFullscreen();
|
||||||
} catch {
|
} catch {}
|
||||||
// Not supported
|
|
||||||
}
|
|
||||||
}, [inTelegram]);
|
}, [inTelegram]);
|
||||||
|
|
||||||
const exitFullscreen = useCallback(() => {
|
const exitFullscreen = useCallback(() => {
|
||||||
if (!inTelegram) return;
|
if (!inTelegram) return;
|
||||||
try {
|
try {
|
||||||
sdkExitFullscreen();
|
sdkExitFullscreen();
|
||||||
} catch {
|
} catch {}
|
||||||
// Not supported
|
|
||||||
}
|
|
||||||
}, [inTelegram]);
|
}, [inTelegram]);
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
@@ -182,27 +152,21 @@ export function useTelegramSDK() {
|
|||||||
if (!inTelegram) return;
|
if (!inTelegram) return;
|
||||||
try {
|
try {
|
||||||
expandViewport();
|
expandViewport();
|
||||||
} catch {
|
} catch {}
|
||||||
// Not supported
|
|
||||||
}
|
|
||||||
}, [inTelegram]);
|
}, [inTelegram]);
|
||||||
|
|
||||||
const disableVerticalSwipes = useCallback(() => {
|
const disableVerticalSwipes = useCallback(() => {
|
||||||
if (!inTelegram) return;
|
if (!inTelegram) return;
|
||||||
try {
|
try {
|
||||||
sdkDisableVerticalSwipes();
|
sdkDisableVerticalSwipes();
|
||||||
} catch {
|
} catch {}
|
||||||
// Not supported
|
|
||||||
}
|
|
||||||
}, [inTelegram]);
|
}, [inTelegram]);
|
||||||
|
|
||||||
const enableVerticalSwipes = useCallback(() => {
|
const enableVerticalSwipes = useCallback(() => {
|
||||||
if (!inTelegram) return;
|
if (!inTelegram) return;
|
||||||
try {
|
try {
|
||||||
sdkEnableVerticalSwipes();
|
sdkEnableVerticalSwipes();
|
||||||
} catch {
|
} catch {}
|
||||||
// Not supported
|
|
||||||
}
|
|
||||||
}, [inTelegram]);
|
}, [inTelegram]);
|
||||||
|
|
||||||
const isFullscreenSupported = inTelegram;
|
const isFullscreenSupported = inTelegram;
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export function applyThemeColors(colors: ThemeColors): void {
|
|||||||
const warningPalette = generatePalette(colors.warning);
|
const warningPalette = generatePalette(colors.warning);
|
||||||
const errorPalette = generatePalette(colors.error);
|
const errorPalette = generatePalette(colors.error);
|
||||||
|
|
||||||
// === DARK THEME PALETTE ===
|
|
||||||
// Convert hex colors to RGB
|
// Convert hex colors to RGB
|
||||||
const darkBgRgb = hexToRgb(colors.darkBackground);
|
const darkBgRgb = hexToRgb(colors.darkBackground);
|
||||||
const darkSurfaceRgb = hexToRgb(colors.darkSurface);
|
const darkSurfaceRgb = hexToRgb(colors.darkSurface);
|
||||||
@@ -104,7 +103,6 @@ export function applyThemeColors(colors: ThemeColors): void {
|
|||||||
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7));
|
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7));
|
||||||
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b));
|
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b));
|
||||||
|
|
||||||
// === LIGHT THEME PALETTE ===
|
|
||||||
const lightBgRgb = hexToRgb(colors.lightBackground);
|
const lightBgRgb = hexToRgb(colors.lightBackground);
|
||||||
const lightSurfaceRgb = hexToRgb(colors.lightSurface);
|
const lightSurfaceRgb = hexToRgb(colors.lightSurface);
|
||||||
const lightTextRgb = hexToRgb(colors.lightText);
|
const lightTextRgb = hexToRgb(colors.lightText);
|
||||||
@@ -149,7 +147,6 @@ export function applyThemeColors(colors: ThemeColors): void {
|
|||||||
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
|
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
|
||||||
);
|
);
|
||||||
|
|
||||||
// === STATUS COLOR PALETTES ===
|
|
||||||
for (const shade of SHADE_LEVELS) {
|
for (const shade of SHADE_LEVELS) {
|
||||||
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]);
|
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]);
|
||||||
root.style.setProperty(`--color-success-${shade}`, successPalette[shade]);
|
root.style.setProperty(`--color-success-${shade}`, successPalette[shade]);
|
||||||
|
|||||||
34
src/main.tsx
34
src/main.tsx
@@ -38,40 +38,29 @@ if (!alreadyInitialized) {
|
|||||||
init();
|
init();
|
||||||
restoreInitData();
|
restoreInitData();
|
||||||
|
|
||||||
// Сбрасываем старые токены если init data изменился (новая сессия Telegram)
|
|
||||||
clearStaleSessionIfNeeded(retrieveRawInitData() || null);
|
clearStaleSessionIfNeeded(retrieveRawInitData() || null);
|
||||||
|
|
||||||
// Mount components — each in its own try/catch so one failure doesn't block others
|
// Each mount in its own try/catch so one failure doesn't block others.
|
||||||
// Note: mountMiniApp() internally mounts themeParams in SDK v3,
|
// mountMiniApp() internally mounts themeParams in SDK v3,
|
||||||
// so we don't call mountThemeParams() separately to avoid ConcurrentCallError
|
// so we don't call mountThemeParams() separately to avoid ConcurrentCallError.
|
||||||
try {
|
try {
|
||||||
mountMiniApp();
|
mountMiniApp();
|
||||||
} catch {
|
} catch {}
|
||||||
/* already mounted */
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
bindThemeParamsCssVars();
|
bindThemeParamsCssVars();
|
||||||
} catch {
|
} catch {}
|
||||||
/* theme params not yet available */
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
mountSwipeBehavior();
|
mountSwipeBehavior();
|
||||||
disableVerticalSwipes();
|
disableVerticalSwipes();
|
||||||
} catch {
|
} catch {}
|
||||||
/* already mounted */
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
mountClosingBehavior();
|
mountClosingBehavior();
|
||||||
disableClosingConfirmation();
|
disableClosingConfirmation();
|
||||||
} catch {
|
} catch {}
|
||||||
/* already mounted */
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
mountBackButton();
|
mountBackButton();
|
||||||
} catch {
|
} catch {}
|
||||||
/* already mounted */
|
// Viewport must be mounted before requesting fullscreen
|
||||||
}
|
|
||||||
// Viewport — async, fullscreen зависит от смонтированного viewport
|
|
||||||
mountViewport()
|
mountViewport()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
bindViewportCssVars();
|
bindViewportCssVars();
|
||||||
@@ -87,12 +76,9 @@ if (!alreadyInitialized) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
miniAppReady();
|
miniAppReady();
|
||||||
} catch {
|
} catch {}
|
||||||
// Not in Telegram — ok
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preload logo from cache — defer to idle time so it doesn't compete with LCP
|
|
||||||
if ('requestIdleCallback' in window) {
|
if ('requestIdleCallback' in window) {
|
||||||
requestIdleCallback(() => initLogoPreload());
|
requestIdleCallback(() => initLogoPreload());
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import {
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||||
import type { PaymentMethodConfig } from '../types';
|
import type { PaymentMethodConfig } from '../types';
|
||||||
// ============ Icons ============
|
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-dark-400"
|
className="h-5 w-5 text-dark-400"
|
||||||
@@ -63,8 +61,6 @@ const SaveIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============ Sortable Card ============
|
|
||||||
|
|
||||||
interface SortableCardProps {
|
interface SortableCardProps {
|
||||||
config: PaymentMethodConfig;
|
config: PaymentMethodConfig;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -184,10 +180,6 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Toast ============
|
|
||||||
|
|
||||||
// ============ Main Page ============
|
|
||||||
|
|
||||||
export default function AdminPaymentMethods() {
|
export default function AdminPaymentMethods() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { rbacApi, AccessPolicy, AdminRole } from '@/api/rbac';
|
|||||||
import { PermissionGate } from '@/components/auth/PermissionGate';
|
import { PermissionGate } from '@/components/auth/PermissionGate';
|
||||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||||
|
|
||||||
// === Icons ===
|
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-dark-400"
|
className="h-5 w-5 text-dark-400"
|
||||||
@@ -96,8 +94,6 @@ const CalendarIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// === Helpers ===
|
|
||||||
|
|
||||||
interface PolicyConditions {
|
interface PolicyConditions {
|
||||||
time_range?: { start: string; end: string };
|
time_range?: { start: string; end: string };
|
||||||
ip_whitelist?: string[];
|
ip_whitelist?: string[];
|
||||||
@@ -130,8 +126,6 @@ function parseConditions(raw: Record<string, unknown>): PolicyConditions {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Sub-components ===
|
|
||||||
|
|
||||||
interface EffectBadgeProps {
|
interface EffectBadgeProps {
|
||||||
effect: 'allow' | 'deny';
|
effect: 'allow' | 'deny';
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -154,8 +148,6 @@ function EffectBadge({ effect, className }: EffectBadgeProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Main Page ===
|
|
||||||
|
|
||||||
export default function AdminPolicies() {
|
export default function AdminPolicies() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import {
|
|||||||
RemnawaveIcon,
|
RemnawaveIcon,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
|
|
||||||
// ============ Icons ============
|
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-dark-400"
|
className="h-5 w-5 text-dark-400"
|
||||||
@@ -37,8 +35,6 @@ const BackIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============ Helpers ============
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = (bytes: number): string => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -100,8 +96,6 @@ const getCountryFlag = (code: string | null | undefined): string => {
|
|||||||
return codeMap[code.toUpperCase()] || code;
|
return codeMap[code.toUpperCase()] || code;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ Sub-Components ============
|
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
@@ -333,8 +327,6 @@ function SyncCard({ title, description, onAction, isLoading, lastResult }: SyncC
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Tab Components ============
|
|
||||||
|
|
||||||
interface OverviewTabProps {
|
interface OverviewTabProps {
|
||||||
stats: SystemStatsResponse | undefined;
|
stats: SystemStatsResponse | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -873,8 +865,6 @@ function SyncTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Main Component ============
|
|
||||||
|
|
||||||
type TabType = 'overview' | 'nodes' | 'squads' | 'sync';
|
type TabType = 'overview' | 'nodes' | 'squads' | 'sync';
|
||||||
|
|
||||||
export default function AdminRemnawave() {
|
export default function AdminRemnawave() {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { PermissionGate } from '@/components/auth/PermissionGate';
|
|||||||
import { usePermissionStore } from '@/store/permissions';
|
import { usePermissionStore } from '@/store/permissions';
|
||||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||||
|
|
||||||
// === Icons ===
|
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-dark-400"
|
className="h-5 w-5 text-dark-400"
|
||||||
@@ -57,8 +55,6 @@ const ShieldIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// === Main Page ===
|
|
||||||
|
|
||||||
export default function AdminRoles() {
|
export default function AdminRoles() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -433,7 +433,6 @@ export default function AdminUserDetail() {
|
|||||||
const data = await adminUsersApi.getReferrals(userId, 0, 50);
|
const data = await adminUsersApi.getReferrals(userId, 0, 50);
|
||||||
setReferrals(data.users);
|
setReferrals(data.users);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
} finally {
|
} finally {
|
||||||
setReferralsLoading(false);
|
setReferralsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -446,7 +445,6 @@ export default function AdminUserDetail() {
|
|||||||
const data = await adminUsersApi.getPanelInfo(userId);
|
const data = await adminUsersApi.getPanelInfo(userId);
|
||||||
setPanelInfo(data);
|
setPanelInfo(data);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
} finally {
|
} finally {
|
||||||
setPanelInfoLoading(false);
|
setPanelInfoLoading(false);
|
||||||
}
|
}
|
||||||
@@ -457,9 +455,7 @@ export default function AdminUserDetail() {
|
|||||||
try {
|
try {
|
||||||
const data = await adminUsersApi.getNodeUsage(userId);
|
const data = await adminUsersApi.getNodeUsage(userId);
|
||||||
setNodeUsage(data);
|
setNodeUsage(data);
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const loadDevices = useCallback(async () => {
|
const loadDevices = useCallback(async () => {
|
||||||
@@ -471,7 +467,6 @@ export default function AdminUserDetail() {
|
|||||||
setDevicesTotal(data.total);
|
setDevicesTotal(data.total);
|
||||||
setDeviceLimit(data.device_limit);
|
setDeviceLimit(data.device_limit);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
} finally {
|
} finally {
|
||||||
setDevicesLoading(false);
|
setDevicesLoading(false);
|
||||||
}
|
}
|
||||||
@@ -488,7 +483,6 @@ export default function AdminUserDetail() {
|
|||||||
const data = await adminUsersApi.getUserGifts(userId);
|
const data = await adminUsersApi.getUserGifts(userId);
|
||||||
setGiftsData(data);
|
setGiftsData(data);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
} finally {
|
} finally {
|
||||||
setGiftsLoading(false);
|
setGiftsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -498,9 +492,7 @@ export default function AdminUserDetail() {
|
|||||||
try {
|
try {
|
||||||
const data = await promocodesApi.getPromoGroups({ limit: 100 });
|
const data = await promocodesApi.getPromoGroups({ limit: 100 });
|
||||||
setPromoGroups(data.items);
|
setPromoGroups(data.items);
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTicketReply = async () => {
|
const handleTicketReply = async () => {
|
||||||
@@ -954,9 +946,7 @@ export default function AdminUserDetail() {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
notify.success(t('admin.users.detail.copied'));
|
notify.success(t('admin.users.detail.copied'));
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { useCurrency } from '../hooks/useCurrency';
|
|||||||
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
|
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
// ============ Icons ============
|
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-dark-400"
|
className="h-5 w-5 text-dark-400"
|
||||||
@@ -57,8 +55,6 @@ const TelegramIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============ Components ============
|
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
@@ -101,8 +97,6 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ User List Component ============
|
|
||||||
|
|
||||||
interface UserRowProps {
|
interface UserRowProps {
|
||||||
user: UserListItem;
|
user: UserListItem;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -181,8 +175,6 @@ function UserRow({ user, onClick, formatAmount }: UserRowProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Main Page ============
|
|
||||||
|
|
||||||
export default function AdminUsers() {
|
export default function AdminUsers() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ import { ColorPicker } from '@/components/ColorPicker';
|
|||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { toNumber } from '../utils/inputHelpers';
|
import { toNumber } from '../utils/inputHelpers';
|
||||||
|
|
||||||
// Icons
|
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-dark-400"
|
className="h-5 w-5 text-dark-400"
|
||||||
@@ -181,8 +179,6 @@ const PRIZE_TYPE_KEYS = [
|
|||||||
|
|
||||||
type Tab = 'settings' | 'prizes' | 'statistics';
|
type Tab = 'settings' | 'prizes' | 'statistics';
|
||||||
|
|
||||||
// ============ Sortable Prize Card ============
|
|
||||||
|
|
||||||
interface SortablePrizeCardProps {
|
interface SortablePrizeCardProps {
|
||||||
prize: WheelPrizeAdmin;
|
prize: WheelPrizeAdmin;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export default function Connection() {
|
|||||||
);
|
);
|
||||||
}, [appConfig?.platforms]);
|
}, [appConfig?.platforms]);
|
||||||
|
|
||||||
// Loading
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center py-20">
|
<div className="flex flex-1 items-center justify-center py-20">
|
||||||
@@ -107,20 +106,7 @@ export default function Connection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error
|
if (error || !appConfig || !hasApps) {
|
||||||
if (error || !appConfig) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
|
||||||
<p className="mb-4 text-lg text-dark-300">{t('common.error')}</p>
|
|
||||||
<button onClick={handleGoBack} className="btn-primary px-6 py-2">
|
|
||||||
{t('common.close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No apps configured — check before subscription since empty config also has no subscription
|
|
||||||
if (!hasApps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ import { getApiErrorMessage } from '../utils/api-error';
|
|||||||
import { formatPrice } from '../utils/format';
|
import { formatPrice } from '../utils/format';
|
||||||
import { usePlatform, useHaptic } from '@/platform';
|
import { usePlatform, useHaptic } from '@/platform';
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// SVG Icons
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function GiftIcon({ className }: { className?: string }) {
|
function GiftIcon({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -131,10 +127,6 @@ function InboxIcon({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Helpers
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function formatPeriodLabel(
|
function formatPeriodLabel(
|
||||||
days: number,
|
days: number,
|
||||||
t: (key: string, options?: Record<string, unknown>) => string,
|
t: (key: string, options?: Record<string, unknown>) => string,
|
||||||
@@ -181,16 +173,8 @@ function formatGiftDate(dateStr: string | null): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Tab type
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
type TabId = 'buy' | 'activate' | 'myGifts';
|
type TabId = 'buy' | 'activate' | 'myGifts';
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Sub-components: Shared
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function LoadingSkeleton() {
|
function LoadingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh items-center justify-center">
|
<div className="flex min-h-dvh items-center justify-center">
|
||||||
@@ -263,10 +247,6 @@ function DisabledState() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Sub-components: Buy Tab
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function TariffCard({
|
function TariffCard({
|
||||||
tariff,
|
tariff,
|
||||||
isSelected,
|
isSelected,
|
||||||
@@ -904,10 +884,6 @@ function BuyTabContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Sub-components: Activate Tab
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
|
function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1023,10 +999,6 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Sub-components: My Gifts Tab
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function CopiedToast({ onDismiss }: { onDismiss: () => void }) {
|
function CopiedToast({ onDismiss }: { onDismiss: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -1320,20 +1292,12 @@ function MyGiftsTabContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Tab animation variants
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
const tabContentVariants = {
|
const tabContentVariants = {
|
||||||
initial: { opacity: 0, x: 20 },
|
initial: { opacity: 0, x: 20 },
|
||||||
animate: { opacity: 1, x: 0 },
|
animate: { opacity: 1, x: 0 },
|
||||||
exit: { opacity: 0, x: -20 },
|
exit: { opacity: 0, x: -20 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Main Component
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export default function GiftSubscription() {
|
export default function GiftSubscription() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '../api/branding';
|
} from '../api/branding';
|
||||||
import { getAndClearReturnUrl } from '../utils/token';
|
import { getAndClearReturnUrl } from '../utils/token';
|
||||||
import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK';
|
import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK';
|
||||||
|
import { closeMiniApp } from '@telegram-apps/sdk-react';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
import TelegramLoginButton from '../components/TelegramLoginButton';
|
import TelegramLoginButton from '../components/TelegramLoginButton';
|
||||||
import OAuthProviderIcon from '../components/OAuthProviderIcon';
|
import OAuthProviderIcon from '../components/OAuthProviderIcon';
|
||||||
@@ -213,30 +214,13 @@ export default function Login() {
|
|||||||
tryTelegramAuth();
|
tryTelegramAuth();
|
||||||
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]);
|
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]);
|
||||||
|
|
||||||
// Manual retry for Telegram Mini App auth
|
const handleRetryTelegramAuth = () => {
|
||||||
const handleRetryTelegramAuth = async () => {
|
|
||||||
const initData = getTelegramInitData();
|
|
||||||
if (!initData) {
|
|
||||||
setError(t('auth.telegramRequired'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError('');
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
try {
|
||||||
await loginWithTelegram(initData);
|
sessionStorage.removeItem('tapps/launchParams');
|
||||||
navigate(getReturnUrl(), { replace: true });
|
sessionStorage.removeItem('telegram_init_data');
|
||||||
} catch (err) {
|
closeMiniApp();
|
||||||
const error = err as { response?: { status?: number; data?: { detail?: string } } };
|
} catch {
|
||||||
const status = error.response?.status;
|
window.location.reload();
|
||||||
const detail = error.response?.data?.detail;
|
|
||||||
if (import.meta.env.DEV) console.warn('Telegram auth retry failed:', status, detail);
|
|
||||||
setError(
|
|
||||||
detail ||
|
|
||||||
t('auth.telegramRetryFailed', 'Authorization failed. Close the app and try again.'),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ import { cn } from '../lib/utils';
|
|||||||
|
|
||||||
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
|
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Sub-components
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function PendingState() {
|
function PendingState() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -600,10 +596,6 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Main Component
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export default function PurchaseSuccess() {
|
export default function PurchaseSuccess() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
|
|||||||
@@ -48,10 +48,6 @@ function formatPeriodLabel(
|
|||||||
return t('landing.periodLabels.nDays', { count: days });
|
return t('landing.periodLabels.nDays', { count: days });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Sub-components
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function LoadingSkeleton() {
|
function LoadingSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh items-center justify-center bg-dark-950">
|
<div className="flex min-h-dvh items-center justify-center bg-dark-950">
|
||||||
@@ -611,10 +607,6 @@ function SummaryCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Discount Countdown
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function TimeUnit({ value, label }: { value: number; label: string }) {
|
function TimeUnit({ value, label }: { value: number; label: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
@@ -715,10 +707,6 @@ function DiscountBanner({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Main Component
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export default function QuickPurchase() {
|
export default function QuickPurchase() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|||||||
@@ -239,9 +239,7 @@ export default function Referral() {
|
|||||||
text: shareText,
|
text: shareText,
|
||||||
url: referralLink,
|
url: referralLink,
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {});
|
||||||
// ignore cancellation errors
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -425,7 +425,6 @@ export default function Wheel() {
|
|||||||
// Use the pending result from polling, or show a fallback
|
// Use the pending result from polling, or show a fallback
|
||||||
if (pendingStarsResultRef.current) {
|
if (pendingStarsResultRef.current) {
|
||||||
setSpinResult(pendingStarsResultRef.current);
|
setSpinResult(pendingStarsResultRef.current);
|
||||||
// Haptic feedback based on result
|
|
||||||
if (pendingStarsResultRef.current.prize_type === 'nothing') {
|
if (pendingStarsResultRef.current.prize_type === 'nothing') {
|
||||||
haptic.notification('warning');
|
haptic.notification('warning');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -73,18 +73,14 @@ function createBackButtonController(): BackButtonController {
|
|||||||
if (currentCallback) {
|
if (currentCallback) {
|
||||||
try {
|
try {
|
||||||
offBackButtonClick(currentCallback);
|
offBackButtonClick(currentCallback);
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
currentCallback = onClick;
|
currentCallback = onClick;
|
||||||
try {
|
try {
|
||||||
onBackButtonClick(onClick);
|
onBackButtonClick(onClick);
|
||||||
showBackButton();
|
showBackButton();
|
||||||
} catch {
|
} catch {}
|
||||||
// Back button not mounted
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
@@ -93,16 +89,12 @@ function createBackButtonController(): BackButtonController {
|
|||||||
if (currentCallback) {
|
if (currentCallback) {
|
||||||
try {
|
try {
|
||||||
offBackButtonClick(currentCallback);
|
offBackButtonClick(currentCallback);
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
currentCallback = null;
|
currentCallback = null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
hideBackButton();
|
hideBackButton();
|
||||||
} catch {
|
} catch {}
|
||||||
// Back button not mounted
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -115,27 +107,21 @@ function createHapticController(): HapticController {
|
|||||||
if (!inTelegram) return;
|
if (!inTelegram) return;
|
||||||
try {
|
try {
|
||||||
hapticFeedbackImpactOccurred(style);
|
hapticFeedbackImpactOccurred(style);
|
||||||
} catch {
|
} catch {}
|
||||||
// Haptic not available
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
notification(type: HapticNotificationType) {
|
notification(type: HapticNotificationType) {
|
||||||
if (!inTelegram) return;
|
if (!inTelegram) return;
|
||||||
try {
|
try {
|
||||||
hapticFeedbackNotificationOccurred(type);
|
hapticFeedbackNotificationOccurred(type);
|
||||||
} catch {
|
} catch {}
|
||||||
// Haptic not available
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
selection() {
|
selection() {
|
||||||
if (!inTelegram) return;
|
if (!inTelegram) return;
|
||||||
try {
|
try {
|
||||||
hapticFeedbackSelectionChanged();
|
hapticFeedbackSelectionChanged();
|
||||||
} catch {
|
} catch {}
|
||||||
// Haptic not available
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -183,8 +169,6 @@ function createDialogController(): DialogController {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const buttons = options.buttons?.map((btn) => {
|
const buttons = options.buttons?.map((btn) => {
|
||||||
// For 'ok', 'close', 'cancel' types: do NOT include text
|
|
||||||
// For 'default', 'destructive' types: text is required
|
|
||||||
if (btn.type === 'ok' || btn.type === 'close' || btn.type === 'cancel') {
|
if (btn.type === 'ok' || btn.type === 'close' || btn.type === 'cancel') {
|
||||||
return { type: btn.type, id: btn.id };
|
return { type: btn.type, id: btn.id };
|
||||||
}
|
}
|
||||||
@@ -212,18 +196,14 @@ function createThemeController(): ThemeController {
|
|||||||
if (!inTelegram) return;
|
if (!inTelegram) return;
|
||||||
try {
|
try {
|
||||||
setMiniAppHeaderColor(color as `#${string}`);
|
setMiniAppHeaderColor(color as `#${string}`);
|
||||||
} catch {
|
} catch {}
|
||||||
// Not supported
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setBottomBarColor(color: string) {
|
setBottomBarColor(color: string) {
|
||||||
if (!inTelegram) return;
|
if (!inTelegram) return;
|
||||||
try {
|
try {
|
||||||
setMiniAppBottomBarColor(color as `#${string}`);
|
setMiniAppBottomBarColor(color as `#${string}`);
|
||||||
} catch {
|
} catch {}
|
||||||
// Not supported
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getThemeParams() {
|
getThemeParams() {
|
||||||
@@ -231,7 +211,6 @@ function createThemeController(): ThemeController {
|
|||||||
try {
|
try {
|
||||||
const params = themeParamsState();
|
const params = themeParamsState();
|
||||||
if (!params) return null;
|
if (!params) return null;
|
||||||
// SDK v3 uses camelCase — convert to snake_case for our interface
|
|
||||||
return {
|
return {
|
||||||
bg_color: params.bgColor,
|
bg_color: params.bgColor,
|
||||||
text_color: params.textColor,
|
text_color: params.textColor,
|
||||||
@@ -330,9 +309,7 @@ export function createTelegramAdapter(): PlatformContext {
|
|||||||
try {
|
try {
|
||||||
await navigator.share({ text: shareText, url });
|
await navigator.share({ text: shareText, url });
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {}
|
||||||
// User cancelled or share failed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -350,9 +327,7 @@ export function createTelegramAdapter(): PlatformContext {
|
|||||||
} else {
|
} else {
|
||||||
disableClosingConfirmation();
|
disableClosingConfirmation();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// Not supported
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
HapticNotificationType,
|
HapticNotificationType,
|
||||||
} from '@/platform/types';
|
} from '@/platform/types';
|
||||||
|
|
||||||
// Storage key for local storage fallback
|
|
||||||
const STORAGE_PREFIX = 'bedolaga_';
|
const STORAGE_PREFIX = 'bedolaga_';
|
||||||
|
|
||||||
function createCapabilities(): PlatformCapabilities {
|
function createCapabilities(): PlatformCapabilities {
|
||||||
@@ -154,7 +153,6 @@ function createCloudStorageController(): CloudStorageController {
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_PREFIX + key, value);
|
localStorage.setItem(STORAGE_PREFIX + key, value);
|
||||||
} catch {
|
} catch {
|
||||||
// Storage might be full or disabled
|
|
||||||
console.warn('Failed to save to localStorage:', key);
|
console.warn('Failed to save to localStorage:', key);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ interface AuthState {
|
|||||||
) => Promise<RegisterResponse>;
|
) => Promise<RegisterResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Блокировка для предотвращения race condition при инициализации
|
|
||||||
// Используем объект для атомарности операций
|
|
||||||
const initState = {
|
const initState = {
|
||||||
promise: null as Promise<void> | null,
|
promise: null as Promise<void> | null,
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
@@ -92,12 +90,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
// Get refresh token from secure storage, not zustand state
|
|
||||||
const refreshToken = tokenStorage.getRefreshToken();
|
const refreshToken = tokenStorage.getRefreshToken();
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
authApi.logout(refreshToken).catch(() => {
|
authApi.logout(refreshToken).catch(() => {});
|
||||||
// Logout API call failed - ignore silently
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
tokenStorage.clearTokens();
|
tokenStorage.clearTokens();
|
||||||
usePermissionStore.getState().reset();
|
usePermissionStore.getState().reset();
|
||||||
@@ -118,7 +113,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
usePermissionStore.getState().reset();
|
usePermissionStore.getState().reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Используем apiClient для единообразной обработки ошибок
|
|
||||||
const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin');
|
const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin');
|
||||||
set({ isAdmin: response.data.is_admin });
|
set({ isAdmin: response.data.is_admin });
|
||||||
if (response.data.is_admin) {
|
if (response.data.is_admin) {
|
||||||
@@ -136,18 +130,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
try {
|
try {
|
||||||
const user = await authApi.getMe();
|
const user = await authApi.getMe();
|
||||||
set({ user });
|
set({ user });
|
||||||
} catch {
|
} catch {}
|
||||||
// Failed to refresh user - ignore silently
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
initialize: async () => {
|
initialize: async () => {
|
||||||
// Защита от race condition - если уже инициализировано, выходим
|
|
||||||
if (initState.isInitialized) {
|
if (initState.isInitialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если уже идёт инициализация, ждём её завершения
|
|
||||||
if (initState.isInitializing && initState.promise) {
|
if (initState.isInitializing && initState.promise) {
|
||||||
return initState.promise;
|
return initState.promise;
|
||||||
}
|
}
|
||||||
@@ -157,7 +147,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
try {
|
try {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
// Миграция токенов из localStorage (для обратной совместимости)
|
|
||||||
tokenStorage.migrateFromLocalStorage();
|
tokenStorage.migrateFromLocalStorage();
|
||||||
|
|
||||||
const accessToken = tokenStorage.getAccessToken();
|
const accessToken = tokenStorage.getAccessToken();
|
||||||
@@ -168,15 +157,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No access token or it's expired — try refresh
|
|
||||||
// This handles Mini App reopens where sessionStorage was cleared
|
|
||||||
// but refresh token persists in localStorage
|
|
||||||
if (!isTokenValid(accessToken)) {
|
if (!isTokenValid(accessToken)) {
|
||||||
// Используем централизованный менеджер для refresh
|
|
||||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
const user = await authApi.getMe();
|
const user = await authApi.getMe();
|
||||||
// Сначала проверяем admin статус, потом снимаем isLoading
|
|
||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
set({
|
set({
|
||||||
accessToken: newToken,
|
accessToken: newToken,
|
||||||
@@ -200,7 +184,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await authApi.getMe();
|
const user = await authApi.getMe();
|
||||||
// Сначала проверяем admin статус, потом снимаем isLoading
|
|
||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
set({
|
set({
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -210,12 +193,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Token might be invalid on server, try to refresh
|
|
||||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
try {
|
try {
|
||||||
const user = await authApi.getMe();
|
const user = await authApi.getMe();
|
||||||
// Сначала проверяем admin статус, потом снимаем isLoading
|
|
||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
set({
|
set({
|
||||||
accessToken: newToken,
|
accessToken: newToken,
|
||||||
@@ -235,7 +216,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Refresh failed, logout
|
|
||||||
tokenStorage.clearTokens();
|
tokenStorage.clearTokens();
|
||||||
set({
|
set({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
@@ -339,9 +319,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
registerWithEmail: async (email, password, firstName, referralCode) => {
|
registerWithEmail: async (email, password, firstName, referralCode) => {
|
||||||
// Registration now returns message, not tokens
|
|
||||||
// User must verify email before they can login
|
|
||||||
// Campaign slug stays in localStorage — consumed during verify_email step
|
|
||||||
const code = referralCode || consumeReferralCode() || undefined;
|
const code = referralCode || consumeReferralCode() || undefined;
|
||||||
const response = await authApi.registerEmailStandalone({
|
const response = await authApi.registerEmailStandalone({
|
||||||
email,
|
email,
|
||||||
@@ -355,8 +332,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'cabinet-auth',
|
name: 'cabinet-auth',
|
||||||
// Only persist user info for UI caching
|
|
||||||
// Tokens are stored securely in sessionStorage via tokenStorage
|
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
user: state.user,
|
user: state.user,
|
||||||
}),
|
}),
|
||||||
@@ -364,9 +339,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture campaign slug and referral code from URL before auth initialization
|
|
||||||
captureCampaignFromUrl();
|
captureCampaignFromUrl();
|
||||||
captureReferralFromUrl();
|
captureReferralFromUrl();
|
||||||
|
|
||||||
// Initialize auth on app load
|
|
||||||
useAuthStore.getState().initialize();
|
useAuthStore.getState().initialize();
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ function clearSlug(): void {
|
|||||||
try {
|
try {
|
||||||
localStorage.removeItem(CAMPAIGN_KEY);
|
localStorage.removeItem(CAMPAIGN_KEY);
|
||||||
localStorage.removeItem(CAMPAIGN_TTL_KEY);
|
localStorage.removeItem(CAMPAIGN_TTL_KEY);
|
||||||
} catch {
|
} catch {}
|
||||||
// localStorage unavailable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,9 +50,7 @@ export function captureCampaignFromUrl(): void {
|
|||||||
const newUrl =
|
const newUrl =
|
||||||
window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
|
window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
|
||||||
window.history.replaceState(null, '', newUrl);
|
window.history.replaceState(null, '', newUrl);
|
||||||
} catch {
|
} catch {}
|
||||||
// localStorage or history API unavailable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ function clearCode(): void {
|
|||||||
try {
|
try {
|
||||||
localStorage.removeItem(REFERRAL_KEY);
|
localStorage.removeItem(REFERRAL_KEY);
|
||||||
localStorage.removeItem(REFERRAL_TTL_KEY);
|
localStorage.removeItem(REFERRAL_TTL_KEY);
|
||||||
} catch {
|
} catch {}
|
||||||
// localStorage unavailable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,9 +50,7 @@ export function captureReferralFromUrl(): void {
|
|||||||
const newUrl =
|
const newUrl =
|
||||||
window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
|
window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
|
||||||
window.history.replaceState(null, '', newUrl);
|
window.history.replaceState(null, '', newUrl);
|
||||||
} catch {
|
} catch {}
|
||||||
// localStorage or history API unavailable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
/**
|
|
||||||
* Утилиты для безопасной работы с JWT токенами
|
|
||||||
*/
|
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const TOKEN_KEYS = {
|
const TOKEN_KEYS = {
|
||||||
@@ -18,10 +14,6 @@ interface JWTPayload {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Декодирует JWT токен без верификации подписи
|
|
||||||
* Используется только для чтения payload на клиенте
|
|
||||||
*/
|
|
||||||
export function decodeJWT(token: string): JWTPayload | null {
|
export function decodeJWT(token: string): JWTPayload | null {
|
||||||
try {
|
try {
|
||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
@@ -35,11 +27,6 @@ export function decodeJWT(token: string): JWTPayload | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверяет, истёк ли срок действия токена
|
|
||||||
* @param token JWT токен
|
|
||||||
* @param bufferSeconds Буфер в секундах до истечения (по умолчанию 30 сек)
|
|
||||||
*/
|
|
||||||
export function isTokenExpired(token: string | null, bufferSeconds = 30): boolean {
|
export function isTokenExpired(token: string | null, bufferSeconds = 30): boolean {
|
||||||
if (!token) return true;
|
if (!token) return true;
|
||||||
|
|
||||||
@@ -50,19 +37,11 @@ export function isTokenExpired(token: string | null, bufferSeconds = 30): boolea
|
|||||||
return payload.exp <= now + bufferSeconds;
|
return payload.exp <= now + bufferSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверяет, валиден ли токен (не истёк и корректный формат)
|
|
||||||
*/
|
|
||||||
export function isTokenValid(token: string | null): boolean {
|
export function isTokenValid(token: string | null): boolean {
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
return !isTokenExpired(token);
|
return !isTokenExpired(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Безопасное хранилище токенов
|
|
||||||
* Access token: sessionStorage (short-lived, cleared on tab close)
|
|
||||||
* Refresh token: localStorage (persistent, survives Mini App reopens, server-validated)
|
|
||||||
*/
|
|
||||||
export const tokenStorage = {
|
export const tokenStorage = {
|
||||||
getAccessToken(): string | null {
|
getAccessToken(): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -74,7 +53,6 @@ export const tokenStorage = {
|
|||||||
|
|
||||||
getRefreshToken(): string | null {
|
getRefreshToken(): string | null {
|
||||||
try {
|
try {
|
||||||
// Refresh token in localStorage for persistence across Mini App reopens
|
|
||||||
return localStorage.getItem(TOKEN_KEYS.REFRESH) || sessionStorage.getItem(TOKEN_KEYS.REFRESH);
|
return localStorage.getItem(TOKEN_KEYS.REFRESH) || sessionStorage.getItem(TOKEN_KEYS.REFRESH);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -84,13 +62,9 @@ export const tokenStorage = {
|
|||||||
setTokens(accessToken: string, refreshToken: string): void {
|
setTokens(accessToken: string, refreshToken: string): void {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken);
|
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken);
|
||||||
// Refresh token in localStorage — survives Mini App tab close/reopen
|
|
||||||
localStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken);
|
localStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken);
|
||||||
// Clean up old sessionStorage refresh token (migration)
|
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
||||||
} catch {
|
} catch {}
|
||||||
// Storage unavailable
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setAccessToken(accessToken: string): void {
|
setAccessToken(accessToken: string): void {
|
||||||
@@ -106,39 +80,26 @@ export const tokenStorage = {
|
|||||||
sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
|
sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.USER);
|
sessionStorage.removeItem(TOKEN_KEYS.USER);
|
||||||
// Также очищаем localStorage для миграции со старой версии
|
|
||||||
localStorage.removeItem(TOKEN_KEYS.ACCESS);
|
localStorage.removeItem(TOKEN_KEYS.ACCESS);
|
||||||
localStorage.removeItem(TOKEN_KEYS.REFRESH);
|
localStorage.removeItem(TOKEN_KEYS.REFRESH);
|
||||||
localStorage.removeItem(TOKEN_KEYS.USER);
|
localStorage.removeItem(TOKEN_KEYS.USER);
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Миграция токенов для обратной совместимости.
|
|
||||||
* Access token: sessionStorage (short-lived, OK to lose on tab close)
|
|
||||||
* Refresh token: localStorage (persistent, survives Mini App reopens)
|
|
||||||
*/
|
|
||||||
migrateFromLocalStorage(): void {
|
migrateFromLocalStorage(): void {
|
||||||
try {
|
try {
|
||||||
const accessToken = localStorage.getItem(TOKEN_KEYS.ACCESS);
|
const accessToken = localStorage.getItem(TOKEN_KEYS.ACCESS);
|
||||||
|
|
||||||
// Migrate access token to sessionStorage
|
|
||||||
if (accessToken && !sessionStorage.getItem(TOKEN_KEYS.ACCESS)) {
|
if (accessToken && !sessionStorage.getItem(TOKEN_KEYS.ACCESS)) {
|
||||||
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken);
|
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken);
|
||||||
}
|
}
|
||||||
localStorage.removeItem(TOKEN_KEYS.ACCESS);
|
localStorage.removeItem(TOKEN_KEYS.ACCESS);
|
||||||
|
|
||||||
// Migrate refresh token from sessionStorage to localStorage
|
|
||||||
const refreshInSession = sessionStorage.getItem(TOKEN_KEYS.REFRESH);
|
const refreshInSession = sessionStorage.getItem(TOKEN_KEYS.REFRESH);
|
||||||
if (refreshInSession && !localStorage.getItem(TOKEN_KEYS.REFRESH)) {
|
if (refreshInSession && !localStorage.getItem(TOKEN_KEYS.REFRESH)) {
|
||||||
localStorage.setItem(TOKEN_KEYS.REFRESH, refreshInSession);
|
localStorage.setItem(TOKEN_KEYS.REFRESH, refreshInSession);
|
||||||
}
|
}
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getTelegramInitData(): string | null {
|
getTelegramInitData(): string | null {
|
||||||
@@ -152,16 +113,10 @@ export const tokenStorage = {
|
|||||||
setTelegramInitData(data: string): void {
|
setTelegramInitData(data: string): void {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, data);
|
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, data);
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract Telegram user ID from raw initData string.
|
|
||||||
* Does NOT validate cryptographic signature — used only for client-side identity comparison.
|
|
||||||
*/
|
|
||||||
function extractTelegramUserId(initData: string): string | null {
|
function extractTelegramUserId(initData: string): string | null {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams(initData);
|
const params = new URLSearchParams(initData);
|
||||||
@@ -176,29 +131,14 @@ function extractTelegramUserId(initData: string): string | null {
|
|||||||
|
|
||||||
const TG_USER_ID_KEY = 'tg_user_id';
|
const TG_USER_ID_KEY = 'tg_user_id';
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect Telegram account switch and clear stale auth data.
|
|
||||||
*
|
|
||||||
* Telegram Mini App WebView shares localStorage across accounts on the same device
|
|
||||||
* (confirmed bug on Desktop, likely on mobile too). This means that when user A logs in,
|
|
||||||
* then user B opens the same Mini App, user A's refresh_token persists in localStorage.
|
|
||||||
*
|
|
||||||
* The old approach stored initData in sessionStorage for comparison, but sessionStorage
|
|
||||||
* is cleared on tab close — so the comparison never triggers between separate openings.
|
|
||||||
*
|
|
||||||
* Fix: store the Telegram user ID in localStorage (survives tab close) and compare it
|
|
||||||
* with the fresh initData on every app launch.
|
|
||||||
*/
|
|
||||||
export function clearStaleSessionIfNeeded(freshInitData: string | null): void {
|
export function clearStaleSessionIfNeeded(freshInitData: string | null): void {
|
||||||
if (!freshInitData) return;
|
if (!freshInitData) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentTgUserId = extractTelegramUserId(freshInitData);
|
const currentTgUserId = extractTelegramUserId(freshInitData);
|
||||||
|
|
||||||
// PRIMARY CHECK: compare Telegram user ID stored in localStorage (survives tab close)
|
|
||||||
const storedTgUserId = localStorage.getItem(TG_USER_ID_KEY);
|
const storedTgUserId = localStorage.getItem(TG_USER_ID_KEY);
|
||||||
|
|
||||||
if (storedTgUserId && currentTgUserId && storedTgUserId !== currentTgUserId) {
|
if (storedTgUserId && currentTgUserId && storedTgUserId !== currentTgUserId) {
|
||||||
// Account switch detected — purge all auth data
|
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
|
sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.USER);
|
sessionStorage.removeItem(TOKEN_KEYS.USER);
|
||||||
@@ -206,32 +146,15 @@ export function clearStaleSessionIfNeeded(freshInitData: string | null): void {
|
|||||||
localStorage.removeItem('cabinet-auth');
|
localStorage.removeItem('cabinet-auth');
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECONDARY CHECK: same-session switch (initData changed without tab close)
|
|
||||||
const storedInitData = sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT);
|
|
||||||
if (storedInitData && storedInitData !== freshInitData) {
|
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
|
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
|
|
||||||
sessionStorage.removeItem(TOKEN_KEYS.USER);
|
|
||||||
localStorage.removeItem(TOKEN_KEYS.REFRESH);
|
|
||||||
localStorage.removeItem('cabinet-auth');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist current Telegram user ID for future comparisons
|
|
||||||
if (currentTgUserId) {
|
if (currentTgUserId) {
|
||||||
localStorage.setItem(TG_USER_ID_KEY, currentTgUserId);
|
localStorage.setItem(TG_USER_ID_KEY, currentTgUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData);
|
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData);
|
||||||
localStorage.removeItem(TOKEN_KEYS.TELEGRAM_INIT);
|
localStorage.removeItem(TOKEN_KEYS.TELEGRAM_INIT);
|
||||||
} catch {
|
} catch {}
|
||||||
// Storage not available
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Централизованный менеджер обновления токенов
|
|
||||||
* Предотвращает множественные параллельные refresh запросы
|
|
||||||
*/
|
|
||||||
class TokenRefreshManager {
|
class TokenRefreshManager {
|
||||||
private isRefreshing = false;
|
private isRefreshing = false;
|
||||||
private refreshPromise: Promise<string | null> | null = null;
|
private refreshPromise: Promise<string | null> | null = null;
|
||||||
@@ -242,12 +165,7 @@ class TokenRefreshManager {
|
|||||||
this.refreshEndpoint = endpoint;
|
this.refreshEndpoint = endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Обновляет access token используя refresh token
|
|
||||||
* При множественных вызовах возвращает один и тот же Promise
|
|
||||||
*/
|
|
||||||
async refreshAccessToken(): Promise<string | null> {
|
async refreshAccessToken(): Promise<string | null> {
|
||||||
// Если уже идёт refresh - возвращаем существующий Promise
|
|
||||||
if (this.isRefreshing && this.refreshPromise) {
|
if (this.isRefreshing && this.refreshPromise) {
|
||||||
return this.refreshPromise;
|
return this.refreshPromise;
|
||||||
}
|
}
|
||||||
@@ -270,9 +188,9 @@ class TokenRefreshManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Uses plain axios (not apiClient) to avoid circular dependency
|
||||||
private async doRefresh(refreshToken: string): Promise<string | null> {
|
private async doRefresh(refreshToken: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// Используем чистый axios (не apiClient) чтобы избежать циклической зависимости
|
|
||||||
const response = await axios.post<{ access_token?: string }>(
|
const response = await axios.post<{ access_token?: string }>(
|
||||||
this.refreshEndpoint,
|
this.refreshEndpoint,
|
||||||
{ refresh_token: refreshToken },
|
{ refresh_token: refreshToken },
|
||||||
@@ -288,14 +206,10 @@ class TokenRefreshManager {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch {
|
} catch {
|
||||||
// Token refresh failed - don't log sensitive error details
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Подписка на результат refresh (для ожидающих запросов)
|
|
||||||
*/
|
|
||||||
subscribe(callback: (token: string | null) => void): () => void {
|
subscribe(callback: (token: string | null) => void): () => void {
|
||||||
this.subscribers.push(callback);
|
this.subscribers.push(callback);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -308,16 +222,10 @@ class TokenRefreshManager {
|
|||||||
this.subscribers = [];
|
this.subscribers = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверяет, идёт ли сейчас refresh
|
|
||||||
*/
|
|
||||||
get isRefreshInProgress(): boolean {
|
get isRefreshInProgress(): boolean {
|
||||||
return this.isRefreshing;
|
return this.isRefreshing;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ожидает завершения текущего refresh (если есть)
|
|
||||||
*/
|
|
||||||
async waitForRefresh(): Promise<string | null> {
|
async waitForRefresh(): Promise<string | null> {
|
||||||
if (this.refreshPromise) {
|
if (this.refreshPromise) {
|
||||||
return this.refreshPromise;
|
return this.refreshPromise;
|
||||||
@@ -328,27 +236,17 @@ class TokenRefreshManager {
|
|||||||
|
|
||||||
export const tokenRefreshManager = new TokenRefreshManager();
|
export const tokenRefreshManager = new TokenRefreshManager();
|
||||||
|
|
||||||
/**
|
|
||||||
* Ключ для сохранения URL для возврата после логина
|
|
||||||
*/
|
|
||||||
const RETURN_URL_KEY = 'auth_return_url';
|
const RETURN_URL_KEY = 'auth_return_url';
|
||||||
|
|
||||||
/**
|
|
||||||
* Сохраняет URL для возврата после авторизации
|
|
||||||
*/
|
|
||||||
export function saveReturnUrl(): void {
|
export function saveReturnUrl(): void {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const currentPath = window.location.pathname + window.location.search;
|
const currentPath = window.location.pathname + window.location.search;
|
||||||
// Не сохраняем /login как return URL
|
|
||||||
if (currentPath && currentPath !== '/login') {
|
if (currentPath && currentPath !== '/login') {
|
||||||
sessionStorage.setItem(RETURN_URL_KEY, currentPath);
|
sessionStorage.setItem(RETURN_URL_KEY, currentPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Получает и очищает сохранённый URL для возврата
|
|
||||||
*/
|
|
||||||
export function getAndClearReturnUrl(): string | null {
|
export function getAndClearReturnUrl(): string | null {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const url = sessionStorage.getItem(RETURN_URL_KEY);
|
const url = sessionStorage.getItem(RETURN_URL_KEY);
|
||||||
@@ -358,40 +256,22 @@ export function getAndClearReturnUrl(): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Безопасный редирект на страницу логина
|
|
||||||
* Валидирует URL чтобы предотвратить open redirect уязвимость
|
|
||||||
* Сохраняет текущий URL для возврата после авторизации
|
|
||||||
*/
|
|
||||||
export function safeRedirectToLogin(): void {
|
export function safeRedirectToLogin(): void {
|
||||||
// Разрешённые пути для редиректа (относительные пути нашего приложения)
|
|
||||||
const loginPath = '/login';
|
|
||||||
|
|
||||||
// Проверяем, что мы на том же origin
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Сохраняем текущий URL для возврата после логина
|
|
||||||
saveReturnUrl();
|
saveReturnUrl();
|
||||||
// Используем только относительный путь для безопасности
|
window.location.href = '/login';
|
||||||
window.location.href = loginPath;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Валидирует URL для редиректа
|
|
||||||
* Возвращает true только для безопасных URL (относительные пути или тот же origin)
|
|
||||||
*/
|
|
||||||
export function isValidRedirectUrl(url: string): boolean {
|
export function isValidRedirectUrl(url: string): boolean {
|
||||||
// Пустой URL - небезопасен
|
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
|
|
||||||
// Относительные пути всегда безопасны
|
|
||||||
if (url.startsWith('/') && !url.startsWith('//')) {
|
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url, window.location.origin);
|
const parsed = new URL(url, window.location.origin);
|
||||||
// Разрешаем только тот же origin
|
|
||||||
return parsed.origin === window.location.origin;
|
return parsed.origin === window.location.origin;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
export function saveTopUpPendingInfo(info: TopUpPendingInfo) {
|
export function saveTopUpPendingInfo(info: TopUpPendingInfo) {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(info));
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(info));
|
||||||
} catch {
|
} catch {}
|
||||||
// sessionStorage unavailable (private mode, quota, etc.)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadTopUpPendingInfo(): TopUpPendingInfo | null {
|
export function loadTopUpPendingInfo(): TopUpPendingInfo | null {
|
||||||
@@ -57,7 +55,5 @@ export function loadTopUpPendingInfo(): TopUpPendingInfo | null {
|
|||||||
export function clearTopUpPendingInfo() {
|
export function clearTopUpPendingInfo() {
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user