refactor: full codebase cleanup, dependency updates, and lint fixes

Phase 0: Remove ~920 lines of dead code (ThemeBentoPicker, PromoDiscountBadge,
AdminLayout, SettingsSidebar, MovingGradient, EmptyState, miniapp API, unused
types/functions/transitions/skeleton helpers). Fix API barrel file (add 20 missing exports).

Phase 1: Add ErrorBoundary (app/page/widget levels), centralize constants,
add axios timeout, fix staleTime:0 in React Query.

Phase 2: Consolidate hexToHsl, extract email validation, fix duplicate code.

Phase 3: Fix auth race condition (await checkAdminStatus), memoize useCurrency,
add WebSocket message validation.

Phase 4: Extract useBranding, useFeatureFlags, useScrollRestoration from AppShell.

Phase 5: Remove all eslint-disable react-hooks/exhaustive-deps (14 total),
simplify logger, remove deprecated hooks (useBackButton, useTelegramDnd,
useTelegramWebApp).

Dependencies: React 19, react-router 7, zustand 5, i18next 25, react-i18next 16,
eslint-plugin-react-refresh 0.5. Remove unused @lottiefiles/dotlottie-react.
Convert vite manualChunks to function-based approach for react-router v7 compat.

Lint: Fix logger.ts no-unused-expressions, fix react-refresh/only-export-components
in 6 Radix primitive files (const re-exports → direct re-exports), fix
WebSocketProvider exhaustive-deps. Result: 0 errors, 0 warnings.

Add CLAUDE.md to .gitignore.
This commit is contained in:
c0mrade
2026-02-06 01:35:12 +03:00
parent c5cad20a6f
commit 562ab7abf7
118 changed files with 1243 additions and 2746 deletions

79
src/hooks/useBranding.ts Normal file
View File

@@ -0,0 +1,79 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { useTelegramSDK, setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
export function useBranding() {
const { isAuthenticated } = useAuthStore();
const { isFullscreen, isTelegramWebApp, requestFullscreen, isMobile } = useTelegramSDK();
// Branding data
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
staleTime: 60000,
enabled: isAuthenticated,
});
const appName = branding ? branding.name : FALLBACK_NAME;
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
// Set document title
useEffect(() => {
document.title = appName || 'VPN';
}, [appName]);
// Update favicon
useEffect(() => {
if (!logoUrl) return;
const link =
document.querySelector<HTMLLinkElement>("link[rel*='icon']") ||
document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = logoUrl;
document.head.appendChild(link);
}, [logoUrl]);
// Fullscreen setting from server
const { data: fullscreenSetting } = useQuery({
queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled,
staleTime: 60000,
});
useEffect(() => {
if (!fullscreenSetting || !isTelegramWebApp) return;
setCachedFullscreenEnabled(fullscreenSetting.enabled);
if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
requestFullscreen();
}
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
return {
appName,
logoLetter,
hasCustomLogo,
logoUrl,
isLogoPreloaded,
};
}

View File

@@ -1,3 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { currencyApi, type ExchangeRates } from '../api/currency';
@@ -40,65 +41,93 @@ export function useCurrency() {
const currencySymbol = t('common.currency');
// Format amount with currency conversion
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
if (isRussian) {
return rubAmount.toFixed(decimals);
}
const formatAmount = useCallback(
(rubAmount: number, decimals: number = 2): string => {
if (isRussian) {
return rubAmount.toFixed(decimals);
}
// Convert to target currency
const convertedAmount = currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates,
);
// Convert to target currency
const convertedAmount = currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates,
);
// For IRR (Iranian Toman), use no decimals as amounts are large
if (targetCurrency === 'IRR') {
return Math.round(convertedAmount).toLocaleString('fa-IR');
}
// For IRR (Iranian Toman), use no decimals as amounts are large
if (targetCurrency === 'IRR') {
return Math.round(convertedAmount).toLocaleString('fa-IR');
}
return convertedAmount.toFixed(decimals);
};
return convertedAmount.toFixed(decimals);
},
[isRussian, targetCurrency, exchangeRates],
);
// Format amount with currency symbol
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
};
const formatWithCurrency = useCallback(
(rubAmount: number, decimals: number = 2): string => {
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
},
[formatAmount, currencySymbol],
);
// Format amount with + sign (for earnings/bonuses)
const formatPositive = (rubAmount: number, decimals: number = 2): string => {
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
};
const formatPositive = useCallback(
(rubAmount: number, decimals: number = 2): string => {
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
},
[formatAmount, currencySymbol],
);
// Get raw converted amount (for calculations)
const convertAmount = (rubAmount: number): number => {
if (isRussian) {
return rubAmount;
}
return currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates,
);
};
const convertAmount = useCallback(
(rubAmount: number): number => {
if (isRussian) {
return rubAmount;
}
return currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates,
);
},
[isRussian, targetCurrency, exchangeRates],
);
// Convert from user's currency back to rubles
const convertToRub = (amount: number): number => {
if (isRussian) {
return amount;
}
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
};
const convertToRub = useCallback(
(amount: number): number => {
if (isRussian) {
return amount;
}
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
},
[isRussian, targetCurrency, exchangeRates],
);
return {
exchangeRates,
targetCurrency,
isRussian,
currencySymbol,
formatAmount,
formatWithCurrency,
formatPositive,
convertAmount,
convertToRub,
};
return useMemo(
() => ({
exchangeRates,
targetCurrency,
isRussian,
currencySymbol,
formatAmount,
formatWithCurrency,
formatPositive,
convertAmount,
convertToRub,
}),
[
exchangeRates,
targetCurrency,
isRussian,
currencySymbol,
formatAmount,
formatWithCurrency,
formatPositive,
convertAmount,
convertToRub,
],
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { STORAGE_KEYS } from '../config/constants';
const STORAGE_KEY = 'admin_favorite_settings';
const STORAGE_KEY = STORAGE_KEYS.FAVORITE_SETTINGS;
export function useFavoriteSettings() {
const [favorites, setFavorites] = useState<string[]>(() => {

View File

@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { referralApi } from '@/api/referral';
import { wheelApi } from '@/api/wheel';
import { contestsApi } from '@/api/contests';
import { pollsApi } from '@/api/polls';
export function useFeatureFlags() {
const { isAuthenticated } = useAuthStore();
const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: contestsCount } = useQuery({
queryKey: ['contests-count'],
queryFn: contestsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: pollsCount } = useQuery({
queryKey: ['polls-count'],
queryFn: pollsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
return {
referralEnabled: referralTerms?.is_enabled,
wheelEnabled: wheelConfig?.is_enabled,
hasContests: (contestsCount?.count ?? 0) > 0,
hasPolls: (pollsCount?.count ?? 0) > 0,
};
}

View File

@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
/**
* Saves and restores scroll position for admin pages.
* Disables browser's automatic scroll restoration.
*/
export function useScrollRestoration() {
const location = useLocation();
const scrollPositions = useRef<Record<string, number>>({});
// Disable browser's automatic scroll restoration
useEffect(() => {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
}, []);
// Save/restore scroll for admin pages
useEffect(() => {
const currentPath = location.pathname;
if (!currentPath.startsWith('/admin')) return;
const handleScroll = () => {
scrollPositions.current[currentPath] = window.scrollY;
};
window.addEventListener('scroll', handleScroll, { passive: true });
const savedPosition = scrollPositions.current[currentPath];
if (savedPosition !== undefined && savedPosition > 0) {
window.scrollTo({ top: savedPosition, behavior: 'instant' });
}
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [location.pathname]);
}

View File

@@ -1,30 +0,0 @@
import { useCallback } from 'react';
import { useTelegramSDK } from './useTelegramSDK';
/**
* Hook for drag-and-drop operations in Telegram Mini App.
* Note: Vertical swipes are now globally disabled at app init,
* so this hook just provides no-op callbacks for compatibility.
*/
export function useTelegramDnd() {
const { isTelegramWebApp } = useTelegramSDK();
const onDragStart = useCallback(() => {
// No-op: swipes are globally disabled
}, []);
const onDragEnd = useCallback(() => {
// No-op: swipes are globally disabled
}, []);
const onDragCancel = useCallback(() => {
// No-op: swipes are globally disabled
}, []);
return {
onDragStart,
onDragEnd,
onDragCancel,
isTelegramWebApp,
};
}

View File

@@ -1,36 +0,0 @@
/**
* @deprecated This hook is deprecated. Use useTelegramSDK instead.
* This file is kept for backward compatibility and re-exports from useTelegramSDK.
*/
import { useTelegramSDK } from './useTelegramSDK';
// Re-export everything from useTelegramSDK for backward compatibility
export {
getCachedFullscreenEnabled,
setCachedFullscreenEnabled,
isInTelegramWebApp,
isTelegramMobile,
} from './useTelegramSDK';
/**
* @deprecated Use useTelegramSDK instead
* Hook for Telegram WebApp API integration - backward compatible wrapper
*/
export function useTelegramWebApp() {
const sdk = useTelegramSDK();
return {
isTelegramWebApp: sdk.isTelegramWebApp,
isFullscreen: sdk.isFullscreen,
isFullscreenSupported: sdk.isFullscreenSupported,
safeAreaInset: sdk.safeAreaInset,
contentSafeAreaInset: sdk.contentSafeAreaInset,
requestFullscreen: sdk.requestFullscreen,
exitFullscreen: sdk.exitFullscreen,
toggleFullscreen: sdk.toggleFullscreen,
disableVerticalSwipes: sdk.disableVerticalSwipes,
enableVerticalSwipes: sdk.enableVerticalSwipes,
webApp: null,
};
}

View File

@@ -1,11 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
import { themeColorsApi } from '../api/themeColors';
import { STORAGE_KEYS } from '../config/constants';
type Theme = 'dark' | 'light';
const THEME_KEY = 'cabinet-theme';
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes';
const THEME_KEY = STORAGE_KEYS.THEME;
const ENABLED_THEMES_KEY = STORAGE_KEYS.ENABLED_THEMES;
// Fetch enabled themes from API
async function fetchEnabledThemes(): Promise<EnabledThemes> {
@@ -82,18 +83,21 @@ export function useTheme() {
return enabled.dark ? 'dark' : 'light';
});
const themeRef = useRef(theme);
themeRef.current = theme;
// Fetch enabled themes on mount
useEffect(() => {
fetchEnabledThemes().then((data) => {
setEnabledThemes(data);
setIsLoading(false);
// If current theme is disabled, switch to enabled one
if (!data[theme]) {
if (!data[themeRef.current]) {
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
// Listen for localStorage changes (when admin updates enabled themes from other tabs)
useEffect(() => {

View File

@@ -2,66 +2,7 @@ import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { themeColorsApi } from '../api/themeColors';
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme';
// Convert hex to RGB values
function hexToRgb(hex: string): { r: number; g: number; b: number } {
// Handle shorthand hex
if (hex.length === 4) {
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
}
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
// Convert hex to HSL
function hexToHsl(hex: string): { h: number; s: number; l: number } {
const { r, g, b } = hexToRgb(hex);
const rNorm = r / 255;
const gNorm = g / 255;
const bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rNorm:
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
break;
case gNorm:
h = ((bNorm - rNorm) / d + 2) / 6;
break;
case bNorm:
h = ((rNorm - gNorm) / d + 4) / 6;
break;
}
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
// Convert HSL to RGB values
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color);
};
return { r: f(0), g: f(8), b: f(4) };
}
import { hexToRgb, hexToHsl, hslToRgb } from '../utils/colorConversion';
// Convert RGB to string format for CSS variable
function rgbToString(r: number, g: number, b: number): string {

View File

@@ -7,8 +7,9 @@ import {
DEFAULT_USER_PREFERENCES,
BORDER_RADIUS_VALUES,
} from '../types/theme';
import { STORAGE_KEYS } from '../config/constants';
const STORAGE_KEY = 'user_theme_preferences';
const STORAGE_KEY = STORAGE_KEYS.USER_THEME_PREFS;
/**
* Parse preferences from storage string