mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
79
src/hooks/useBranding.ts
Normal file
79
src/hooks/useBranding.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]>(() => {
|
||||
|
||||
49
src/hooks/useFeatureFlags.ts
Normal file
49
src/hooks/useFeatureFlags.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
src/hooks/useScrollRestoration.ts
Normal file
40
src/hooks/useScrollRestoration.ts
Normal 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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user