mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge PR #483: сдвиг страницы, мигание переключателя языка и перезапуск фона при SPA-навигации
fix(ui): сдвиг страницы, мигание переключателя языка и перезапуск фона при SPA-навигации
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
{/* Живёт над <Routes>: анимация фона не перезапускается при навигации */}
|
||||
<BackgroundHost />
|
||||
<BlockingOverlay />
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
|
||||
@@ -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<LanguageInfo[]>([]);
|
||||
const dropdownRef = useRef<HTMLDivElement>(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: '🇷🇺' };
|
||||
|
||||
45
src/components/backgrounds/BackgroundHost.tsx
Normal file
45
src/components/backgrounds/BackgroundHost.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { BackgroundRenderer } from './BackgroundRenderer';
|
||||
|
||||
/**
|
||||
* Держит анимированный фон смонтированным ПОВЕРХ смены роутов.
|
||||
*
|
||||
* Каждый роут оборачивается в собственный <Layout>, поэтому AppShell
|
||||
* перемонтируется при каждой навигации — если рендерить BackgroundRenderer
|
||||
* прямо из AppShell, анимация фона стартует заново на каждой странице.
|
||||
*
|
||||
* Вместо этого AppShell лишь регистрирует своё присутствие через
|
||||
* useBackgroundConsumer(), а BackgroundHost живёт в App (над <Routes>, не
|
||||
* перемонтируется) и держит один экземпляр 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 ? <BackgroundRenderer /> : null;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-viewport">
|
||||
{/* Animated background renders via portal on document.body at z-index: -1 */}
|
||||
<BackgroundRenderer />
|
||||
|
||||
{/* Global components */}
|
||||
<WebSocketNotifications />
|
||||
<CampaignBonusNotifier />
|
||||
@@ -190,7 +190,11 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<PromptDialogHost />
|
||||
|
||||
{/* Desktop Header */}
|
||||
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/95 lg:block">
|
||||
{/* w-screen вместо left-0 right-0: right-0 упирается в край вьюпорта БЕЗ
|
||||
скроллбара, и капсула по центру прыгала бы на полширины скроллбара при
|
||||
переходах между страницами со скроллом и без. 100vw даёт ту же ось
|
||||
центрирования, что и у body (тоже 100vw). */}
|
||||
<header className="fixed left-0 top-0 z-50 hidden w-screen border-b border-dark-800/50 bg-dark-950/95 lg:block">
|
||||
{/* 3-зонный grid: лого | капсула | действия. Колонки 1fr_auto_1fr держат
|
||||
капсулу строго по центру вьюпорта НЕЗАВИСИМО от ширины лого/действий,
|
||||
а действия — у правого края. Поэтому ничего не «скачет» при переходах
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user