diff --git a/client/src/App.tsx b/client/src/App.tsx index 4c58777..e54f545 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import { Switch, Route, useLocation } from "wouter"; +import { Switch, Route, Redirect, useLocation } from "wouter"; import { queryClient } from "./lib/queryClient"; import { QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/toaster"; @@ -16,6 +16,7 @@ import { usePwaFontSize } from "@/hooks/usePwaFontSize"; import { useGpsTracking } from "@/hooks/useGpsTracking"; import { invalidateHomeQueriesThrottled } from "@/lib/throttledInvalidate"; import { GlobalErrorBoundary } from "@/components/GlobalErrorBoundary"; +import { AppShell } from "@/components/AppShell"; import { Loader2 } from "lucide-react"; type LazyModule = { default: ComponentType }; @@ -159,7 +160,10 @@ function useRouteTitleAndFocus() { } function Router() { return ( - }> + // AppShell — снаружи Suspense: при подгрузке lazy-чанка страницы хром + // (Sidebar/навигация) остаётся смонтированным, fallback показывается внутри. + + }> @@ -188,7 +192,7 @@ function Router() { return ( - {() => { window.location.href = '/field-templates'; return null; }} + {() => } @@ -221,7 +225,8 @@ function Router() { return ( - + + ); } @@ -256,6 +261,7 @@ function GlobalEventListener() { */ function ForegroundPushHandler() { const { toast } = useToast(); + const [, setLocation] = useLocation(); useEffect(() => { if (!('serviceWorker' in navigator)) return; @@ -286,7 +292,17 @@ function ForegroundPushHandler() { // Тост с action («Ответить») не закрываем по таймеру — пользователь может не успеть нажать duration: isChat ? Infinity : 6000, action: isChat ? ( - { window.location.href = url; }}> + { + // Внутренние пути — SPA-навигация без перезагрузки; внешние URL (подстраховка) — как раньше + if (url.startsWith('/')) { + setLocation(url); + } else { + window.location.href = url; + } + }} + > Ответить ) : undefined, @@ -295,7 +311,7 @@ function ForegroundPushHandler() { navigator.serviceWorker.addEventListener('message', handler); return () => navigator.serviceWorker.removeEventListener('message', handler); - }, [toast]); + }, [toast, setLocation]); return null; } diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx new file mode 100644 index 0000000..33d2705 --- /dev/null +++ b/client/src/components/AppShell.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react'; +import { useLocation } from 'wouter'; +import { useQuery } from '@tanstack/react-query'; +import { Loader2, AlertTriangle } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; +import { Sidebar } from '@/components/Sidebar'; +import { AddBookmarkFAB } from '@/components/AddBookmarkFAB'; +import { apiRequest } from '@/lib/queryClient'; +import { isAdmin } from '@/lib/permissions'; +import { cn } from '@/lib/utils'; + +/** + * Пути страниц с общим хромом (Sidebar/MobileBottomNav, биллинг-оверлей, FAB) — + * те, что раньше сами оборачивались в Layout. + * Не-shell страницы проходят сквозь без хрома и auth-gate: + * публичные (/, /login, /register, /invite, /forgot-password, /reset-password, + * /privacy-policy, /superadmin*, /share-target, /qr/*) и страницы со своим + * каркасом — /billing, /documents/* (DocumentLayout), /chat (свой Sidebar), + * /field-templates (GlobalFields без Layout). + */ +const SHELL_ROUTE_PATTERNS: RegExp[] = [ + /^\/users/, + /^\/forms/, + /^\/directories/, + /^\/tasks\//, + /^\/projects/, + /^\/reports/, + /^\/settings/, + /^\/profile/, + /^\/bots/, + /^\/pages\//, + /^\/home/, + /^\/dashboard/, + /^\/calendar/, + /^\/gantt/, + /^\/med/, + /^\/gps/, + /^\/notifications/, + // org-префиксные роуты задач: /:org/tasks/... и /:org/forms/... + /^\/[^/]+\/(tasks|forms)\//, +]; + +/** Оверлей блокировки биллинга с плавным появлением (opacity transition) */ +function BillingBlockedOverlay() { + // Состояние mounted → запускаем fade-in после монтирования + const [visible, setVisible] = useState(false); + useEffect(() => { + const raf = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(raf); + }, []); + + return ( +
+
+
+
+
+
+

Доступ приостановлен

+

+ Баланс вашей организации исчерпан. Функции системы временно недоступны. + Обратитесь к администратору системы для пополнения баланса. +

+ + Перейти к биллингу + +
+
+ ); +} + +/** + * Единый persistent shell приложения: auth-gate, биллинг-оверлей, Sidebar и + * AddBookmarkFAB монтируются один раз и не размонтируются при переходах между + * shell-страницами. Контент (Switch с роутами) рендерится как children. + */ +export function AppShell({ children }: { children: React.ReactNode }) { + const { user, isLoading } = useAuth(); + const [location, setLocation] = useLocation(); + const isShellRoute = SHELL_ROUTE_PATTERNS.some((pattern) => pattern.test(location)); + + useEffect(() => { + if (!isShellRoute || isLoading || user) return; + // Сохраняем полный URL (pathname + search), чтобы после входа + // восстановить и целевую страницу, и query-параметры (например, ?prefill_field_3=546). + const current = window.location.pathname + window.location.search; + const isAuthPage = current === '/' || current.startsWith('/login'); + const returnTo = !isAuthPage ? current : ''; + setLocation(returnTo ? `/login?returnTo=${encodeURIComponent(returnTo)}` : '/login'); + }, [isShellRoute, isLoading, user, location, setLocation]); + + // Check billing block status for admins + const { data: billingSummary } = useQuery({ + queryKey: ['/api/billing/summary'], + queryFn: () => apiRequest('GET', '/api/billing/summary').then(r => r.json()), + enabled: !!user && isAdmin(user), + // Don't throw on 402 — it just means blocked, we handle it via the overlay + retry: false, + staleTime: 60_000, + }); + + if (!isShellRoute) { + return <>{children}; + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!user) { + return null; + } + + const isBillingBlocked = billingSummary?.summary?.billingBlocked === true; + // Show overlay everywhere except billing page itself (она вне shell — проверка подстраховка) + const showBlockedOverlay = isBillingBlocked && !location.startsWith('/billing'); + + return ( + + {showBlockedOverlay && } + {children} + + + ); +} + +export default AppShell; diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index bf4eb2b..bc44713 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -1,12 +1,4 @@ -import { useAuth } from '@/hooks/useAuth'; -import { Sidebar } from '@/components/Sidebar'; -import { AddBookmarkFAB } from '@/components/AddBookmarkFAB'; -import { useLocation } from 'wouter'; -import { useEffect } from 'react'; -import { Loader2, AlertTriangle } from 'lucide-react'; -import { useQuery } from '@tanstack/react-query'; -import { apiRequest } from '@/lib/queryClient'; -import { isAdmin } from '@/lib/permissions'; +import { useScrollRestoration } from '@/hooks/useScrollRestoration'; import { cn } from '@/lib/utils'; interface LayoutProps { @@ -14,81 +6,18 @@ interface LayoutProps { noScroll?: boolean; } +/** + * Тонкий контент-контейнер страницы: только скролл-обёртка с восстановлением позиции. + * Auth-gate, биллинг-оверлей, Sidebar и AddBookmarkFAB живут в AppShell (App.tsx) — + * здесь их нет, чтобы хром не размонтировался при переходах между страницами. + */ const Layout = ({ children, noScroll }: LayoutProps) => { - const { user, isLoading } = useAuth(); - const [location, setLocation] = useLocation(); - - useEffect(() => { - if (!isLoading && !user) { - // Сохраняем полный URL (pathname + search), чтобы после входа - // восстановить и целевую страницу, и query-параметры (например, ?prefill_field_3=546). - const current = window.location.pathname + window.location.search; - const isAuthPage = current === '/' || current.startsWith('/login'); - const returnTo = !isAuthPage ? current : ''; - setLocation(returnTo ? `/login?returnTo=${encodeURIComponent(returnTo)}` : '/login'); - } - }, [isLoading, user, location, setLocation]); - - // Check billing block status for admins - const { data: billingSummary } = useQuery({ - queryKey: ['/api/billing/summary'], - queryFn: () => apiRequest('GET', '/api/billing/summary').then(r => r.json()), - enabled: !!user && isAdmin(user), - // Don't throw on 402 — it just means blocked, we handle it via the overlay - retry: false, - staleTime: 60_000, - }); - - if (isLoading) { - return ( -
- -
- ); - } - - if (!user) { - return null; - } - - const isBillingBlocked = billingSummary?.summary?.billingBlocked === true; - // Show overlay everywhere except billing page itself - const showBlockedOverlay = isBillingBlocked && !location.startsWith('/billing'); + const scrollRef = useScrollRestoration(noScroll); return ( - - {showBlockedOverlay && ( -
-
-
-
-
-
-

Доступ приостановлен

-

- Баланс вашей организации исчерпан. Функции системы временно недоступны. - Обратитесь к администратору системы для пополнения баланса. -

- - Перейти к биллингу - -
-
- )} -
- {children} -
- -
+
+ {children} +
); }; diff --git a/client/src/components/NotificationsDropdown.tsx b/client/src/components/NotificationsDropdown.tsx index 6d1fa9b..8f97e56 100644 --- a/client/src/components/NotificationsDropdown.tsx +++ b/client/src/components/NotificationsDropdown.tsx @@ -154,9 +154,9 @@ export const NotificationsDropdown = ({ collapsed = false }: NotificationsDropdo markAsReadMutation.mutate(notification.id); } - // Переходим к задаче с правильным маршрутом + // Переходим к задаче с правильным маршрутом (SPA-навигация, без перезагрузки страницы) if (notification.task?.id && notification.task?.formId) { - window.location.href = `/forms/${notification.task.formId}/tasks/${notification.task.id}`; + setLocation(`/forms/${notification.task.formId}/tasks/${notification.task.id}`); } setIsOpen(false); diff --git a/client/src/hooks/useScrollRestoration.ts b/client/src/hooks/useScrollRestoration.ts new file mode 100644 index 0000000..8773949 --- /dev/null +++ b/client/src/hooks/useScrollRestoration.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react'; +import { useLocation } from 'wouter'; + +// Позиции скролла по пути — живут на уровне модуля, переживают размонтирование страниц +const scrollPositions = new Map(); + +/** + * Запоминает scrollTop скролл-контейнера при уходе со страницы и восстанавливает + * его при возврате на тот же путь (после рендера, через requestAnimationFrame). + * + * Скроллящим элементом может быть как сам контейнер (десктоп: overflow-auto), + * так и родительский
(мобильный Sidebar) — читаем/пишем оба, берём максимум. + * + * noScroll-страницы и виртуализированные списки пропускаются через disabled — + * они управляют скроллом сами. + */ +export function useScrollRestoration(disabled?: boolean) { + const ref = useRef(null); + const [location] = useLocation(); + + useEffect(() => { + const el = ref.current; + if (!el || disabled) return; + // Ключ включает search, чтобы ?tab=... и подобные варианты страницы не делили позицию + const key = location + window.location.search; + const main = el.closest('main'); + + const raf = requestAnimationFrame(() => { + const saved = scrollPositions.get(key); + if (saved == null) return; + el.scrollTop = saved; + if (main instanceof HTMLElement) main.scrollTop = saved; + }); + + return () => { + cancelAnimationFrame(raf); + const top = Math.max(el.scrollTop, main instanceof HTMLElement ? main.scrollTop : 0); + scrollPositions.set(key, top); + }; + }, [location, disabled]); + + return ref; +} diff --git a/client/src/pages/TableEditor.tsx b/client/src/pages/TableEditor.tsx index 3446fb3..9f4ecbd 100644 --- a/client/src/pages/TableEditor.tsx +++ b/client/src/pages/TableEditor.tsx @@ -1621,7 +1621,7 @@ export default function TableEditor() { {(accessRulesData?.linkedForms || []).map(f => (

{f.name}

-