diff --git a/src/api/health.ts b/src/api/health.ts index 80a7c09..a4cac20 100644 --- a/src/api/health.ts +++ b/src/api/health.ts @@ -85,8 +85,18 @@ let confirmInFlight = false; * already shown. */ export async function reportPossibleBackendDown(): Promise { - if (confirmInFlight) return; if (useBlockingStore.getState().blockingType === 'backend_unavailable') return; + // During the INITIAL bootstrap (we've never reached the backend yet) the + // caller's failed request is itself the confirmation, so flip the screen + // IMMEDIATELY and synchronously. A deferred probe here would let the /login + // page paint for the ~probe duration before the outage screen — the "jump" + // we must avoid. Once the app has loaded, fall through to the confirm-probe so + // a one-off network blip can't blank a working session. + if (!everReachedBackend) { + useBlockingStore.getState().setBackendUnavailable(); + return; + } + if (confirmInFlight) return; confirmInFlight = true; try { const reachable = await pingBackend(); @@ -97,3 +107,20 @@ export async function reportPossibleBackendDown(): Promise { confirmInFlight = false; } } + +/** + * Eager liveness check fired once at app launch (from main.tsx), in parallel + * with auth bootstrap. If the backend is already down at launch this paints the + * ServiceUnavailableScreen immediately — before the auth flow can flash the + * /login page — even on the no-stored-token path that makes no early request. + * No-op if the app has already reached the backend or another blocking screen + * is showing. + */ +export async function checkBackendOnStartup(): Promise { + if (everReachedBackend) return; + if (useBlockingStore.getState().blockingType !== null) return; + const reachable = await pingBackend(); + if (!reachable && !everReachedBackend && useBlockingStore.getState().blockingType === null) { + useBlockingStore.getState().setBackendUnavailable(); + } +} diff --git a/src/components/blocking/ServiceUnavailableScreen.tsx b/src/components/blocking/ServiceUnavailableScreen.tsx index e5fa9a3..c60eadf 100644 --- a/src/components/blocking/ServiceUnavailableScreen.tsx +++ b/src/components/blocking/ServiceUnavailableScreen.tsx @@ -4,7 +4,8 @@ import { useQueryClient } from '@tanstack/react-query'; import { useBlockingStore } from '../../store/blocking'; import { useFocusTrap } from '../../hooks/useFocusTrap'; import { pingBackend, hasEverReachedBackend } from '../../api/health'; -import { CloudWarningIcon, RestartIcon } from '@/components/icons'; +import { isInTelegramWebApp, closeTelegramApp } from '../../hooks/useTelegramSDK'; +import { CloudWarningIcon, RestartIcon, CloseIcon } from '@/components/icons'; const POLL_INTERVAL_MS = 5000; @@ -21,6 +22,7 @@ export default function ServiceUnavailableScreen() { const queryClient = useQueryClient(); const [isChecking, setIsChecking] = useState(false); const isCheckingRef = useRef(false); + const inTelegram = isInTelegramWebApp(); const recover = useCallback(() => { clearBlocking(); @@ -133,6 +135,18 @@ export default function ServiceUnavailableScreen() { )} + {/* Close button — Telegram Mini App only (a browser tab can't be closed + by script). Reliably exits the Mini App instead of routing back. */} + {inTelegram && ( + + )} + {/* Decorative dots */}
initLogoPreload()); } else { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 50297fe..a05d633 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -19,6 +19,8 @@ interface ImportMeta { interface TelegramWebAppGlobal { onEvent?: (event: string, callback: () => void) => void; offEvent?: (event: string, callback: () => void) => void; + /** Closes the Mini App (injected by telegram-web-app.js). */ + close?: () => void; } /** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */