refactor(ui): фаза 3 — единый AppShell (Sidebar не размонтируется при навигации), Layout стал тонким контейнером, SPA-навигация вместо location.href (уведомления, тосты, TableEditor), scroll restoration, плавное появление биллинг-оверлея

This commit is contained in:
2026-09-08 20:48:28 +03:00
parent 5d219ac794
commit c4fdfe4eab
6 changed files with 223 additions and 90 deletions

View File

@@ -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<T = any> = { default: ComponentType<T> };
@@ -159,7 +160,10 @@ function useRouteTitleAndFocus() {
}
function Router() { return (
<Suspense fallback={<PageLoader />}>
// AppShell — снаружи Suspense: при подгрузке lazy-чанка страницы хром
// (Sidebar/навигация) остаётся смонтированным, fallback показывается внутри.
<AppShell>
<Suspense fallback={<PageLoader />}>
<Switch>
<Route path="/" component={Login} />
<Route path="/login" component={Login} />
@@ -188,7 +192,7 @@ function Router() { return (
<Route path="/bots" component={Bots} />
<Route path="/field-templates" component={FieldTemplates} />
<Route path="/global-fields">
{() => { window.location.href = '/field-templates'; return null; }}
{() => <Redirect to="/field-templates" />}
</Route>
<Route path="/pages/:slug" component={CustomPageView} />
<Route path="/home" component={HomePage} />
@@ -221,7 +225,8 @@ function Router() { return (
<Route path="/superadmin" component={SuperAdminDashboard} />
<Route component={NotFound} />
</Switch>
</Suspense>
</Suspense>
</AppShell>
);
}
@@ -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 ? (
<ToastAction altText="Ответить" onClick={() => { window.location.href = url; }}>
<ToastAction
altText="Ответить"
onClick={() => {
// Внутренние пути — SPA-навигация без перезагрузки; внешние URL (подстраховка) — как раньше
if (url.startsWith('/')) {
setLocation(url);
} else {
window.location.href = url;
}
}}
>
Ответить
</ToastAction>
) : undefined,
@@ -295,7 +311,7 @@ function ForegroundPushHandler() {
navigator.serviceWorker.addEventListener('message', handler);
return () => navigator.serviceWorker.removeEventListener('message', handler);
}, [toast]);
}, [toast, setLocation]);
return null;
}

View File

@@ -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 (
<div
className={cn(
"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-base",
visible ? "opacity-100" : "opacity-0"
)}
>
<div
role="dialog"
aria-modal="true"
aria-label="Доступ приостановлен"
className="bg-card text-card-foreground rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4 text-center space-y-4"
>
<div className="flex justify-center">
<div className="bg-warning p-4 rounded-full">
<AlertTriangle className="h-10 w-10 text-warning-foreground" aria-hidden="true" />
</div>
</div>
<h2 className="text-xl font-bold text-foreground">Доступ приостановлен</h2>
<p className="text-muted-foreground text-sm">
Баланс вашей организации исчерпан. Функции системы временно недоступны.
Обратитесь к администратору системы для пополнения баланса.
</p>
<a
href="/billing"
className="inline-block mt-2 px-5 py-2 rounded-lg bg-warning text-warning-foreground hover:bg-warning/90 font-medium text-sm transition-colors"
>
Перейти к биллингу
</a>
</div>
</div>
);
}
/**
* Единый 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 (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
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 (
<Sidebar>
{showBlockedOverlay && <BillingBlockedOverlay />}
{children}
<AddBookmarkFAB />
</Sidebar>
);
}
export default AppShell;

View File

@@ -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 (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
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<HTMLDivElement>(noScroll);
return (
<Sidebar>
{showBlockedOverlay && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-label="Доступ приостановлен"
className="bg-card text-card-foreground rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4 text-center space-y-4"
>
<div className="flex justify-center">
<div className="bg-warning p-4 rounded-full">
<AlertTriangle className="h-10 w-10 text-warning-foreground" aria-hidden="true" />
</div>
</div>
<h2 className="text-xl font-bold text-foreground">Доступ приостановлен</h2>
<p className="text-muted-foreground text-sm">
Баланс вашей организации исчерпан. Функции системы временно недоступны.
Обратитесь к администратору системы для пополнения баланса.
</p>
<a
href="/billing"
className="inline-block mt-2 px-5 py-2 rounded-lg bg-warning text-warning-foreground hover:bg-warning/90 font-medium text-sm transition-colors"
>
Перейти к биллингу
</a>
</div>
</div>
)}
<div className={cn("flex-1 flex flex-col p-0 md:p-4", noScroll ? "overflow-hidden" : "overflow-auto")}>
{children}
</div>
<AddBookmarkFAB />
</Sidebar>
<div ref={scrollRef} className={cn("flex-1 flex flex-col p-0 md:p-4", noScroll ? "overflow-hidden" : "overflow-auto")}>
{children}
</div>
);
};

View File

@@ -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);

View File

@@ -0,0 +1,43 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'wouter';
// Позиции скролла по пути — живут на уровне модуля, переживают размонтирование страниц
const scrollPositions = new Map<string, number>();
/**
* Запоминает scrollTop скролл-контейнера при уходе со страницы и восстанавливает
* его при возврате на тот же путь (после рендера, через requestAnimationFrame).
*
* Скроллящим элементом может быть как сам контейнер (десктоп: overflow-auto),
* так и родительский <main> (мобильный Sidebar) — читаем/пишем оба, берём максимум.
*
* noScroll-страницы и виртуализированные списки пропускаются через disabled —
* они управляют скроллом сами.
*/
export function useScrollRestoration<T extends HTMLElement>(disabled?: boolean) {
const ref = useRef<T>(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;
}

View File

@@ -1621,7 +1621,7 @@ export default function TableEditor() {
{(accessRulesData?.linkedForms || []).map(f => (
<div key={f.id} className="flex items-center justify-between p-2 border rounded">
<p className="font-medium">{f.name}</p>
<Button variant="outline" size="sm" onClick={() => window.location.href = `/forms/${f.id}`}>
<Button variant="outline" size="sm" onClick={() => setLocation(`/forms/${f.id}`)}>
Перейти
</Button>
</div>