From b40f939e914453d3080bde6051a7dbcf66a42321 Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:46:22 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(ui):=20=D1=84=D0=B8=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B0=20100vw=20=E2=80=94=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=BD=D0=B5=20=D1=81?= =?UTF-8?q?=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BF=D0=BE=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BA=D1=80=D0=BE=D0=BB=D0=BB=D0=B1=D0=B0=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/AppShell/AppShell.tsx | 6 +++++- src/styles/globals.css | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 4afc448..05d9e3c 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -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, From a7ff8a6b09281df2b971cddf30a95a4f8b774205 Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:46:24 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(header):=20=D0=BA=D1=8D=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BE=D0=BA=20=D1=8F=D0=B7=D1=8B=D0=BA=D0=BE=D0=B2=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20=D0=BD=D0=B5=20=D0=BC=D0=B8=D0=B3=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LanguageSwitcher.tsx | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) 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: '🇷🇺' }; From 1ea9b99ab579f709db440b103cd37f93b92927ca Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:52:36 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(ui):=20=D0=BD=D0=B5=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=84=D0=BE=D0=BD=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 3 ++ src/components/backgrounds/BackgroundHost.tsx | 45 +++++++++++++++++++ src/components/layout/AppShell/AppShell.tsx | 8 ++-- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/components/backgrounds/BackgroundHost.tsx 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/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 05d9e3c..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 */}