- {/* Badge button */}
-
-
- {/* Dropdown - mobile: fixed centered, desktop: absolute right */}
- {isOpen && (
- <>
- {/* Mobile backdrop */}
-
setIsOpen(false)}
- />
-
-
- {/* Header */}
-
-
-
-
-
-
-
{t('promo.discountActive')}
-
- -{activeDiscount.discount_percent}%
-
-
-
-
-
- {/* Content */}
-
-
{t('promo.discountDescription')}
-
- {/* Time remaining */}
- {timeLeft && (
-
-
-
- {t('promo.expiresIn')}:{' '}
- {timeLeft}
-
-
- )}
-
- {/* Deactivation error */}
- {deactivateError && (
-
- {deactivateError}
-
- )}
-
- {/* CTA Button */}
-
-
- {/* Deactivate Button */}
-
-
-
- >
- )}
-
-
- {/* Deactivation Confirmation Modal */}
-
- >
- );
-}
diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx
index f14ee55..b0a3ac4 100644
--- a/src/components/PromoOffersSection.tsx
+++ b/src/components/PromoOffersSection.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router';
import { promoApi, PromoOffer } from '../api/promo';
import { ClockIcon, CheckIcon } from './icons';
import { usePlatform } from '@/platform/hooks/usePlatform';
diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx
index dcb4c45..a97b49a 100644
--- a/src/components/SuccessNotificationModal.tsx
+++ b/src/components/SuccessNotificationModal.tsx
@@ -6,11 +6,11 @@
import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router';
import { useSuccessNotification } from '../store/successNotification';
import { useCurrency } from '../hooks/useCurrency';
-import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
-import { useBackButton, useHaptic } from '@/platform';
+import { useTelegramSDK } from '../hooks/useTelegramSDK';
+import { useHaptic } from '@/platform';
// Icons
const CheckCircleIcon = () => (
@@ -80,7 +80,7 @@ export default function SuccessNotificationModal() {
const navigate = useNavigate();
const { isOpen, data, hide } = useSuccessNotification();
const { formatAmount, currencySymbol } = useCurrency();
- const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp();
+ const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK();
const haptic = useHaptic();
const safeBottom = isTelegramWebApp
@@ -106,9 +106,6 @@ export default function SuccessNotificationModal() {
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleClose]);
- // Telegram back button - using platform hook
- useBackButton(isOpen ? handleClose : null);
-
// Haptic feedback on open
useEffect(() => {
if (isOpen) {
diff --git a/src/components/ThemeBentoPicker.tsx b/src/components/ThemeBentoPicker.tsx
deleted file mode 100644
index dadc957..0000000
--- a/src/components/ThemeBentoPicker.tsx
+++ /dev/null
@@ -1,579 +0,0 @@
-import { useState, useEffect, useCallback, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets';
-import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion';
-import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme';
-import { applyThemeColors } from '../hooks/useThemeColors';
-
-interface ThemeBentoPickerProps {
- currentColors: ThemeColors;
- onColorsChange: (colors: ThemeColors) => void;
- onSave: () => void;
- isSaving: boolean;
-}
-
-const CheckIcon = () => (
-
-);
-
-const ChevronDownIcon = () => (
-
-);
-
-const MoonIcon = () => (
-
-);
-
-const SunIcon = () => (
-
-);
-
-const StatusIcon = () => (
-
-);
-
-const PaletteIcon = () => (
-
-);
-
-function PresetCard({
- preset,
- isSelected,
- onClick,
-}: {
- preset: ColorPreset;
- isSelected: boolean;
- onClick: () => void;
-}) {
- const { i18n } = useTranslation();
- const isRu = i18n.language === 'ru';
-
- return (
-
- );
-}
-
-function HSLSlider({
- label,
- value,
- onChange,
- max,
- gradient,
- suffix = '',
-}: {
- label: string;
- value: number;
- onChange: (value: number) => void;
- max: number;
- gradient: string;
- suffix?: string;
-}) {
- return (
-
-
-
-
- {value}
- {suffix}
-
-
-
onChange(parseInt(e.target.value))}
- className="h-2.5 w-full cursor-pointer appearance-none rounded-full"
- style={{ background: gradient }}
- />
-
- );
-}
-
-function CompactColorInput({
- label,
- value,
- onChange,
-}: {
- label: string;
- value: string;
- onChange: (color: string) => void;
-}) {
- const [localValue, setLocalValue] = useState(value);
- const [isEditing, setIsEditing] = useState(false);
-
- useEffect(() => {
- setLocalValue(value);
- }, [value]);
-
- const handleChange = (newValue: string) => {
- let formatted = newValue.toUpperCase();
- if (!formatted.startsWith('#')) {
- formatted = '#' + formatted;
- }
- setLocalValue(formatted);
- if (isValidHex(formatted)) {
- onChange(formatted);
- }
- };
-
- const handleBlur = () => {
- setIsEditing(false);
- if (!isValidHex(localValue)) {
- setLocalValue(value);
- }
- };
-
- return (
-
- );
-}
-
-function CollapsibleSection({
- title,
- icon,
- isOpen,
- onToggle,
- children,
- badge,
-}: {
- title: string;
- icon: React.ReactNode;
- isOpen: boolean;
- onToggle: () => void;
- children: React.ReactNode;
- badge?: string;
-}) {
- return (
-
-
-
-
-
- );
-}
-
-export function ThemeBentoPicker({
- currentColors,
- onColorsChange,
- onSave,
- isSaving,
-}: ThemeBentoPickerProps) {
- const { t } = useTranslation();
-
- const [hsl, setHsl] = useState
(() => hexToHsl(currentColors.accent));
- const [hexInput, setHexInput] = useState(currentColors.accent);
- const [hasChanges, setHasChanges] = useState(false);
-
- const [isPresetsOpen, setIsPresetsOpen] = useState(false);
- const [isAccentOpen, setIsAccentOpen] = useState(false);
- const [isDarkOpen, setIsDarkOpen] = useState(false);
- const [isLightOpen, setIsLightOpen] = useState(false);
- const [isStatusOpen, setIsStatusOpen] = useState(false);
-
- const selectedPresetId = useMemo(() => {
- const match = COLOR_PRESETS.find(
- (p) =>
- p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
- p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
- p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(),
- );
- return match?.id ?? null;
- }, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]);
-
- useEffect(() => {
- setHsl(hexToHsl(currentColors.accent));
- setHexInput(currentColors.accent);
- }, [currentColors.accent]);
-
- const updateColor = useCallback(
- (key: keyof ThemeColors, value: string) => {
- const newColors = { ...currentColors, [key]: value };
- onColorsChange(newColors);
- applyThemeColors(newColors);
- setHasChanges(true);
- },
- [currentColors, onColorsChange],
- );
-
- const updateAccentFromHsl = useCallback(
- (newHsl: HSLColor) => {
- setHsl(newHsl);
- const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l);
- setHexInput(newHex);
- updateColor('accent', newHex);
- },
- [updateColor],
- );
-
- const handleHexInputChange = (value: string) => {
- setHexInput(value);
- if (isValidHex(value)) {
- const newHsl = hexToHsl(value);
- setHsl(newHsl);
- updateColor('accent', value);
- }
- };
-
- const handlePresetSelect = (preset: ColorPreset) => {
- onColorsChange(preset.colors);
- applyThemeColors(preset.colors);
- setHasChanges(true);
- };
-
- const hueGradient = useMemo(() => {
- return `linear-gradient(to right,
- hsl(0, ${hsl.s}%, ${hsl.l}%),
- hsl(60, ${hsl.s}%, ${hsl.l}%),
- hsl(120, ${hsl.s}%, ${hsl.l}%),
- hsl(180, ${hsl.s}%, ${hsl.l}%),
- hsl(240, ${hsl.s}%, ${hsl.l}%),
- hsl(300, ${hsl.s}%, ${hsl.l}%),
- hsl(360, ${hsl.s}%, ${hsl.l}%)
- )`;
- }, [hsl.s, hsl.l]);
-
- const saturationGradient = useMemo(() => {
- return `linear-gradient(to right,
- hsl(${hsl.h}, 0%, ${hsl.l}%),
- hsl(${hsl.h}, 100%, ${hsl.l}%)
- )`;
- }, [hsl.h, hsl.l]);
-
- const lightnessGradient = useMemo(() => {
- return `linear-gradient(to right,
- hsl(${hsl.h}, ${hsl.s}%, 0%),
- hsl(${hsl.h}, ${hsl.s}%, 50%),
- hsl(${hsl.h}, ${hsl.s}%, 100%)
- )`;
- }, [hsl.h, hsl.s]);
-
- return (
-
-
}
- badge={`${COLOR_PRESETS.length}`}
- isOpen={isPresetsOpen}
- onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
- >
-
- {COLOR_PRESETS.map((preset, index) => (
-
-
handlePresetSelect(preset)}
- />
-
- ))}
-
-
-
-
-
- {t('admin.theme.customizeColors', 'Customize Colors')}
-
-
-
}
- badge={hexInput.toUpperCase()}
- isOpen={isAccentOpen}
- onToggle={() => setIsAccentOpen(!isAccentOpen)}
- >
-
-
-
-
- {hexInput.toUpperCase()}
-
-
-
-
updateAccentFromHsl({ ...hsl, h })}
- max={360}
- gradient={hueGradient}
- suffix="Β°"
- />
-
- updateAccentFromHsl({ ...hsl, s })}
- max={100}
- gradient={saturationGradient}
- suffix="%"
- />
-
- updateAccentFromHsl({ ...hsl, l })}
- max={100}
- gradient={lightnessGradient}
- suffix="%"
- />
-
-
-
- handleHexInputChange(e.target.value)}
- placeholder="#3b82f6"
- maxLength={7}
- className="input w-full font-mono text-sm uppercase"
- />
-
-
-
-
-
}
- isOpen={isDarkOpen}
- onToggle={() => setIsDarkOpen(!isDarkOpen)}
- >
-
- updateColor('darkBackground', c)}
- />
- updateColor('darkSurface', c)}
- />
- updateColor('darkText', c)}
- />
- updateColor('darkTextSecondary', c)}
- />
-
-
-
-
}
- isOpen={isLightOpen}
- onToggle={() => setIsLightOpen(!isLightOpen)}
- >
-
- updateColor('lightBackground', c)}
- />
- updateColor('lightSurface', c)}
- />
- updateColor('lightText', c)}
- />
- updateColor('lightTextSecondary', c)}
- />
-
-
-
-
}
- isOpen={isStatusOpen}
- onToggle={() => setIsStatusOpen(!isStatusOpen)}
- >
-
- updateColor('success', c)}
- />
- updateColor('warning', c)}
- />
- updateColor('error', c)}
- />
-
-
-
-
-
-
- {t('theme.preview', 'Preview')}
-
-
-
-
- {t('theme.success', 'Success')}
- {t('theme.warning', 'Warning')}
- {t('theme.error', 'Error')}
-
-
-
- {hasChanges && (
-
-
-
- )}
-
- );
-}
diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx
index c44a6f7..693f58d 100644
--- a/src/components/TicketNotificationBell.tsx
+++ b/src/components/TicketNotificationBell.tsx
@@ -1,12 +1,12 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ticketNotificationsApi } from '../api/ticketNotifications';
import { useAuthStore } from '../store/auth';
import { useToast } from './Toast';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
-import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
+import { useTelegramSDK } from '../hooks/useTelegramSDK';
import type { TicketNotification } from '../types';
const BellIcon = () => (
@@ -37,7 +37,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
const { showToast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
- const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
+ const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
const dropdownTop = isFullscreen
diff --git a/src/components/WebSocketNotifications.tsx b/src/components/WebSocketNotifications.tsx
index ea8cc70..3baffb3 100644
--- a/src/components/WebSocketNotifications.tsx
+++ b/src/components/WebSocketNotifications.tsx
@@ -4,7 +4,7 @@
*/
import { useCallback } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
diff --git a/src/components/admin/AdminBackButton.tsx b/src/components/admin/AdminBackButton.tsx
index 7972d13..5dc084b 100644
--- a/src/components/admin/AdminBackButton.tsx
+++ b/src/components/admin/AdminBackButton.tsx
@@ -1,4 +1,4 @@
-import { Link } from 'react-router-dom';
+import { Link } from 'react-router';
import { usePlatform } from '@/platform';
import { BackIcon } from './icons';
diff --git a/src/components/admin/AdminLayout.tsx b/src/components/admin/AdminLayout.tsx
deleted file mode 100644
index 526f5ae..0000000
--- a/src/components/admin/AdminLayout.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { ReactNode } from 'react';
-
-interface AdminLayoutProps {
- children: ReactNode;
- className?: string;
-}
-
-/**
- * AdminLayout - wrapper for all admin pages.
- * Animations removed to prevent black flash during transitions.
- */
-export function AdminLayout({ children, className }: AdminLayoutProps) {
- return {children}
;
-}
diff --git a/src/components/admin/BrandingTab.tsx b/src/components/admin/BrandingTab.tsx
index bcdd233..915fa29 100644
--- a/src/components/admin/BrandingTab.tsx
+++ b/src/components/admin/BrandingTab.tsx
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi, setCachedBranding } from '../../api/branding';
import { setCachedAnimationEnabled } from '../AnimatedBackground';
-import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
+import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK';
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
import { Toggle } from './Toggle';
diff --git a/src/components/admin/SettingsSidebar.tsx b/src/components/admin/SettingsSidebar.tsx
deleted file mode 100644
index 5236c00..0000000
--- a/src/components/admin/SettingsSidebar.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import { AdminBackButton, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
-
-interface SettingsSidebarProps {
- activeSection: string;
- setActiveSection: (section: string) => void;
- mobileMenuOpen: boolean;
- setMobileMenuOpen: (open: boolean) => void;
- favoritesCount: number;
-}
-
-export function SettingsSidebar({
- activeSection,
- setActiveSection,
- mobileMenuOpen,
- setMobileMenuOpen,
- favoritesCount,
-}: SettingsSidebarProps) {
- const { t } = useTranslation();
-
- return (
-
- );
-}
diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx
index 380d851..42b319a 100644
--- a/src/components/admin/ThemeTab.tsx
+++ b/src/components/admin/ThemeTab.tsx
@@ -59,6 +59,8 @@ export function ThemeTab() {
// Local draft state
const [draftColors, setDraftColors] = useState(DEFAULT_THEME_COLORS);
const savedColorsRef = useRef(DEFAULT_THEME_COLORS);
+ const draftColorsRef = useRef(draftColors);
+ draftColorsRef.current = draftColors;
// Sync server data into draft and saved snapshot when it arrives
useEffect(() => {
@@ -79,14 +81,14 @@ export function ThemeTab() {
};
// Only sync if saved snapshot matches current draft (no unsaved changes)
if (
- colorsEqual(savedColorsRef.current, draftColors) ||
+ colorsEqual(savedColorsRef.current, draftColorsRef.current) ||
colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS)
) {
setDraftColors(colors);
}
savedColorsRef.current = colors;
}
- }, [serverColors]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [serverColors]);
const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current);
diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts
index 3bbeafd..4fc1815 100644
--- a/src/components/admin/index.ts
+++ b/src/components/admin/index.ts
@@ -1,6 +1,5 @@
// Components
export * from './AdminBackButton';
-export * from './AdminLayout';
export * from './icons';
export * from './Toggle';
export * from './SettingInput';
@@ -10,7 +9,6 @@ export * from './BrandingTab';
export * from './ThemeTab';
export * from './FavoritesTab';
export * from './SettingsTab';
-export * from './SettingsSidebar';
export * from './SettingsMobileTabs';
export * from './SettingsSearch';
diff --git a/src/components/data-display/EmptyState/EmptyState.tsx b/src/components/data-display/EmptyState/EmptyState.tsx
deleted file mode 100644
index 466d02c..0000000
--- a/src/components/data-display/EmptyState/EmptyState.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { forwardRef, type ReactNode } from 'react';
-import { motion } from 'framer-motion';
-import { cn } from '@/lib/utils';
-import { Button, type ButtonProps } from '@/components/primitives/Button';
-import { fadeIn, fadeInTransition } from '@/components/motion/transitions';
-
-export interface EmptyStateProps {
- icon?: ReactNode;
- title: string;
- description?: string;
- action?: {
- label: string;
- onClick: () => void;
- variant?: ButtonProps['variant'];
- };
- className?: string;
-}
-
-export const EmptyState = forwardRef(
- ({ icon, title, description, action, className }, ref) => {
- return (
-
- {/* Icon */}
- {icon && (
-
- {icon}
-
- )}
-
- {/* Title */}
- {title}
-
- {/* Description */}
- {description && {description}
}
-
- {/* Action */}
- {action && (
-
-
-
- )}
-
- );
- },
-);
-
-EmptyState.displayName = 'EmptyState';
diff --git a/src/components/data-display/EmptyState/index.ts b/src/components/data-display/EmptyState/index.ts
deleted file mode 100644
index cbf85e5..0000000
--- a/src/components/data-display/EmptyState/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { EmptyState, type EmptyStateProps } from './EmptyState';
diff --git a/src/components/data-display/index.ts b/src/components/data-display/index.ts
index d3e63c3..353156e 100644
--- a/src/components/data-display/index.ts
+++ b/src/components/data-display/index.ts
@@ -1,3 +1,2 @@
export * from './Card';
export * from './StatCard';
-export * from './EmptyState';
diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx
index 9a0a9b1..4799b62 100644
--- a/src/components/layout/AppShell/AppHeader.tsx
+++ b/src/components/layout/AppShell/AppHeader.tsx
@@ -1,4 +1,4 @@
-import { Link, useLocation } from 'react-router-dom';
+import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx
index 7b4bf61..c7eeef6 100644
--- a/src/components/layout/AppShell/AppShell.tsx
+++ b/src/components/layout/AppShell/AppShell.tsx
@@ -1,26 +1,19 @@
-import { useEffect, useState, useCallback, useRef } from 'react';
-import { useLocation, useNavigate, Link } from 'react-router-dom';
+import { useEffect, useState } from 'react';
+import { useLocation, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/auth';
-import { useBackButton, useHaptic } from '@/platform';
+import { useHaptic } from '@/platform';
import { useTelegramSDK } from '@/hooks/useTelegramSDK';
import { useTheme } from '@/hooks/useTheme';
+import { useBranding } from '@/hooks/useBranding';
+import { useFeatureFlags } from '@/hooks/useFeatureFlags';
+import { useScrollRestoration } from '@/hooks/useScrollRestoration';
import { themeColorsApi } from '@/api/themeColors';
-import { referralApi } from '@/api/referral';
-import { wheelApi } from '@/api/wheel';
-import { contestsApi } from '@/api/contests';
-import { pollsApi } from '@/api/polls';
-import {
- brandingApi,
- getCachedBranding,
- setCachedBranding,
- preloadLogo,
- isLogoPreloaded,
-} from '@/api/branding';
-import { setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
+import { isLogoPreloaded } from '@/api/branding';
import { cn } from '@/lib/utils';
+import { UI } from '@/config/constants';
import WebSocketNotifications from '@/components/WebSocketNotifications';
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
@@ -176,9 +169,6 @@ const MoonIcon = ({ className }: { className?: string }) => (
);
-const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
-const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
-
interface AppShellProps {
children: React.ReactNode;
}
@@ -186,20 +176,17 @@ interface AppShellProps {
export function AppShell({ children }: AppShellProps) {
const { t } = useTranslation();
const location = useLocation();
- const navigate = useNavigate();
- const { isAdmin, isAuthenticated, logout } = useAuthStore();
- const {
- isFullscreen,
- safeAreaInset,
- contentSafeAreaInset,
- requestFullscreen,
- isTelegramWebApp,
- platform,
- isMobile,
- } = useTelegramSDK();
+ const { isAdmin, logout } = useAuthStore();
+ const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
+ useTelegramSDK();
const haptic = useHaptic();
const { toggleTheme, isDark } = useTheme();
+ // Extracted hooks
+ const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
+ const { referralEnabled, wheelEnabled, hasContests, hasPolls } = useFeatureFlags();
+ useScrollRestoration();
+
// Theme toggle visibility
const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'],
@@ -214,147 +201,6 @@ export function AppShell({ children }: AppShellProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
- // Scroll position restoration for admin pages
- const scrollPositions = useRef>({});
-
- // Disable browser's automatic scroll restoration
- useEffect(() => {
- if ('scrollRestoration' in history) {
- history.scrollRestoration = 'manual';
- }
- }, []);
-
- // Continuously save scroll position for current path
- useEffect(() => {
- const currentPath = location.pathname;
-
- // Only track scroll for admin pages
- if (!currentPath.startsWith('/admin')) return;
-
- const handleScroll = () => {
- scrollPositions.current[currentPath] = window.scrollY;
- };
-
- // Save on scroll
- window.addEventListener('scroll', handleScroll, { passive: true });
-
- // Restore scroll position immediately (synchronous)
- const savedPosition = scrollPositions.current[currentPath];
- if (savedPosition !== undefined && savedPosition > 0) {
- // Immediate restore
- window.scrollTo({ top: savedPosition, behavior: 'instant' });
- }
-
- return () => {
- window.removeEventListener('scroll', handleScroll);
- };
- }, [location.pathname]);
-
- // Branding
- 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("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,
- });
-
- // Apply fullscreen setting when loaded from server
- // Only apply on mobile Telegram (iOS/Android) - desktop doesn't need fullscreen
- useEffect(() => {
- if (!fullscreenSetting || !isTelegramWebApp) return;
-
- // Update cache for future app starts
- setCachedFullscreenEnabled(fullscreenSetting.enabled);
-
- // Request fullscreen if enabled, not already fullscreen, and on mobile Telegram
- if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
- requestFullscreen();
- }
- }, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
-
- // Feature flags
- 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,
- });
-
- // BackButton for Telegram Mini App
- // Don't show back button on main tab pages (bottom nav) - users navigate via tabs
- const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support', '/wheel'];
- const isMainTabPage = mainTabPaths.includes(location.pathname);
- const handleBack = useCallback(() => {
- if (mobileMenuOpen) {
- setMobileMenuOpen(false);
- return;
- }
- navigate(-1);
- }, [mobileMenuOpen, navigate]);
-
- useBackButton(isMainTabPage ? null : handleBack);
-
// Keyboard detection for hiding bottom nav
useEffect(() => {
const handleFocusIn = (e: FocusEvent) => {
@@ -405,7 +251,8 @@ export function AppShell({ children }: AppShellProps) {
// Calculate header height based on fullscreen mode (only on mobile Telegram)
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
- const telegramHeaderHeight = platform === 'android' ? 48 : 45;
+ const telegramHeaderHeight =
+ platform === 'android' ? UI.TELEGRAM_HEADER_ANDROID_PX : UI.TELEGRAM_HEADER_IOS_PX;
const headerHeight = isMobileFullscreen
? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + telegramHeaderHeight
: 64;
@@ -465,7 +312,7 @@ export function AppShell({ children }: AppShellProps) {
{item.label}
))}
- {referralTerms?.is_enabled && (
+ {referralEnabled && (
0}
- hasPolls={(pollsCount?.count ?? 0) > 0}
+ wheelEnabled={wheelEnabled}
+ referralEnabled={referralEnabled}
+ hasContests={hasContests}
+ hasPolls={hasPolls}
/>
{/* Desktop spacer */}
@@ -559,8 +406,8 @@ export function AppShell({ children }: AppShellProps) {
{/* Mobile Bottom Navigation */}
);
diff --git a/src/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx
index 415f7d5..e73f726 100644
--- a/src/components/layout/AppShell/Aurora.tsx
+++ b/src/components/layout/AppShell/Aurora.tsx
@@ -154,6 +154,14 @@ export function Aurora() {
const background = isDark ? themeColors.darkBackground : themeColors.lightBackground;
const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface;
+ // Refs for initial color values (WebGL context shouldn't recreate on color change)
+ const backgroundRef = useRef(background);
+ backgroundRef.current = background;
+ const surfaceRef = useRef(surface);
+ surfaceRef.current = surface;
+ const accentRef = useRef(themeColors.accent);
+ accentRef.current = themeColors.accent;
+
// Initialize WebGL context once (only depends on isEnabled)
useEffect(() => {
if (!isEnabled || !containerRef.current) return;
@@ -175,7 +183,11 @@ export function Aurora() {
const geometry = new Triangle(gl);
- const colorStops = generateColorStops(background, surface, themeColors.accent);
+ const colorStops = generateColorStops(
+ backgroundRef.current,
+ surfaceRef.current,
+ accentRef.current,
+ );
const colorStopsArray = colorStops
.map((hex) => {
const c = new Color(hex);
@@ -238,7 +250,7 @@ export function Aurora() {
rendererRef.current = null;
programRef.current = null;
};
- }, [isEnabled]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [isEnabled]);
// Update color uniforms reactively without recreating WebGL context
useEffect(() => {
diff --git a/src/components/layout/AppShell/DesktopSidebar.tsx b/src/components/layout/AppShell/DesktopSidebar.tsx
index ecebd2f..9b71433 100644
--- a/src/components/layout/AppShell/DesktopSidebar.tsx
+++ b/src/components/layout/AppShell/DesktopSidebar.tsx
@@ -1,4 +1,4 @@
-import { Link, useLocation } from 'react-router-dom';
+import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { useQuery } from '@tanstack/react-query';
diff --git a/src/components/layout/AppShell/MobileBottomNav.tsx b/src/components/layout/AppShell/MobileBottomNav.tsx
index a51cc30..72463b7 100644
--- a/src/components/layout/AppShell/MobileBottomNav.tsx
+++ b/src/components/layout/AppShell/MobileBottomNav.tsx
@@ -1,4 +1,4 @@
-import { Link, useLocation } from 'react-router-dom';
+import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
diff --git a/src/components/layout/AppShell/MovingGradient.tsx b/src/components/layout/AppShell/MovingGradient.tsx
deleted file mode 100644
index d53ec30..0000000
--- a/src/components/layout/AppShell/MovingGradient.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { motion } from 'framer-motion';
-
-/**
- * Animated moving gradient background.
- * Uses CSS variables for colors to support theme switching.
- * Lightweight - pure CSS gradients with Framer Motion animation.
- */
-export function MovingGradient() {
- return (
-