feat: implement app content readiness handling in page and components

- Added `appContentReady` state to manage visibility of app content once loading is complete.
- Updated `useEffect` hooks in `CurrentDutyView` and `CalendarPage` to signal when content is ready, enhancing user experience by hiding native loading indicators.
- Refactored `Home` component to conditionally render content based on `appContentReady`, ensuring a smoother transition for users.
- Enhanced app store to include `setAppContentReady` method for state management.
This commit is contained in:
2026-03-03 18:11:02 +03:00
parent cac06f22fa
commit 68a153e4a7
4 changed files with 48 additions and 22 deletions

View File

@@ -5,12 +5,13 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback, useEffect } from "react";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useTelegramTheme } from "@/hooks/use-telegram-theme"; import { useTelegramTheme } from "@/hooks/use-telegram-theme";
import { useTelegramAuth } from "@/hooks/use-telegram-auth"; import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { useAppInit } from "@/hooks/use-app-init"; import { useAppInit } from "@/hooks/use-app-init";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView"; import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
import { CalendarPage } from "@/components/CalendarPage"; import { CalendarPage } from "@/components/CalendarPage";
@@ -22,29 +23,48 @@ export default function Home() {
useAppInit({ isAllowed, startParam }); useAppInit({ isAllowed, startParam });
const { currentView, setCurrentView, setSelectedDay } = useAppStore( const { currentView, setCurrentView, setSelectedDay, appContentReady } =
useAppStore(
useShallow((s) => ({ useShallow((s) => ({
currentView: s.currentView, currentView: s.currentView,
setCurrentView: s.setCurrentView, setCurrentView: s.setCurrentView,
setSelectedDay: s.setSelectedDay, setSelectedDay: s.setSelectedDay,
appContentReady: s.appContentReady,
})) }))
); );
// When content is ready, tell Telegram to hide native loading and show our app.
useEffect(() => {
if (appContentReady) {
callMiniAppReadyOnce();
}
}, [appContentReady]);
const handleBackFromCurrentDuty = useCallback(() => { const handleBackFromCurrentDuty = useCallback(() => {
setCurrentView("calendar"); setCurrentView("calendar");
setSelectedDay(null); setSelectedDay(null);
}, [setCurrentView, setSelectedDay]); }, [setCurrentView, setSelectedDay]);
if (currentView === "currentDuty") { const content =
return ( currentView === "currentDuty" ? (
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe"> <div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
<CurrentDutyView <CurrentDutyView
onBack={handleBackFromCurrentDuty} onBack={handleBackFromCurrentDuty}
openedFromPin={startParam === "duty"} openedFromPin={startParam === "duty"}
/> />
</div> </div>
) : (
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
);
return (
<div
style={{
visibility: appContentReady ? "visible" : "hidden",
minHeight: "100vh",
}}
>
{content}
</div>
); );
} }
return <CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />;
}

View File

@@ -16,7 +16,6 @@ import { CalendarHeader } from "@/components/calendar/CalendarHeader";
import { CalendarGrid } from "@/components/calendar/CalendarGrid"; import { CalendarGrid } from "@/components/calendar/CalendarGrid";
import { DutyList } from "@/components/duty/DutyList"; import { DutyList } from "@/components/duty/DutyList";
import { DayDetail, type DayDetailHandle } from "@/components/day-detail"; import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { ErrorState } from "@/components/states/ErrorState"; import { ErrorState } from "@/components/states/ErrorState";
import { AccessDenied } from "@/components/states/AccessDenied"; import { AccessDenied } from "@/components/states/AccessDenied";
@@ -60,6 +59,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
prevMonth, prevMonth,
setCurrentMonth, setCurrentMonth,
setSelectedDay, setSelectedDay,
setAppContentReady,
} = useAppStore( } = useAppStore(
useShallow((s) => ({ useShallow((s) => ({
currentMonth: s.currentMonth, currentMonth: s.currentMonth,
@@ -75,6 +75,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
prevMonth: s.prevMonth, prevMonth: s.prevMonth,
setCurrentMonth: s.setCurrentMonth, setCurrentMonth: s.setCurrentMonth,
setSelectedDay: s.setSelectedDay, setSelectedDay: s.setSelectedDay,
setAppContentReady: s.setAppContentReady,
})) }))
); );
@@ -126,13 +127,13 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
}, [setSelectedDay]); }, [setSelectedDay]);
const readyCalledRef = useRef(false); const readyCalledRef = useRef(false);
// Signal Telegram to hide loading when the first load finishes (loading goes true -> false). // Mark content ready when first load finishes or access denied, so page can call ready() and show content.
useEffect(() => { useEffect(() => {
if (!loading && !readyCalledRef.current) { if ((!loading || accessDenied) && !readyCalledRef.current) {
readyCalledRef.current = true; readyCalledRef.current = true;
callMiniAppReadyOnce(); setAppContentReady(true);
} }
}, [loading]); }, [loading, accessDenied, setAppContentReady]);
return ( return (
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe"> <div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">

View File

@@ -20,7 +20,6 @@ import {
formatHHMM, formatHHMM,
} from "@/lib/date-utils"; } from "@/lib/date-utils";
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty"; import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { ContactLinks } from "@/components/contact/ContactLinks"; import { ContactLinks } from "@/components/contact/ContactLinks";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -47,6 +46,7 @@ type ViewState = "loading" | "error" | "ready";
export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) { export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const lang = useAppStore((s) => s.lang); const lang = useAppStore((s) => s.lang);
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
const { initDataRaw } = useTelegramAuth(); const { initDataRaw } = useTelegramAuth();
const [state, setState] = useState<ViewState>("loading"); const [state, setState] = useState<ViewState>("loading");
@@ -93,12 +93,12 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
return () => controller.abort(); return () => controller.abort();
}, [loadTodayDuties]); }, [loadTodayDuties]);
// Signal Telegram to hide loading when this view is ready (or error). // Mark content ready when data is loaded or error, so page can call ready() and show content.
useEffect(() => { useEffect(() => {
if (state !== "loading") { if (state !== "loading") {
callMiniAppReadyOnce(); setAppContentReady(true);
} }
}, [state]); }, [state, setAppContentReady]);
// Auto-update remaining time every second when there is an active duty. // Auto-update remaining time every second when there is an active duty.
useEffect(() => { useEffect(() => {

View File

@@ -28,6 +28,8 @@ export interface AppState {
accessDeniedDetail: string | null; accessDeniedDetail: string | null;
currentView: CurrentView; currentView: CurrentView;
selectedDay: string | null; selectedDay: string | null;
/** True when the first visible screen has finished loading; used to hide content until ready(). */
appContentReady: boolean;
setCurrentMonth: (d: Date) => void; setCurrentMonth: (d: Date) => void;
nextMonth: () => void; nextMonth: () => void;
@@ -41,8 +43,9 @@ export interface AppState {
setLang: (v: "ru" | "en") => void; setLang: (v: "ru" | "en") => void;
setCurrentView: (v: CurrentView) => void; setCurrentView: (v: CurrentView) => void;
setSelectedDay: (key: string | null) => void; setSelectedDay: (key: string | null) => void;
setAppContentReady: (v: boolean) => void;
/** Batch multiple state updates into a single re-render. */ /** Batch multiple state updates into a single re-render. */
batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "pendingMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay">>) => void; batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "pendingMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay" | "appContentReady">>) => void;
} }
const now = new Date(); const now = new Date();
@@ -67,6 +70,7 @@ export const useAppStore = create<AppState>((set) => ({
accessDeniedDetail: null, accessDeniedDetail: null,
currentView: getInitialView(), currentView: getInitialView(),
selectedDay: null, selectedDay: null,
appContentReady: false,
setCurrentMonth: (d) => set({ currentMonth: d }), setCurrentMonth: (d) => set({ currentMonth: d }),
nextMonth: () => nextMonth: () =>
@@ -86,5 +90,6 @@ export const useAppStore = create<AppState>((set) => ({
setLang: (v) => set({ lang: v }), setLang: (v) => set({ lang: v }),
setCurrentView: (v) => set({ currentView: v }), setCurrentView: (v) => set({ currentView: v }),
setSelectedDay: (key) => set({ selectedDay: key }), setSelectedDay: (key) => set({ selectedDay: key }),
setAppContentReady: (v) => set({ appContentReady: v }),
batchUpdate: (partial) => set(partial), batchUpdate: (partial) => set(partial),
})); }));