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 { queryClient } from "./lib/queryClient";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
@@ -16,6 +16,7 @@ import { usePwaFontSize } from "@/hooks/usePwaFontSize";
|
|||||||
import { useGpsTracking } from "@/hooks/useGpsTracking";
|
import { useGpsTracking } from "@/hooks/useGpsTracking";
|
||||||
import { invalidateHomeQueriesThrottled } from "@/lib/throttledInvalidate";
|
import { invalidateHomeQueriesThrottled } from "@/lib/throttledInvalidate";
|
||||||
import { GlobalErrorBoundary } from "@/components/GlobalErrorBoundary";
|
import { GlobalErrorBoundary } from "@/components/GlobalErrorBoundary";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
type LazyModule<T = any> = { default: ComponentType<T> };
|
type LazyModule<T = any> = { default: ComponentType<T> };
|
||||||
@@ -159,7 +160,10 @@ function useRouteTitleAndFocus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Router() { return (
|
function Router() { return (
|
||||||
<Suspense fallback={<PageLoader />}>
|
// AppShell — снаружи Suspense: при подгрузке lazy-чанка страницы хром
|
||||||
|
// (Sidebar/навигация) остаётся смонтированным, fallback показывается внутри.
|
||||||
|
<AppShell>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={Login} />
|
<Route path="/" component={Login} />
|
||||||
<Route path="/login" component={Login} />
|
<Route path="/login" component={Login} />
|
||||||
@@ -188,7 +192,7 @@ function Router() { return (
|
|||||||
<Route path="/bots" component={Bots} />
|
<Route path="/bots" component={Bots} />
|
||||||
<Route path="/field-templates" component={FieldTemplates} />
|
<Route path="/field-templates" component={FieldTemplates} />
|
||||||
<Route path="/global-fields">
|
<Route path="/global-fields">
|
||||||
{() => { window.location.href = '/field-templates'; return null; }}
|
{() => <Redirect to="/field-templates" />}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/pages/:slug" component={CustomPageView} />
|
<Route path="/pages/:slug" component={CustomPageView} />
|
||||||
<Route path="/home" component={HomePage} />
|
<Route path="/home" component={HomePage} />
|
||||||
@@ -221,7 +225,8 @@ function Router() { return (
|
|||||||
<Route path="/superadmin" component={SuperAdminDashboard} />
|
<Route path="/superadmin" component={SuperAdminDashboard} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +261,7 @@ function GlobalEventListener() {
|
|||||||
*/
|
*/
|
||||||
function ForegroundPushHandler() {
|
function ForegroundPushHandler() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!('serviceWorker' in navigator)) return;
|
if (!('serviceWorker' in navigator)) return;
|
||||||
@@ -286,7 +292,17 @@ function ForegroundPushHandler() {
|
|||||||
// Тост с action («Ответить») не закрываем по таймеру — пользователь может не успеть нажать
|
// Тост с action («Ответить») не закрываем по таймеру — пользователь может не успеть нажать
|
||||||
duration: isChat ? Infinity : 6000,
|
duration: isChat ? Infinity : 6000,
|
||||||
action: isChat ? (
|
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>
|
</ToastAction>
|
||||||
) : undefined,
|
) : undefined,
|
||||||
@@ -295,7 +311,7 @@ function ForegroundPushHandler() {
|
|||||||
|
|
||||||
navigator.serviceWorker.addEventListener('message', handler);
|
navigator.serviceWorker.addEventListener('message', handler);
|
||||||
return () => navigator.serviceWorker.removeEventListener('message', handler);
|
return () => navigator.serviceWorker.removeEventListener('message', handler);
|
||||||
}, [toast]);
|
}, [toast, setLocation]);
|
||||||
|
|
||||||
return null;
|
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 { useScrollRestoration } from '@/hooks/useScrollRestoration';
|
||||||
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 { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
@@ -14,81 +6,18 @@ interface LayoutProps {
|
|||||||
noScroll?: boolean;
|
noScroll?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тонкий контент-контейнер страницы: только скролл-обёртка с восстановлением позиции.
|
||||||
|
* Auth-gate, биллинг-оверлей, Sidebar и AddBookmarkFAB живут в AppShell (App.tsx) —
|
||||||
|
* здесь их нет, чтобы хром не размонтировался при переходах между страницами.
|
||||||
|
*/
|
||||||
const Layout = ({ children, noScroll }: LayoutProps) => {
|
const Layout = ({ children, noScroll }: LayoutProps) => {
|
||||||
const { user, isLoading } = useAuth();
|
const scrollRef = useScrollRestoration<HTMLDivElement>(noScroll);
|
||||||
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');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<div ref={scrollRef} className={cn("flex-1 flex flex-col p-0 md:p-4", noScroll ? "overflow-hidden" : "overflow-auto")}>
|
||||||
{showBlockedOverlay && (
|
{children}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -154,9 +154,9 @@ export const NotificationsDropdown = ({ collapsed = false }: NotificationsDropdo
|
|||||||
markAsReadMutation.mutate(notification.id);
|
markAsReadMutation.mutate(notification.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Переходим к задаче с правильным маршрутом
|
// Переходим к задаче с правильным маршрутом (SPA-навигация, без перезагрузки страницы)
|
||||||
if (notification.task?.id && notification.task?.formId) {
|
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);
|
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 => (
|
{(accessRulesData?.linkedForms || []).map(f => (
|
||||||
<div key={f.id} className="flex items-center justify-between p-2 border rounded">
|
<div key={f.id} className="flex items-center justify-between p-2 border rounded">
|
||||||
<p className="font-medium">{f.name}</p>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user