diff --git a/src/App.tsx b/src/App.tsx index dfb426e..0a6d145 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import { ServiceUnavailableScreen, } from './components/blocking'; import { ErrorBoundary } from './components/ErrorBoundary'; +import { BackgroundHost } from './components/backgrounds/BackgroundHost'; import { PermissionRoute } from '@/components/auth/PermissionRoute'; import { saveReturnUrl } from './utils/token'; import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'; @@ -259,6 +260,8 @@ function App() { return ( <> + {/* Живёт над : анимация фона не перезапускается при навигации */} + {/* Public routes */} diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 45585f3..47b95a7 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -1,25 +1,23 @@ import { useTranslation } from 'react-i18next'; import { useState, useRef, useEffect } from 'react'; -import { infoApi, type LanguageInfo } from '@/api/info'; +import { useQuery } from '@tanstack/react-query'; +import { infoApi } from '@/api/info'; import { ChevronDownIcon } from '@/components/icons'; export default function LanguageSwitcher() { const { i18n } = useTranslation(); const [isOpen, setIsOpen] = useState(false); - const [availableLanguages, setAvailableLanguages] = useState([]); const dropdownRef = useRef(null); - useEffect(() => { - const fetchLanguages = async () => { - try { - const data = await infoApi.getLanguages(); - setAvailableLanguages(data.languages); - } catch { - // Silently fall back to empty list — component handles it gracefully - } - }; - fetchLanguages(); - }, []); + // Кэш react-query переживает перемонтирование AppShell при смене роута: + // локальный useState начинал каждый маунт с пустого списка, и до ответа API + // компонент рендерил null — переключатель «мигал» при каждой навигации. + const { data } = useQuery({ + queryKey: ['languages'], + queryFn: infoApi.getLanguages, + staleTime: 1000 * 60 * 5, + }); + const availableLanguages = data?.languages ?? []; const currentLang = availableLanguages.find((l) => l.code === i18n.language) || availableLanguages[0] || { code: 'ru', name: 'RU', flag: '🇷🇺' }; diff --git a/src/components/backgrounds/BackgroundHost.tsx b/src/components/backgrounds/BackgroundHost.tsx new file mode 100644 index 0000000..bc1c4ca --- /dev/null +++ b/src/components/backgrounds/BackgroundHost.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import { create } from 'zustand'; +import { BackgroundRenderer } from './BackgroundRenderer'; + +/** + * Держит анимированный фон смонтированным ПОВЕРХ смены роутов. + * + * Каждый роут оборачивается в собственный , поэтому AppShell + * перемонтируется при каждой навигации — если рендерить BackgroundRenderer + * прямо из AppShell, анимация фона стартует заново на каждой странице. + * + * Вместо этого AppShell лишь регистрирует своё присутствие через + * useBackgroundConsumer(), а BackgroundHost живёт в App (над , не + * перемонтируется) и держит один экземпляр BackgroundRenderer, пока есть хоть + * один потребитель. Страницы без AppShell (логин, QuickPurchase со своим + * экземпляром) фон, как и раньше, не получают. + */ +const useBackgroundConsumers = create<{ count: number }>(() => ({ count: 0 })); + +/** Вызывается из AppShell: «на этом роуте должен быть анимированный фон». */ +export function useBackgroundConsumer() { + useEffect(() => { + useBackgroundConsumers.setState((s) => ({ count: s.count + 1 })); + return () => useBackgroundConsumers.setState((s) => ({ count: s.count - 1 })); + }, []); +} + +export function BackgroundHost() { + const active = useBackgroundConsumers((s) => s.count > 0); + const [mounted, setMounted] = useState(active); + + useEffect(() => { + if (active) { + setMounted(true); + return; + } + // Грейс-период на случай, когда между размонтированием старого AppShell и + // маунтом нового есть зазор (StrictMode, Suspense): без него фон моргал бы + // и на таких переходах. + const timer = setTimeout(() => setMounted(false), 300); + return () => clearTimeout(timer); + }, [active]); + + return mounted ? : null; +} diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 4afc448..18188cb 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -39,7 +39,7 @@ import { import { MobileBottomNav } from './MobileBottomNav'; import { AppHeader } from './AppHeader'; -import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer'; +import { useBackgroundConsumer } from '@/components/backgrounds/BackgroundHost'; interface AppShellProps { children: React.ReactNode; @@ -60,6 +60,9 @@ export function AppShell({ children }: AppShellProps) { const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding(); const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags(); useScrollRestoration(); + // Анимированный фон рендерит BackgroundHost в App (не перемонтируется при + // смене роута) — здесь только регистрируем, что на этом роуте он нужен. + useBackgroundConsumer(); // Theme toggle visibility const { data: enabledThemes } = useQuery({ @@ -180,9 +183,6 @@ export function AppShell({ children }: AppShellProps) { return (
- {/* Animated background renders via portal on document.body at z-index: -1 */} - - {/* Global components */} @@ -190,7 +190,11 @@ export function AppShell({ children }: AppShellProps) { {/* Desktop Header */} -
+ {/* w-screen вместо left-0 right-0: right-0 упирается в край вьюпорта БЕЗ + скроллбара, и капсула по центру прыгала бы на полширины скроллбара при + переходах между страницами со скроллом и без. 100vw даёт ту же ось + центрирования, что и у body (тоже 100vw). */} +
{/* 3-зонный grid: лого | капсула | действия. Колонки 1fr_auto_1fr держат капсулу строго по центру вьюпорта НЕЗАВИСИМО от ширины лого/действий, а действия — у правого края. Поэтому ничего не «скачет» при переходах diff --git a/src/styles/globals.css b/src/styles/globals.css index 2cbdb86..1c5bde0 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -235,6 +235,17 @@ scrollbar-color: rgb(var(--color-dark-700)) transparent; } + body { + /* 100vw включает ширину скроллбара, поэтому центрированный контент не + сдвигается, когда при навигации появляется/исчезает вертикальный + скроллбар; лишние ~6px справа уходят под скроллбар и срезаются + overflow-x: hidden на html. Вариант со scrollbar-gutter: stable не + подошёл: фоновый слой (fixed-портал) не красит зарезервированный жёлоб, + и справа остаётся пустая полоса. На мобильных overlay-скроллбары места + не занимают — правило ни на что не влияет. */ + width: 100vw; + } + /* Hide scrollbar on mobile (iOS/Android) */ @media (max-width: 1023px) { html,