From 07e22079ee3f725e202a9ccf2d56bb516aeb96f6 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Wed, 4 Mar 2026 19:19:14 +0300 Subject: [PATCH] feat: enhance CSS and components for Telegram Mini App performance - Updated CSS to utilize viewport variables for safe area insets and stable height, improving layout consistency across devices. - Introduced haptic feedback triggers in various components to enhance user interaction, mimicking native Telegram behavior. - Added functionality to detect Android performance class, minimizing animations on low-performance devices for better user experience. - Refactored components to incorporate new CSS classes for content safety and improved responsiveness. --- webapp-next/src/app/globals.css | 22 +++++++++++-- webapp-next/src/app/page.tsx | 4 +-- webapp-next/src/components/CalendarPage.tsx | 5 ++- .../src/components/calendar/CalendarDay.tsx | 2 ++ .../current-duty/CurrentDutyView.tsx | 4 +++ .../src/components/day-detail/DayDetail.tsx | 2 ++ .../src/components/duty/DutyTimelineCard.tsx | 3 ++ .../components/providers/TelegramProvider.tsx | 32 ++++++++++++++++--- .../components/states/AccessDeniedScreen.tsx | 2 ++ .../src/components/states/ErrorState.tsx | 6 +++- .../src/hooks/use-telegram-theme.test.ts | 1 + webapp-next/src/hooks/use-telegram-theme.ts | 4 +++ webapp-next/src/lib/telegram-android-perf.ts | 26 +++++++++++++++ webapp-next/src/lib/telegram-haptic.ts | 19 +++++++++++ webapp-next/src/lib/telegram-ready.ts | 11 +++++-- 15 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 webapp-next/src/lib/telegram-android-perf.ts create mode 100644 webapp-next/src/lib/telegram-haptic.ts diff --git a/webapp-next/src/app/globals.css b/webapp-next/src/app/globals.css index 5b67b81..91254b6 100644 --- a/webapp-next/src/app/globals.css +++ b/webapp-next/src/app/globals.css @@ -174,7 +174,7 @@ html::-webkit-scrollbar { margin-right: auto; padding: 12px; padding-top: 0; - padding-bottom: env(safe-area-inset-bottom, 12px); + padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 12px)); border-radius: 12px; } @@ -261,9 +261,25 @@ html::-webkit-scrollbar { } } +/* Android low-performance devices: minimize animations (Telegram User-Agent). */ +[data-perf="low"] *, +[data-perf="low"] *::before, +[data-perf="low"] *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; +} + /* Safe area for Telegram Mini App (notch / status bar). */ .pt-safe { - padding-top: env(safe-area-inset-top, 0); + padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0)); +} + +/* Content safe area: top/bottom only to avoid overlap with Telegram header/bottom bar (Bot API 8.0+). + Horizontal padding is left to layout classes (e.g. px-3) so indents are preserved when viewport vars are 0. */ +.content-safe { + padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0)); + padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0)); } /* Sticky calendar header: shadow when scrolled (useStickyScroll). */ @@ -305,7 +321,7 @@ html::-webkit-scrollbar { body { margin: 0; padding: 0; - min-height: 100vh; + min-height: var(--tg-viewport-stable-height, 100vh); background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, sans-serif; diff --git a/webapp-next/src/app/page.tsx b/webapp-next/src/app/page.tsx index 7691a28..bc236be 100644 --- a/webapp-next/src/app/page.tsx +++ b/webapp-next/src/app/page.tsx @@ -50,7 +50,7 @@ export default function Home() { const content = accessDenied ? ( ) : currentView === "currentDuty" ? ( -
+
{content} diff --git a/webapp-next/src/components/CalendarPage.tsx b/webapp-next/src/components/CalendarPage.tsx index 9ef5d11..3cee0d6 100644 --- a/webapp-next/src/components/CalendarPage.tsx +++ b/webapp-next/src/components/CalendarPage.tsx @@ -17,6 +17,7 @@ import { CalendarGrid } from "@/components/calendar/CalendarGrid"; import { DutyList } from "@/components/duty/DutyList"; import { DayDetail, type DayDetailHandle } from "@/components/day-detail"; import { ErrorState } from "@/components/states/ErrorState"; +import { triggerHapticLight } from "@/lib/telegram-haptic"; /** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */ const STICKY_HEIGHT_FALLBACK_PX = 268; @@ -90,10 +91,12 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) { const navDisabled = loading || accessDenied || selectedDay !== null; const handlePrevMonth = useCallback(() => { if (navDisabled) return; + triggerHapticLight(); prevMonth(); }, [navDisabled, prevMonth]); const handleNextMonth = useCallback(() => { if (navDisabled) return; + triggerHapticLight(); nextMonth(); }, [navDisabled, nextMonth]); @@ -133,7 +136,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) { }, [loading, accessDenied, setAppContentReady]); return ( -
+
{ if (isOtherMonth) return; + triggerHapticLight(); onDayClick(dateKey, e.currentTarget.getBoundingClientRect()); }} > diff --git a/webapp-next/src/components/current-duty/CurrentDutyView.tsx b/webapp-next/src/components/current-duty/CurrentDutyView.tsx index 943c02a..b2a0922 100644 --- a/webapp-next/src/components/current-duty/CurrentDutyView.tsx +++ b/webapp-next/src/components/current-duty/CurrentDutyView.tsx @@ -20,6 +20,7 @@ import { formatHHMM, } from "@/lib/date-utils"; import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty"; +import { triggerHapticLight } from "@/lib/telegram-haptic"; import { ContactLinks } from "@/components/contact/ContactLinks"; import { Button } from "@/components/ui/button"; import { @@ -145,10 +146,12 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi }, [onBack]); const handleBack = () => { + triggerHapticLight(); onBack(); }; const handleClose = () => { + triggerHapticLight(); if (closeMiniApp.isAvailable()) { closeMiniApp(); } else { @@ -213,6 +216,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi if (state === "error") { const handleRetry = () => { + triggerHapticLight(); setState("loading"); loadTodayDuties(); }; diff --git a/webapp-next/src/components/day-detail/DayDetail.tsx b/webapp-next/src/components/day-detail/DayDetail.tsx index f9ab239..037c5f9 100644 --- a/webapp-next/src/components/day-detail/DayDetail.tsx +++ b/webapp-next/src/components/day-detail/DayDetail.tsx @@ -27,6 +27,7 @@ import { DayDetailContent } from "./DayDetailContent"; import type { CalendarEvent, DutyWithUser } from "@/types"; import { cn } from "@/lib/utils"; import { useAppStore } from "@/store/app-store"; +import { triggerHapticLight } from "@/lib/telegram-haptic"; /** Empty state for day detail: date and "no duties or events" message. */ function DayDetailEmpty({ dateKey }: { dateKey: string }) { @@ -120,6 +121,7 @@ export const DayDetail = React.forwardRef( /** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */ const requestClose = React.useCallback(() => { + triggerHapticLight(); setExiting(true); }, []); diff --git a/webapp-next/src/components/duty/DutyTimelineCard.tsx b/webapp-next/src/components/duty/DutyTimelineCard.tsx index e027172..daefe76 100644 --- a/webapp-next/src/components/duty/DutyTimelineCard.tsx +++ b/webapp-next/src/components/duty/DutyTimelineCard.tsx @@ -13,6 +13,7 @@ import { formatHHMM, } from "@/lib/date-utils"; import { cn } from "@/lib/utils"; +import { triggerHapticLight } from "@/lib/telegram-haptic"; import { ContactLinks } from "@/components/contact/ContactLinks"; import { Button } from "@/components/ui/button"; import type { DutyWithUser } from "@/types"; @@ -77,6 +78,7 @@ export function DutyTimelineCard({ const flipped = isControlled ? (isFlipped ?? false) : localFlipped; const handleFlipToBack = () => { + triggerHapticLight(); if (isControlled) { onFlipChange?.(true); } else { @@ -86,6 +88,7 @@ export function DutyTimelineCard({ }; const handleFlipToFront = () => { + triggerHapticLight(); if (isControlled) { onFlipChange?.(false); } else { diff --git a/webapp-next/src/components/providers/TelegramProvider.tsx b/webapp-next/src/components/providers/TelegramProvider.tsx index 2e7ed51..2d75a9b 100644 --- a/webapp-next/src/components/providers/TelegramProvider.tsx +++ b/webapp-next/src/components/providers/TelegramProvider.tsx @@ -6,15 +6,20 @@ import { mountMiniAppSync, mountThemeParamsSync, bindThemeParamsCssVars, + mountViewport, + bindViewportCssVars, + unmountViewport, } from "@telegram-apps/sdk-react"; import { fixSurfaceContrast } from "@/hooks/use-telegram-theme"; +import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf"; /** * Wraps the app with Telegram Mini App SDK initialization. * Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars), - * and mounts the mini app. Does not call ready() here — the app calls - * callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen - * has finished loading, so Telegram keeps its native loading animation until then. + * mounts the mini app, then mounts viewport and binds viewport CSS vars + * (--tg-viewport-stable-height, --tg-viewport-content-safe-area-inset-*, etc.). + * Does not call ready() here — the app calls callMiniAppReadyOnce() from + * lib/telegram-ready when the first visible screen has finished loading. * Theme is set before first paint by the inline script in layout.tsx (URL hash); * useTelegramTheme() in the app handles ongoing theme changes. */ @@ -39,7 +44,26 @@ export function TelegramProvider({ mountMiniAppSync(); } - return cleanup; + applyAndroidPerformanceClass(); + + let unbindViewportCssVars: (() => void) | undefined; + if (mountViewport.isAvailable()) { + mountViewport() + .then(() => { + if (bindViewportCssVars.isAvailable()) { + unbindViewportCssVars = bindViewportCssVars(); + } + }) + .catch(() => { + // Viewport not supported (e.g. not in Mini App); ignore. + }); + } + + return () => { + unbindViewportCssVars?.(); + unmountViewport(); + cleanup(); + }; }, []); return <>{children}; diff --git a/webapp-next/src/components/states/AccessDeniedScreen.tsx b/webapp-next/src/components/states/AccessDeniedScreen.tsx index 0793e92..9d17c9f 100644 --- a/webapp-next/src/components/states/AccessDeniedScreen.tsx +++ b/webapp-next/src/components/states/AccessDeniedScreen.tsx @@ -8,6 +8,7 @@ import { useEffect } from "react"; import { getLang, translate } from "@/i18n/messages"; import { useAppStore } from "@/store/app-store"; +import { triggerHapticLight } from "@/lib/telegram-haptic"; export interface AccessDeniedScreenProps { /** Optional detail from API 403 response, shown below the hint. */ @@ -40,6 +41,7 @@ export function AccessDeniedScreen({ const hasDetail = Boolean(serverDetail && String(serverDetail).trim()); const handleClick = () => { + triggerHapticLight(); if (primaryAction === "reload") { if (typeof window !== "undefined") { window.location.reload(); diff --git a/webapp-next/src/components/states/ErrorState.tsx b/webapp-next/src/components/states/ErrorState.tsx index b7ae72f..94cfaad 100644 --- a/webapp-next/src/components/states/ErrorState.tsx +++ b/webapp-next/src/components/states/ErrorState.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "@/i18n/use-translation"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { triggerHapticLight } from "@/lib/telegram-haptic"; export interface ErrorStateProps { /** Error message to display. If not provided, uses generic i18n message. */ @@ -65,7 +66,10 @@ export function ErrorState({ message, onRetry, className }: ErrorStateProps) { variant="default" size="sm" className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2" - onClick={onRetry} + onClick={() => { + triggerHapticLight(); + onRetry(); + }} > {t("error.retry")} diff --git a/webapp-next/src/hooks/use-telegram-theme.test.ts b/webapp-next/src/hooks/use-telegram-theme.test.ts index bc0350a..233461b 100644 --- a/webapp-next/src/hooks/use-telegram-theme.test.ts +++ b/webapp-next/src/hooks/use-telegram-theme.test.ts @@ -16,6 +16,7 @@ vi.mock("@telegram-apps/sdk-react", () => ({ isThemeParamsDark: vi.fn(), setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) }, setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) }, + setMiniAppBottomBarColor: { isAvailable: vi.fn(() => false) }, })); describe("getFallbackScheme", () => { diff --git a/webapp-next/src/hooks/use-telegram-theme.ts b/webapp-next/src/hooks/use-telegram-theme.ts index 91e3278..27e872c 100644 --- a/webapp-next/src/hooks/use-telegram-theme.ts +++ b/webapp-next/src/hooks/use-telegram-theme.ts @@ -6,6 +6,7 @@ import { isThemeParamsDark, setMiniAppBackgroundColor, setMiniAppHeaderColor, + setMiniAppBottomBarColor, } from "@telegram-apps/sdk-react"; /** @@ -69,6 +70,9 @@ export function applyTheme(scheme?: "dark" | "light"): void { if (setMiniAppHeaderColor.isAvailable()) { setMiniAppHeaderColor("bg_color"); } + if (setMiniAppBottomBarColor.isAvailable()) { + setMiniAppBottomBarColor("bottom_bar_bg_color"); + } } /** diff --git a/webapp-next/src/lib/telegram-android-perf.ts b/webapp-next/src/lib/telegram-android-perf.ts new file mode 100644 index 0000000..5e83846 --- /dev/null +++ b/webapp-next/src/lib/telegram-android-perf.ts @@ -0,0 +1,26 @@ +/** + * Detects Android Telegram Mini App performance class from User-Agent and sets + * data-perf on document.documentElement so CSS can reduce animations on low-end devices. + * @see https://core.telegram.org/bots/webapps#additional-data-in-user-agent + */ + +import { retrieveAndroidDeviceData } from "@telegram-apps/sdk-react"; + +const DATA_ATTR = "data-perf"; + +/** + * Runs once: if running in Telegram on Android with LOW performance class, + * sets data-perf="low" on the document root for CSS to minimize animations. + */ +export function applyAndroidPerformanceClass(): void { + if (typeof document === "undefined") return; + try { + const data = retrieveAndroidDeviceData(); + const perf = data?.performanceClass; + if (perf === "LOW") { + document.documentElement.setAttribute(DATA_ATTR, "low"); + } + } catch { + // Not in Telegram or not Android; ignore. + } +} diff --git a/webapp-next/src/lib/telegram-haptic.ts b/webapp-next/src/lib/telegram-haptic.ts new file mode 100644 index 0000000..d7a6156 --- /dev/null +++ b/webapp-next/src/lib/telegram-haptic.ts @@ -0,0 +1,19 @@ +/** + * Triggers Telegram Mini App haptic feedback when available. + * Use on primary actions and key interactions to mimic native Telegram behavior. + */ + +import { hapticFeedbackImpactOccurred } from "@telegram-apps/sdk-react"; + +/** + * Triggers light impact haptic feedback. No-op when not in Telegram or unsupported. + */ +export function triggerHapticLight(): void { + try { + if (hapticFeedbackImpactOccurred.isAvailable()) { + hapticFeedbackImpactOccurred("light"); + } + } catch { + // SDK not available; ignore. + } +} diff --git a/webapp-next/src/lib/telegram-ready.ts b/webapp-next/src/lib/telegram-ready.ts index ebb837e..3134690 100644 --- a/webapp-next/src/lib/telegram-ready.ts +++ b/webapp-next/src/lib/telegram-ready.ts @@ -1,15 +1,17 @@ /** - * Single-call wrapper for Telegram Mini App ready(). + * Single-call wrapper for Telegram Mini App ready() and expand(). * Called once when the first visible screen has finished loading so Telegram * hides its native loading animation only after our content is ready. + * Also expands the Mini App to full height when supported. */ -import { miniAppReady } from "@telegram-apps/sdk-react"; +import { miniAppReady, expandViewport } from "@telegram-apps/sdk-react"; let readyCalled = false; /** - * Calls Telegram miniAppReady() at most once per session. + * Calls Telegram miniAppReady() at most once per session, then expandViewport() + * when available so the app opens to full height. * Safe when SDK is unavailable (e.g. non-Telegram environment). */ export function callMiniAppReadyOnce(): void { @@ -19,6 +21,9 @@ export function callMiniAppReadyOnce(): void { miniAppReady(); readyCalled = true; } + if (expandViewport.isAvailable()) { + expandViewport(); + } } catch { // SDK not available or not in Mini App context; no-op. }