Реализована проверка версионности и требование soft\force обновлений приложения

This commit is contained in:
mi
2026-09-03 19:12:38 +03:00
parent 465a70d488
commit 44db38f6fe
37 changed files with 3057 additions and 53 deletions
Binary file not shown.
+3
View File
@@ -10,9 +10,11 @@ import { AuthOtp } from './pages/AuthOtp';
import { AuthLoading } from './pages/AuthLoading';
import { AuthConsent } from './pages/AuthConsent';
import { Root } from './pages/Root';
import { UpdateProvider } from './contexts/UpdateContext';
export default function App() {
return (
<UpdateProvider>
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<Root />}>
@@ -30,5 +32,6 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</MemoryRouter>
</UpdateProvider>
);
}
@@ -0,0 +1,170 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { ArrowUpCircle, AlertTriangle, X } from "lucide-react";
import { useUpdate } from "../contexts/UpdateContext";
export function UpdateModal() {
const { config, dismissUpdate } = useUpdate();
if (!config) return null;
const isStrict = config.type === "strict";
const handleUpdate = () => {
if (config.updateUrl) {
window.open(config.updateUrl, "_blank", "noopener,noreferrer");
}
// В strict-режиме не закрываем — пользователь обязан обновиться
if (!isStrict) {
dismissUpdate();
}
};
const handleOpenChange = (open: boolean) => {
if (!open && !isStrict) {
dismissUpdate();
}
// В strict-режиме игнорируем попытки закрыть
};
return (
<DialogPrimitive.Root open={true} onOpenChange={handleOpenChange}>
<DialogPrimitive.Portal>
{/* Оверлей: в strict-режиме pointer-events-none отключаем клик по фону */}
<DialogPrimitive.Overlay
className={[
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
isStrict ? "pointer-events-none" : "",
].join(" ")}
/>
<DialogPrimitive.Content
// В strict-режиме блокируем закрытие по Escape и клику вне
onEscapeKeyDown={(e) => isStrict && e.preventDefault()}
onPointerDownOutside={(e) => isStrict && e.preventDefault()}
onInteractOutside={(e) => isStrict && e.preventDefault()}
className={[
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
"w-full max-w-[360px] rounded-2xl border border-border bg-background shadow-2xl",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"focus:outline-none",
].join(" ")}
>
{/* Иконка и шапка */}
<div
className={[
"flex flex-col items-center gap-3 rounded-t-2xl px-6 pt-7 pb-5",
isStrict
? "bg-destructive/10"
: "bg-primary/5",
].join(" ")}
>
<div
className={[
"flex items-center justify-center w-14 h-14 rounded-full",
isStrict
? "bg-destructive/15 text-destructive"
: "bg-primary/10 text-primary",
].join(" ")}
>
{isStrict ? (
<AlertTriangle className="w-7 h-7" strokeWidth={2} />
) : (
<ArrowUpCircle className="w-7 h-7" strokeWidth={2} />
)}
</div>
<div className="text-center">
<DialogPrimitive.Title className="text-lg font-semibold text-foreground leading-tight">
{isStrict ? "Обновление обязательно" : "Доступно обновление"}
</DialogPrimitive.Title>
<p className="mt-1 text-sm text-muted-foreground">
Версия{" "}
<span className="font-medium text-foreground">
{config.newVersion}
</span>
</p>
</div>
</div>
{/* Тело */}
<div className="px-6 py-5 space-y-4">
<DialogPrimitive.Description className="text-sm text-muted-foreground leading-relaxed text-center">
{isStrict ? (
<>
Версия <strong>{config.currentVersion}</strong> больше не
поддерживается. Для продолжения работы необходимо установить
обновление.
</>
) : (
<>
Вышла новая версия приложения. Вы можете обновить его сейчас
или сделать это позже.
</>
)}
</DialogPrimitive.Description>
{config.releaseNotes && (
<div className="rounded-xl bg-muted/60 px-4 py-3 text-xs text-muted-foreground leading-relaxed">
{config.releaseNotes}
</div>
)}
{/* Плашка текущей / новой версии */}
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1">
{config.currentVersion}
</span>
<span></span>
<span
className={[
"rounded-md px-2 py-1 font-medium",
isStrict
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
].join(" ")}
>
{config.newVersion}
</span>
</div>
</div>
{/* Кнопки */}
<div className="px-6 pb-6 flex flex-col gap-2">
<button
onClick={handleUpdate}
className={[
"w-full rounded-xl py-3 text-sm font-semibold transition-opacity active:opacity-80",
isStrict
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground",
].join(" ")}
>
Обновить приложение
</button>
{!isStrict && (
<button
onClick={dismissUpdate}
className="w-full rounded-xl py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
Позже
</button>
)}
</div>
{/* Крестик только в soft-режиме */}
{!isStrict && (
<DialogPrimitive.Close
onClick={dismissUpdate}
className="absolute right-4 top-4 rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none"
aria-label="Закрыть"
>
<X className="w-4 h-4" />
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
@@ -0,0 +1,43 @@
import React, { createContext, useContext, useState, useCallback } from "react";
export type UpdateType = "soft" | "strict";
export interface UpdateConfig {
type: UpdateType;
currentVersion: string;
newVersion: string;
updateUrl?: string;
releaseNotes?: string;
}
interface UpdateContextValue {
config: UpdateConfig | null;
showUpdate: (cfg: UpdateConfig) => void;
dismissUpdate: () => void;
}
const UpdateContext = createContext<UpdateContextValue | null>(null);
export function UpdateProvider({ children }: { children: React.ReactNode }) {
const [config, setConfig] = useState<UpdateConfig | null>(null);
const showUpdate = useCallback((cfg: UpdateConfig) => {
setConfig(cfg);
}, []);
const dismissUpdate = useCallback(() => {
setConfig(null);
}, []);
return (
<UpdateContext.Provider value={{ config, showUpdate, dismissUpdate }}>
{children}
</UpdateContext.Provider>
);
}
export function useUpdate() {
const ctx = useContext(UpdateContext);
if (!ctx) throw new Error("useUpdate must be used inside UpdateProvider");
return ctx;
}
+31
View File
@@ -3,13 +3,44 @@ import { Notifications } from '../components/Notifications';
import { PopularQuestions } from '../components/PopularQuestions';
import { ChatInput } from '../components/ChatInput';
import { QuickActions } from '../components/QuickActions';
import { useUpdate } from '../contexts/UpdateContext';
export function Home() {
const { showUpdate } = useUpdate();
return (
<>
{/* Компактный логотип HAN */}
<HanLogo />
{/* Demo: триггеры для тестирования диалогов обновления */}
<div className="px-4 py-2 flex gap-2">
<button
onClick={() =>
showUpdate({
type: "soft",
currentVersion: "2.4.1",
newVersion: "2.5.0",
})
}
className="flex-1 rounded-xl border border-border bg-muted/40 py-2 text-xs font-medium text-muted-foreground hover:bg-muted transition-colors"
>
Soft update
</button>
<button
onClick={() =>
showUpdate({
type: "strict",
currentVersion: "1.8.0",
newVersion: "2.5.0",
})
}
className="flex-1 rounded-xl border border-destructive/30 bg-destructive/5 py-2 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors"
>
Strict update
</button>
</div>
{/* Уведомления от компании */}
<div className="flex-1 overflow-y-auto">
<Notifications />
+3
View File
@@ -1,5 +1,6 @@
import { Outlet, useLocation } from 'react-router';
import { Header } from '../components/Header';
import { UpdateModal } from '../components/UpdateModal';
export function Root() {
const location = useLocation();
@@ -14,6 +15,8 @@ export function Root() {
<Outlet />
</div>
<UpdateModal />
</div>
);
}