refactor(ui): фаза 3 — единый AppShell (Sidebar не размонтируется при навигации), Layout стал тонким контейнером, SPA-навигация вместо location.href (уведомления, тосты, TableEditor), scroll restoration, плавное появление биллинг-оверлея
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
145
client/src/components/AppShell.tsx
Normal file
145
client/src/components/AppShell.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
43
client/src/hooks/useScrollRestoration.ts
Normal file
43
client/src/hooks/useScrollRestoration.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user