diff --git a/package-lock.json b/package-lock.json index 8ed0fe1..e25a042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "cabinet-frontend", - "version": "1.5.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { @@ -24,6 +24,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", + "@tma.js/sdk-react": "^3.0.15", "axios": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2606,6 +2607,92 @@ "react": "^18 || ^19" } }, + "node_modules/@tma.js/bridge": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@tma.js/bridge/-/bridge-2.2.2.tgz", + "integrity": "sha512-dL7iZzYP/VC4/qJYSXPI1GXLGP3gLTp10L7iGY6PcjB2F4xXS2JnHUoYvD2DgQG1ogjE93zWnVIMN7inx1v+Eg==", + "license": "MIT", + "dependencies": { + "@tma.js/signals": "^1.0.1", + "@tma.js/toolkit": "^1.0.4", + "@tma.js/transformers": "^1.1.3", + "@tma.js/types": "^1.0.2", + "better-promises": "^1.0.0", + "error-kid": "^1.0.2", + "fp-ts": "^2.16.11", + "mitt": "^3.0.1", + "valibot": "^1.1.0" + } + }, + "node_modules/@tma.js/sdk": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@tma.js/sdk/-/sdk-3.1.6.tgz", + "integrity": "sha512-NENkQa7G5N9kwbL20ErUMO9Fukxl9eVhcJ439PbCg2WYMk/x9oNbx2DhEzhX4xM718zSjYejmSj1oXCKk0PxYA==", + "license": "MIT", + "dependencies": { + "@tma.js/bridge": "^2.2.2", + "@tma.js/signals": "^1.0.1", + "@tma.js/toolkit": "^1.0.4", + "@tma.js/transformers": "^1.1.3", + "@tma.js/types": "^1.0.2", + "better-promises": "^1.0.0", + "error-kid": "^1.0.2", + "fp-ts": "^2.16.11", + "valibot": "^1.1.0" + } + }, + "node_modules/@tma.js/sdk-react": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@tma.js/sdk-react/-/sdk-react-3.0.15.tgz", + "integrity": "sha512-HsgqPINSBK87NIRZ+ZDol07QPhSQ3GOTofyZEH0xoaQjZWb9wVxUXwECa5ozRUdzWZI+Mgx8oD1pzrjyNkDjFA==", + "license": "MIT", + "dependencies": { + "@tma.js/sdk": "^3.1.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@tma.js/signals": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tma.js/signals/-/signals-1.0.1.tgz", + "integrity": "sha512-i2HUuwGqL4BmM5KAklAUhMhlEgUOF+F4nMHHS/zxrmD0upHE/0CiXCEdQxVeeOGN6e2RrKlonA40sDtR6OUDCw==", + "license": "MIT" + }, + "node_modules/@tma.js/toolkit": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tma.js/toolkit/-/toolkit-1.0.4.tgz", + "integrity": "sha512-6CDlkgc+43WD2jy6jog3B50yt9t+/wRwrN25zOa4iSw4IzfqoFjaQcJzTupBYaRxz19C3Rrir7p0/qStS/Z8vw==", + "license": "MIT", + "dependencies": { + "better-promises": "^1.0.0", + "fp-ts": "^2.16.11" + } + }, + "node_modules/@tma.js/transformers": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@tma.js/transformers/-/transformers-1.1.3.tgz", + "integrity": "sha512-zRL+fdo/NBpGqCJfl8ItAvcJIFT5Zy6UOYJwQPmUV5/gTHgmSvmGlrP43PeVi9XZ7Ggf4xbO6LUa4ehxozhMTA==", + "license": "MIT", + "dependencies": { + "@tma.js/toolkit": "^1.0.4", + "@tma.js/types": "^1.0.2", + "fp-ts": "^2.16.11", + "valibot": "^1.1.0" + } + }, + "node_modules/@tma.js/types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tma.js/types/-/types-1.0.2.tgz", + "integrity": "sha512-qs4mi+U1xZmMQBdMhWAo1X4YqUJ/ae0y28s+GNCpQq58bsJo0h8rvyVOB1RwPvXogIY9+yribbZe6z3AIJmsAQ==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3194,6 +3281,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-promises": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/better-promises/-/better-promises-1.0.0.tgz", + "integrity": "sha512-gPgL2nRgeSbMIe3QpsYdKR3K0S9OZuphVuos60Eqsw8d/6GivOkyJ5D/zmnolJ6hzh7upnnflBUWUlQh4qpU2A==", + "license": "MIT", + "dependencies": { + "error-kid": "^1.0.1" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3660,6 +3756,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/error-kid": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/error-kid/-/error-kid-1.0.2.tgz", + "integrity": "sha512-Xvq0ZrY/azCbREWKt9E/3mXDF0MkuEVVvHnOKutUhtq2O8pyneEixQ4nSFkA19Xfn+/OVSg//iVG0dxIDj7DIA==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4139,6 +4241,12 @@ "node": ">= 6" } }, + "node_modules/fp-ts": { + "version": "2.16.11", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.11.tgz", + "integrity": "sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==", + "license": "MIT" + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4876,6 +4984,12 @@ "node": "*" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/motion-dom": { "version": "12.29.2", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz", @@ -6164,7 +6278,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -6299,6 +6413,20 @@ "dev": true, "license": "MIT" }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index c5244b9..1416f46 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", + "@tma.js/sdk-react": "^3.0.15", "axios": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/api/client.ts b/src/api/client.ts index 53c1f83..1349c80 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -6,6 +6,7 @@ import { safeRedirectToLogin, } from '../utils/token'; import { useBlockingStore } from '../store/blocking'; +import { getTelegramInitData as getSDKInitData } from '../hooks/useTelegramSDK'; const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; @@ -41,7 +42,8 @@ function ensureCsrfToken(): string { const getTelegramInitData = (): string | null => { if (typeof window === 'undefined') return null; - const initData = window.Telegram?.WebApp?.initData; + // Use SDK helper to get initData + const initData = getSDKInitData(); if (initData) { tokenStorage.setTelegramInitData(initData); return initData; diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx index 3e6bac1..03c0edb 100644 --- a/src/components/layout/AppShell/AppHeader.tsx +++ b/src/components/layout/AppShell/AppHeader.tsx @@ -45,14 +45,17 @@ import { const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'; const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'; +import type { TelegramPlatform } from '@/hooks/useTelegramSDK'; + interface AppHeaderProps { mobileMenuOpen: boolean; setMobileMenuOpen: (open: boolean) => void; onCommandPaletteOpen: () => void; headerHeight: number; isFullscreen: boolean; - safeAreaInset: { top: number; bottom: number }; - contentSafeAreaInset: { top: number; bottom: number }; + safeAreaInset: { top: number; bottom: number; left: number; right: number }; + contentSafeAreaInset: { top: number; bottom: number; left: number; right: number }; + telegramPlatform?: TelegramPlatform; wheelEnabled?: boolean; referralEnabled?: boolean; hasContests?: boolean; @@ -67,6 +70,7 @@ export function AppHeader({ isFullscreen, safeAreaInset, contentSafeAreaInset, + telegramPlatform, wheelEnabled, referralEnabled, hasContests, @@ -178,7 +182,7 @@ export function AppHeader({ className="glass fixed left-0 right-0 top-0 z-50 shadow-lg shadow-black/10 lg:hidden" style={{ paddingTop: isFullscreen - ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` + ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (telegramPlatform === 'android' ? 0 : 45)}px` : undefined, }} > diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index ffca0d4..c87419d 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { useAuthStore } from '@/store/auth'; import { useBackButton, useHaptic } from '@/platform'; -import { useTelegramWebApp } from '@/hooks/useTelegramWebApp'; +import { useTelegramSDK } from '@/hooks/useTelegramSDK'; import { referralApi } from '@/api/referral'; import { wheelApi } from '@/api/wheel'; import { contestsApi } from '@/api/contests'; @@ -18,7 +18,7 @@ import { preloadLogo, isLogoPreloaded, } from '@/api/branding'; -import { setCachedFullscreenEnabled, isTelegramMobile } from '@/hooks/useTelegramWebApp'; +import { setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK'; import { cn } from '@/lib/utils'; import WebSocketNotifications from '@/components/WebSocketNotifications'; @@ -157,12 +157,19 @@ export function AppShell({ children }: AppShellProps) { const location = useLocation(); const navigate = useNavigate(); const { isAdmin, isAuthenticated, logout } = useAuthStore(); - const { isFullscreen, safeAreaInset, contentSafeAreaInset, requestFullscreen, isTelegramWebApp } = - useTelegramWebApp(); + const { + isFullscreen, + safeAreaInset, + contentSafeAreaInset, + requestFullscreen, + isTelegramWebApp, + platform, + isMobile, + } = useTelegramSDK(); const haptic = useHaptic(); // Only apply fullscreen UI adjustments on mobile Telegram (iOS/Android) - const isMobileFullscreen = isFullscreen && isTelegramMobile(); + const isMobileFullscreen = isFullscreen && isMobile; const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [isKeyboardOpen, setIsKeyboardOpen] = useState(false); @@ -256,10 +263,10 @@ export function AppShell({ children }: AppShellProps) { setCachedFullscreenEnabled(fullscreenSetting.enabled); // Request fullscreen if enabled, not already fullscreen, and on mobile Telegram - if (fullscreenSetting.enabled && !isFullscreen && isTelegramMobile()) { + if (fullscreenSetting.enabled && !isFullscreen && isMobile) { requestFullscreen(); } - }, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen]); + }, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]); // Feature flags const { data: referralTerms } = useQuery({ @@ -356,8 +363,9 @@ export function AppShell({ children }: AppShellProps) { }; // Calculate header height based on fullscreen mode (only on mobile Telegram) + // On Android, don't add extra 45px padding as the native header is already accounted for const headerHeight = isMobileFullscreen - ? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + ? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (platform === 'android' ? 0 : 45) : 64; return ( @@ -478,6 +486,7 @@ export function AppShell({ children }: AppShellProps) { isFullscreen={isMobileFullscreen} safeAreaInset={safeAreaInset} contentSafeAreaInset={contentSafeAreaInset} + telegramPlatform={platform} wheelEnabled={wheelConfig?.is_enabled} referralEnabled={referralTerms?.is_enabled} hasContests={(contestsCount?.count ?? 0) > 0} diff --git a/src/hooks/useTelegramSDK.ts b/src/hooks/useTelegramSDK.ts new file mode 100644 index 0000000..28f8e02 --- /dev/null +++ b/src/hooks/useTelegramSDK.ts @@ -0,0 +1,273 @@ +import { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { init, viewport, miniApp, swipeBehavior, themeParams } from '@tma.js/sdk-react'; + +// SDK initialization state +let sdkInitialized = false; +let initCleanup: VoidFunction | null = null; + +const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'; + +/** + * Get cached fullscreen setting + */ +export const getCachedFullscreenEnabled = (): boolean => { + try { + return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'; + } catch { + return false; + } +}; + +/** + * Set cached fullscreen setting + */ +export const setCachedFullscreenEnabled = (enabled: boolean) => { + try { + localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled)); + } catch { + // localStorage not available + } +}; + +/** + * Check if we're actually running inside Telegram Mini App + */ +export function isInTelegramWebApp(): boolean { + const webApp = window.Telegram?.WebApp; + return Boolean(webApp?.initData && webApp.initData.length > 0); +} + +/** + * Check if running on mobile Telegram client (iOS/Android) + */ +export function isTelegramMobile(): boolean { + const webApp = window.Telegram?.WebApp; + if (!webApp?.platform) return false; + return webApp.platform === 'ios' || webApp.platform === 'android'; +} + +/** + * Get Telegram init data for authentication + * Returns the raw initData string used for backend authentication + */ +export function getTelegramInitData(): string | null { + const webApp = window.Telegram?.WebApp; + return webApp?.initData || null; +} + +/** + * Initialize Telegram SDK + * Call this once at app startup (in main.tsx) + */ +export function initTelegramSDK() { + // Only run in Telegram context + if (!isInTelegramWebApp()) { + return; + } + + // Prevent double initialization + if (sdkInitialized) { + return; + } + + try { + // Initialize the SDK + initCleanup = init(); + sdkInitialized = true; + + // Mount viewport and bind CSS variables + viewport + .mount() + .then(() => { + viewport.bindCssVars(); + // Expand the mini app + viewport.expand(); + }) + .catch((err) => { + console.warn('Viewport mount failed:', err); + }); + + // Mount mini app and call ready + miniApp.mount(); + miniApp.ready(); + + // Mount theme params and bind CSS variables + try { + themeParams.mount(); + themeParams.bindCssVars(); + } catch { + // Theme params may already be mounted or not supported + } + + // Disable vertical swipes if supported + if (swipeBehavior.isSupported()) { + swipeBehavior.mount(); + swipeBehavior.disableVertical(); + } + + // Auto-enter fullscreen if enabled in settings (mobile only) + const fullscreenEnabled = getCachedFullscreenEnabled(); + if (fullscreenEnabled && isTelegramMobile()) { + // Wait for viewport to be mounted + setTimeout(() => { + const isFullscreen = viewport.isFullscreen(); + if (!isFullscreen) { + viewport.requestFullscreen().catch((e) => { + console.warn('Auto-fullscreen failed:', e); + }); + } + }, 100); + } + } catch (e) { + console.warn('Telegram SDK initialization failed:', e); + } +} + +/** + * Cleanup SDK (call on app unmount if needed) + */ +export function cleanupTelegramSDK() { + if (initCleanup) { + initCleanup(); + initCleanup = null; + sdkInitialized = false; + } +} + +/** + * Type for platform values + */ +export type TelegramPlatform = + | 'android' + | 'ios' + | 'tdesktop' + | 'macos' + | 'weba' + | 'webk' + | 'unigram' + | 'unknown' + | undefined; + +// Default values for when SDK is not available +const defaultInsets = { top: 0, bottom: 0, left: 0, right: 0 }; + +/** + * Helper to subscribe to SDK signals using useSyncExternalStore pattern + */ +function useSDKSignal( + signal: { (): T; sub(fn: VoidFunction): VoidFunction } | null, + defaultValue: T, +): T { + return useSyncExternalStore( + (callback) => { + if (!signal) return () => {}; + return signal.sub(callback); + }, + () => (signal ? signal() : defaultValue), + () => defaultValue, + ); +} + +/** + * Hook for Telegram SDK integration + * Provides fullscreen mode, safe area insets, platform info, and other features + */ +export function useTelegramSDK() { + const inTelegram = isInTelegramWebApp(); + const isReady = inTelegram && sdkInitialized; + + // Get platform from window.Telegram.WebApp (always available in Telegram) + const platform = useMemo(() => { + if (!inTelegram) return undefined; + return window.Telegram?.WebApp?.platform as TelegramPlatform; + }, [inTelegram]); + + // Use SDK signals for reactive state + const isFullscreen = useSDKSignal(isReady ? viewport.isFullscreen : null, false); + const safeAreaInsets = useSDKSignal(isReady ? viewport.safeAreaInsets : null, defaultInsets); + const contentSafeAreaInsets = useSDKSignal( + isReady ? viewport.contentSafeAreaInsets : null, + defaultInsets, + ); + const viewportHeight = useSDKSignal(isReady ? viewport.height : null, 0); + const viewportStableHeight = useSDKSignal(isReady ? viewport.stableHeight : null, 0); + const viewportWidth = useSDKSignal(isReady ? viewport.width : null, 0); + const isExpanded = useSDKSignal(isReady ? viewport.isExpanded : null, true); + + const requestFullscreen = useCallback(() => { + if (!isReady) return; + viewport.requestFullscreen().catch((e) => { + console.warn('Fullscreen not supported:', e); + }); + }, [isReady]); + + const exitFullscreen = useCallback(() => { + if (!isReady) return; + viewport.exitFullscreen().catch((e) => { + console.warn('Exit fullscreen failed:', e); + }); + }, [isReady]); + + const toggleFullscreen = useCallback(() => { + if (isFullscreen) { + exitFullscreen(); + } else { + requestFullscreen(); + } + }, [isFullscreen, requestFullscreen, exitFullscreen]); + + const expand = useCallback(() => { + if (!isReady) return; + viewport.expand(); + }, [isReady]); + + const disableVerticalSwipes = useCallback(() => { + if (!isReady) return; + if (swipeBehavior.isSupported()) { + swipeBehavior.disableVertical(); + } + }, [isReady]); + + const enableVerticalSwipes = useCallback(() => { + if (!isReady) return; + if (swipeBehavior.isSupported()) { + swipeBehavior.enableVertical(); + } + }, [isReady]); + + // Check if fullscreen is supported (Bot API 8.0+) + const isFullscreenSupported = useMemo(() => { + if (!isReady) return false; + try { + return typeof viewport.requestFullscreen === 'function'; + } catch { + return false; + } + }, [isReady]); + + // Check if it's mobile platform + const isMobile = platform === 'ios' || platform === 'android'; + + return { + isTelegramWebApp: inTelegram, + isFullscreen, + isFullscreenSupported, + safeAreaInset: safeAreaInsets, + contentSafeAreaInset: contentSafeAreaInsets, + viewportHeight, + viewportStableHeight, + viewportWidth, + isExpanded, + platform, + isMobile, + requestFullscreen, + exitFullscreen, + toggleFullscreen, + expand, + disableVerticalSwipes, + enableVerticalSwipes, + // Expose raw SDK components for advanced usage + viewport: isReady ? viewport : null, + miniApp: isReady ? miniApp : null, + }; +} diff --git a/src/hooks/useTelegramWebApp.ts b/src/hooks/useTelegramWebApp.ts index 1bea67f..0178b89 100644 --- a/src/hooks/useTelegramWebApp.ts +++ b/src/hooks/useTelegramWebApp.ts @@ -1,218 +1,38 @@ -import { useEffect, useState, useCallback } from 'react'; - -const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'; - /** - * Check if running on mobile Telegram client (iOS/Android) - * Fullscreen mode should only be applied on mobile platforms + * @deprecated This hook is deprecated. Use useTelegramSDK instead. + * This file is kept for backward compatibility and re-exports from useTelegramSDK. */ -export function isTelegramMobile(): boolean { - const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined; - if (!webApp?.platform) return false; - // Only iOS and Android are mobile platforms - // tdesktop, macos, web, unknown - all are desktop/non-mobile - return webApp.platform === 'ios' || webApp.platform === 'android'; -} +import { useTelegramSDK } from './useTelegramSDK'; -// Get cached fullscreen setting -export const getCachedFullscreenEnabled = (): boolean => { - try { - return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'; - } catch { - return false; - } -}; - -// Set cached fullscreen setting -export const setCachedFullscreenEnabled = (enabled: boolean) => { - try { - localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled)); - } catch { - // localStorage not available - } -}; +// Re-export everything from useTelegramSDK for backward compatibility +export { + getCachedFullscreenEnabled, + setCachedFullscreenEnabled, + isInTelegramWebApp, + isTelegramMobile, + initTelegramSDK as initTelegramWebApp, // Alias for backward compatibility +} from './useTelegramSDK'; /** - * Hook for Telegram WebApp API integration - * Provides fullscreen mode, safe area insets, and other WebApp features + * @deprecated Use useTelegramSDK instead + * Hook for Telegram WebApp API integration - backward compatible wrapper */ export function useTelegramWebApp() { - // Initialize synchronously to avoid flash/flicker on first render - const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined; - const inTelegram = isInTelegramWebApp(); - - const [isFullscreen, setIsFullscreen] = useState( - () => (inTelegram && webApp?.isFullscreen) || false, - ); - const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => inTelegram); - const [safeAreaInset, setSafeAreaInset] = useState( - () => (inTelegram && webApp?.safeAreaInset) || { top: 0, bottom: 0, left: 0, right: 0 }, - ); - const [contentSafeAreaInset, setContentSafeAreaInset] = useState( - () => - (inTelegram && webApp?.contentSafeAreaInset) || { - top: 0, - bottom: 0, - left: 0, - right: 0, - }, - ); - - useEffect(() => { - // Only run Telegram-specific code if we're actually in Telegram - const isActuallyInTelegram = isInTelegramWebApp(); - if (!webApp || !isActuallyInTelegram) { - setIsTelegramWebApp(false); - return; - } - - setIsTelegramWebApp(true); - setIsFullscreen(webApp.isFullscreen || false); - setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }); - setContentSafeAreaInset( - webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }, - ); - - // Expand WebApp to full height - webApp.expand(); - webApp.ready(); - - // Listen for fullscreen changes - const handleFullscreenChanged = () => { - setIsFullscreen(webApp.isFullscreen || false); - setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }); - setContentSafeAreaInset( - webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }, - ); - }; - - // Listen for safe area changes - const handleSafeAreaChanged = () => { - setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }); - setContentSafeAreaInset( - webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }, - ); - }; - - webApp.onEvent('fullscreenChanged', handleFullscreenChanged); - webApp.onEvent('safeAreaChanged', handleSafeAreaChanged); - webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged); - - return () => { - webApp.offEvent('fullscreenChanged', handleFullscreenChanged); - webApp.offEvent('safeAreaChanged', handleSafeAreaChanged); - webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged); - }; - }, [webApp]); - - const requestFullscreen = useCallback(() => { - if (webApp?.requestFullscreen) { - try { - webApp.requestFullscreen(); - } catch (e) { - console.warn('Fullscreen not supported:', e); - } - } - }, [webApp]); - - const exitFullscreen = useCallback(() => { - if (webApp?.exitFullscreen) { - try { - webApp.exitFullscreen(); - } catch (e) { - console.warn('Exit fullscreen failed:', e); - } - } - }, [webApp]); - - const toggleFullscreen = useCallback(() => { - if (isFullscreen) { - exitFullscreen(); - } else { - requestFullscreen(); - } - }, [isFullscreen, requestFullscreen, exitFullscreen]); - - const disableVerticalSwipes = useCallback(() => { - try { - if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') { - webApp.disableVerticalSwipes(); - } - } catch { - // Not supported in this version - } - }, [webApp]); - - const enableVerticalSwipes = useCallback(() => { - try { - if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') { - webApp.enableVerticalSwipes(); - } - } catch { - // Not supported in this version - } - }, [webApp]); - - // Check if fullscreen is supported (Bot API 8.0+) - const isFullscreenSupported = Boolean(webApp?.requestFullscreen); + const sdk = useTelegramSDK(); return { - isTelegramWebApp, - isFullscreen, - isFullscreenSupported, - safeAreaInset, - contentSafeAreaInset, - requestFullscreen, - exitFullscreen, - toggleFullscreen, - disableVerticalSwipes, - enableVerticalSwipes, - webApp, + isTelegramWebApp: sdk.isTelegramWebApp, + isFullscreen: sdk.isFullscreen, + isFullscreenSupported: sdk.isFullscreenSupported, + safeAreaInset: sdk.safeAreaInset, + contentSafeAreaInset: sdk.contentSafeAreaInset, + requestFullscreen: sdk.requestFullscreen, + exitFullscreen: sdk.exitFullscreen, + toggleFullscreen: sdk.toggleFullscreen, + disableVerticalSwipes: sdk.disableVerticalSwipes, + enableVerticalSwipes: sdk.enableVerticalSwipes, + // For backward compatibility, expose webApp as the raw Telegram object + webApp: window.Telegram?.WebApp ?? null, }; } - -/** - * Check if we're actually running inside Telegram Mini App - * (not just the script loaded on a regular webpage) - */ -export function isInTelegramWebApp(): boolean { - const webApp = window.Telegram?.WebApp; - // Check if initData exists - it's empty when not in Telegram - return Boolean(webApp?.initData && webApp.initData.length > 0); -} - -/** - * Initialize Telegram WebApp on app start - * Call this in main.tsx or App.tsx - */ -export function initTelegramWebApp() { - const webApp = window.Telegram?.WebApp; - // Only initialize if we're actually in Telegram - if (!webApp || !isInTelegramWebApp()) { - return; - } - - webApp.ready(); - webApp.expand(); - - // Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+) - try { - if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') { - webApp.disableVerticalSwipes(); - } - } catch { - // Swipe control not supported in this version - } - - // Auto-enter fullscreen if enabled in settings (use cached value for instant response) - // Only apply fullscreen on mobile Telegram (iOS/Android) - desktop doesn't need it - const fullscreenEnabled = getCachedFullscreenEnabled(); - if (fullscreenEnabled && isTelegramMobile() && webApp.requestFullscreen && !webApp.isFullscreen) { - try { - webApp.requestFullscreen(); - } catch (e) { - console.warn('Auto-fullscreen failed:', e); - } - } -} diff --git a/src/main.tsx b/src/main.tsx index f69eb2f..ada5801 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,12 +8,12 @@ import { ThemeColorsProvider } from './providers/ThemeColorsProvider'; import { ToastProvider } from './components/Toast'; import { TooltipProvider } from './components/primitives/Tooltip'; import { initLogoPreload } from './api/branding'; -import { initTelegramWebApp } from './hooks/useTelegramWebApp'; +import { initTelegramSDK } from './hooks/useTelegramSDK'; import './i18n'; import './styles/globals.css'; -// Initialize Telegram WebApp (expand, disable swipes) -initTelegramWebApp(); +// Initialize Telegram SDK (init, viewport mount, CSS vars binding, swipe control) +initTelegramSDK(); // Preload logo from cache immediately on page load initLogoPreload(); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 5e86484..d1231d7 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -14,6 +14,7 @@ import { type EmailAuthEnabled, } from '../api/branding'; import { getAndClearReturnUrl } from '../utils/token'; +import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; import LanguageSwitcher from '../components/LanguageSwitcher'; import TelegramLoginButton from '../components/TelegramLoginButton'; @@ -111,14 +112,13 @@ export default function Login() { // Try Telegram WebApp authentication on mount useEffect(() => { const tryTelegramAuth = async () => { - const tg = window.Telegram?.WebApp; - if (tg?.initData) { + const initData = getTelegramInitData(); + if (isInTelegramWebApp() && initData) { setIsTelegramWebApp(true); - tg.ready(); - tg.expand(); + // Note: ready() and expand() are already called by initTelegramSDK in main.tsx setIsLoading(true); try { - await loginWithTelegram(tg.initData); + await loginWithTelegram(initData); navigate(getReturnUrl(), { replace: true }); } catch (err) { // Log only status code to avoid leaking sensitive data diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 2398c4c..a1bd3d4 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -11,6 +11,7 @@ import type { TicketDetail, TicketMessage } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; +import { usePlatform } from '@/platform'; const log = logger.createLogger('Support'); @@ -152,6 +153,7 @@ export default function Support() { const { t } = useTranslation(); const { isAdmin } = useAuthStore(); const queryClient = useQueryClient(); + const { openTelegramLink, openLink } = usePlatform(); const [selectedTicket, setSelectedTicket] = useState(null); const [showCreateForm, setShowCreateForm] = useState(false); const [newTitle, setNewTitle] = useState(''); @@ -314,7 +316,6 @@ export default function Support() { buttonText: t('support.contactUs'), buttonAction: () => { log.debug('Button clicked, opening:', supportUsername); - const webApp = window.Telegram?.WebApp; // Extract username without @ const username = supportUsername.startsWith('@') @@ -322,36 +323,10 @@ export default function Support() { : supportUsername; const webUrl = `https://t.me/${username}`; - log.debug('Web URL:', webUrl, 'WebApp methods:', { - openTelegramLink: !!webApp?.openTelegramLink, - openLink: !!webApp?.openLink, - }); + log.debug('Web URL:', webUrl); - // Try openTelegramLink first (for tg:// links) - if (webApp?.openTelegramLink) { - log.debug('Using openTelegramLink with web URL'); - try { - webApp.openTelegramLink(webUrl); - return; - } catch (e) { - log.error('openTelegramLink failed:', e); - } - } - - // Fallback to openLink - if (webApp?.openLink) { - log.debug('Using openLink'); - try { - webApp.openLink(webUrl, { try_browser: true }); - return; - } catch (e) { - log.error('openLink failed:', e); - } - } - - // Last resort - window.open - log.debug('Using window.open'); - window.open(webUrl, '_blank'); + // Use platform's openTelegramLink + openTelegramLink(webUrl); }, }; } @@ -362,12 +337,7 @@ export default function Support() { message: t('support.useExternalLink'), buttonText: t('support.openSupport'), buttonAction: () => { - const webApp = window.Telegram?.WebApp; - if (webApp?.openLink) { - webApp.openLink(supportConfig.support_url!, { try_browser: true }); - } else { - window.open(supportConfig.support_url!, '_blank'); - } + openLink(supportConfig.support_url!, { tryInstantView: false }); }, }; } @@ -381,7 +351,6 @@ export default function Support() { buttonText: t('support.contactUs'), buttonAction: () => { log.debug('Fallback button clicked, opening:', supportUsername); - const webApp = window.Telegram?.WebApp; // Extract username without @ const username = supportUsername.startsWith('@') @@ -391,16 +360,8 @@ export default function Support() { const webUrl = `https://t.me/${username}`; log.debug('Fallback opening URL:', webUrl); - if (webApp?.openTelegramLink) { - log.debug('Fallback using openTelegramLink'); - webApp.openTelegramLink(webUrl); - } else if (webApp?.openLink) { - log.debug('Fallback using openLink'); - webApp.openLink(webUrl, { try_browser: true }); - } else { - log.debug('Fallback using window.open'); - window.open(webUrl, '_blank'); - } + // Use platform's openTelegramLink + openTelegramLink(webUrl); }, }; }; diff --git a/src/pages/TelegramRedirect.tsx b/src/pages/TelegramRedirect.tsx index 8d90a87..1342519 100644 --- a/src/pages/TelegramRedirect.tsx +++ b/src/pages/TelegramRedirect.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '../store/auth'; import { brandingApi } from '../api/branding'; +import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; // Validate redirect URL to prevent open redirect attacks const getSafeRedirectUrl = (url: string | null): string => { @@ -64,33 +65,19 @@ export default function TelegramRedirect() { const initTelegram = async () => { // Check if running in Telegram WebApp - const tg = window.Telegram?.WebApp; + const initData = getTelegramInitData(); - if (!tg?.initData) { + if (!isInTelegramWebApp() || !initData) { // Not in Telegram, show message and redirect to login setStatus('not-telegram'); setTimeout(() => navigate('/login'), 2000); return; } - // Initialize Telegram WebApp - tg.ready(); - tg.expand(); - - // Set theme colors if available - if (tg.themeParams) { - document.documentElement.style.setProperty( - '--tg-theme-bg-color', - tg.themeParams.bg_color || '#1a1a2e', - ); - document.documentElement.style.setProperty( - '--tg-theme-text-color', - tg.themeParams.text_color || '#ffffff', - ); - } + // Note: ready(), expand(), and theme CSS vars are already handled by initTelegramSDK in main.tsx try { - await loginWithTelegram(tg.initData); + await loginWithTelegram(initData); setStatus('success'); // Small delay for nice UX diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 4f7c6cd..629f642 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -1,10 +1,11 @@ -import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel'; import FortuneWheel from '../components/wheel/FortuneWheel'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import { useCurrency } from '../hooks/useCurrency'; +import { usePlatform } from '@/platform'; // Pre-calculated confetti positions (stable across re-renders) const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({ @@ -72,6 +73,7 @@ export default function Wheel() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { formatAmount, currencySymbol } = useCurrency(); + const { platform, openInvoice, capabilities } = usePlatform(); const [isSpinning, setIsSpinning] = useState(false); const [targetRotation, setTargetRotation] = useState(null); @@ -83,11 +85,8 @@ export default function Wheel() { const [showHistory, setShowHistory] = useState(false); const [isPayingStars, setIsPayingStars] = useState(false); - const isTelegramMiniApp = useMemo(() => { - // Check if we're in Telegram Mini App environment - const webApp = window.Telegram?.WebApp; - return !!(webApp && typeof webApp.initData === 'string'); - }, []); + // Check if we're in Telegram Mini App environment + const isTelegramMiniApp = platform === 'telegram'; const { data: config, @@ -206,64 +205,43 @@ export default function Wheel() { const starsInvoiceMutation = useMutation({ mutationFn: wheelApi.createStarsInvoice, - onSuccess: (data) => { - const webApp = window.Telegram?.WebApp; - // openInvoice requires WebApp version 6.1+ - const supportsInvoice = - webApp?.openInvoice && webApp?.isVersionAtLeast && webApp.isVersionAtLeast('6.1'); - if (supportsInvoice) { - webApp.openInvoice(data.invoice_url, async (status) => { - if (status === 'paid') { - // Mark this as a Stars spin so handleSpinComplete knows to use the pending result - isStarsSpinRef.current = true; - pendingStarsResultRef.current = null; + onSuccess: async (data) => { + // Use platform's openInvoice if available + if (capabilities.hasInvoice) { + const status = await openInvoice(data.invoice_url); - // Cancel any existing polling - if (pollingAbortRef.current) { - pollingAbortRef.current.abort(); - } - pollingAbortRef.current = new AbortController(); + if (status === 'paid') { + // Mark this as a Stars spin so handleSpinComplete knows to use the pending result + isStarsSpinRef.current = true; + pendingStarsResultRef.current = null; - // Payment done - reset paying state immediately - setIsPayingStars(false); + // Cancel any existing polling + if (pollingAbortRef.current) { + pollingAbortRef.current.abort(); + } + pollingAbortRef.current = new AbortController(); - // Start spinning animation (5 seconds duration in FortuneWheel) - setIsSpinning(true); - setTargetRotation(360 * 5 + Math.random() * 360); + // Payment done - reset paying state immediately + setIsPayingStars(false); - // Poll for the result in the background - don't await here! - // The result will be stored and shown when animation completes - const abortSignal = pollingAbortRef.current.signal; - pollForSpinResult(abortSignal) - .then((result) => { - if (abortSignal.aborted) return; + // Start spinning animation (5 seconds duration in FortuneWheel) + setIsSpinning(true); + setTargetRotation(360 * 5 + Math.random() * 360); - queryClient.invalidateQueries({ queryKey: ['wheel-config'] }); - queryClient.invalidateQueries({ queryKey: ['wheel-history'] }); + // Poll for the result in the background - don't await here! + // The result will be stored and shown when animation completes + const abortSignal = pollingAbortRef.current.signal; + pollForSpinResult(abortSignal) + .then((result) => { + if (abortSignal.aborted) return; - if (result) { - pendingStarsResultRef.current = result; - } else { - // Fallback: couldn't get result - pendingStarsResultRef.current = { - success: true, - prize_id: null, - prize_type: null, - prize_value: 0, - prize_display_name: '', - emoji: '🎰', - color: '#8B5CF6', - rotation_degrees: 0, - message: t('wheel.starsPaymentSuccessCheckHistory'), - promocode: null, - error: null, - }; - } - }) - .catch(() => { - if (abortSignal.aborted) return; + queryClient.invalidateQueries({ queryKey: ['wheel-config'] }); + queryClient.invalidateQueries({ queryKey: ['wheel-history'] }); - // Error polling, show generic success + if (result) { + pendingStarsResultRef.current = result; + } else { + // Fallback: couldn't get result pendingStarsResultRef.current = { success: true, prize_id: null, @@ -277,29 +255,47 @@ export default function Wheel() { promocode: null, error: null, }; - }); - } else if (status !== 'cancelled') { - setIsPayingStars(false); - setSpinResult({ - success: false, - prize_id: null, - prize_type: null, - prize_value: 0, - prize_display_name: '', - emoji: '😔', - color: '#EF4444', - rotation_degrees: 0, - message: t('wheel.starsPaymentFailed'), - promocode: null, - error: 'payment_failed', + } + }) + .catch(() => { + if (abortSignal.aborted) return; + + // Error polling, show generic success + pendingStarsResultRef.current = { + success: true, + prize_id: null, + prize_type: null, + prize_value: 0, + prize_display_name: '', + emoji: '🎰', + color: '#8B5CF6', + rotation_degrees: 0, + message: t('wheel.starsPaymentSuccessCheckHistory'), + promocode: null, + error: null, + }; }); - setShowResultModal(true); - } else { - setIsPayingStars(false); - } - }); + } else if (status !== 'cancelled') { + setIsPayingStars(false); + setSpinResult({ + success: false, + prize_id: null, + prize_type: null, + prize_value: 0, + prize_display_name: '', + emoji: '😔', + color: '#EF4444', + rotation_degrees: 0, + message: t('wheel.starsPaymentFailed'), + promocode: null, + error: 'payment_failed', + }); + setShowResultModal(true); + } else { + setIsPayingStars(false); + } } else { - // Fallback: open invoice URL in Telegram (browser or unsupported WebApp version) + // Fallback: open invoice URL in browser (unsupported platform) setIsPayingStars(false); window.open(data.invoice_url, '_blank', 'noopener,noreferrer'); setSpinResult({ diff --git a/src/platform/PlatformProvider.tsx b/src/platform/PlatformProvider.tsx index 07265ed..dbda697 100644 --- a/src/platform/PlatformProvider.tsx +++ b/src/platform/PlatformProvider.tsx @@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from 'react'; import { PlatformContext } from '@/platform/PlatformContext'; import { createTelegramAdapter } from '@/platform/adapters/TelegramAdapter'; import { createWebAdapter } from '@/platform/adapters/WebAdapter'; +import { isInTelegramWebApp } from '@/hooks/useTelegramSDK'; import type { PlatformContext as PlatformContextType } from '@/platform/types'; interface PlatformProviderProps { @@ -9,16 +10,7 @@ interface PlatformProviderProps { } function detectPlatform(): 'telegram' | 'web' { - // Check if Telegram WebApp is available and actually initialized with data - // initData is an empty string when not in real Telegram context - if ( - typeof window !== 'undefined' && - window.Telegram?.WebApp?.initData && - window.Telegram.WebApp.initData.length > 0 - ) { - return 'telegram'; - } - return 'web'; + return isInTelegramWebApp() ? 'telegram' : 'web'; } function createAdapter(): PlatformContextType { diff --git a/src/platform/adapters/TelegramAdapter.ts b/src/platform/adapters/TelegramAdapter.ts index 2b9b7ca..d95d93d 100644 --- a/src/platform/adapters/TelegramAdapter.ts +++ b/src/platform/adapters/TelegramAdapter.ts @@ -1,3 +1,13 @@ +import { + backButton, + mainButton, + hapticFeedback, + cloudStorage, + themeParams, + popup, + miniApp, +} from '@tma.js/sdk-react'; +import { isInTelegramWebApp } from '@/hooks/useTelegramSDK'; import type { PlatformContext, PlatformCapabilities, @@ -14,186 +24,201 @@ import type { HapticNotificationType, } from '@/platform/types'; +// Keep reference to raw Telegram WebApp for features not in SDK function getTelegram(): TelegramWebApp | null { return window.Telegram?.WebApp ?? null; } -function isVersionAtLeast(version: string): boolean { - const tg = getTelegram(); - if (!tg) return false; - return tg.isVersionAtLeast?.(version) ?? false; -} - function createCapabilities(): PlatformCapabilities { const tg = getTelegram(); + const inTelegram = isInTelegramWebApp(); + return { - hasBackButton: !!tg?.BackButton, - hasMainButton: !!tg?.MainButton, - hasHapticFeedback: isVersionAtLeast('6.1') && !!tg?.HapticFeedback, - hasNativeDialogs: isVersionAtLeast('6.2'), - hasThemeSync: isVersionAtLeast('6.1'), + hasBackButton: inTelegram && backButton.isSupported(), + hasMainButton: inTelegram, + hasHapticFeedback: inTelegram && hapticFeedback.isSupported(), + hasNativeDialogs: inTelegram && popup.isSupported(), + hasThemeSync: inTelegram, hasInvoice: !!tg?.openInvoice, - hasCloudStorage: isVersionAtLeast('6.9') && !!tg?.CloudStorage, - hasShare: true, // openTelegramLink is always available + hasCloudStorage: inTelegram && cloudStorage.isSupported(), + hasShare: true, version: tg?.version, }; } function createBackButtonController(): BackButtonController { - const tg = getTelegram(); - let currentCallback: (() => void) | null = null; + const inTelegram = isInTelegramWebApp(); + let removeClickListener: VoidFunction | null = null; + + // Mount back button on first use + let mounted = false; + const ensureMounted = () => { + if (!mounted && inTelegram && backButton.isSupported()) { + try { + backButton.mount(); + mounted = true; + } catch { + // Already mounted or not supported + } + } + }; return { get isVisible() { - return tg?.BackButton?.isVisible ?? false; + if (!inTelegram) return false; + return backButton.isVisible(); }, show(onClick: () => void) { - if (!tg?.BackButton) return; + if (!inTelegram || !backButton.isSupported()) return; + + ensureMounted(); // Remove previous callback if exists - if (currentCallback) { - tg.BackButton.offClick(currentCallback); + if (removeClickListener) { + removeClickListener(); + removeClickListener = null; } - currentCallback = onClick; - tg.BackButton.onClick(onClick); - tg.BackButton.show(); + removeClickListener = backButton.onClick(onClick); + backButton.show(); }, hide() { - if (!tg?.BackButton) return; + if (!inTelegram || !backButton.isSupported()) return; - if (currentCallback) { - tg.BackButton.offClick(currentCallback); - currentCallback = null; + if (removeClickListener) { + removeClickListener(); + removeClickListener = null; } - tg.BackButton.hide(); + backButton.hide(); }, }; } function createMainButtonController(): MainButtonController { - const tg = getTelegram(); - let currentCallback: (() => void) | null = null; + const inTelegram = isInTelegramWebApp(); + let removeClickListener: VoidFunction | null = null; + + // Mount main button on first use + let mounted = false; + const ensureMounted = () => { + if (!mounted && inTelegram) { + try { + mainButton.mount(); + mounted = true; + } catch { + // Already mounted or not supported + } + } + }; return { get isVisible() { - return tg?.MainButton?.isVisible ?? false; + if (!inTelegram) return false; + return mainButton.isVisible(); }, show(config: MainButtonConfig) { - if (!tg?.MainButton) return; + if (!inTelegram) return; + + ensureMounted(); // Remove previous callback if exists - if (currentCallback) { - tg.MainButton.offClick(currentCallback); + if (removeClickListener) { + removeClickListener(); + removeClickListener = null; } - currentCallback = config.onClick; - // Set button parameters - if (tg.MainButton.setParams) { - tg.MainButton.setParams({ - text: config.text, - color: config.color, - text_color: config.textColor, - is_active: config.isActive !== false, - is_visible: true, - }); - } else { - tg.MainButton.text = config.text; - if (config.color) tg.MainButton.color = config.color; - if (config.textColor) tg.MainButton.textColor = config.textColor; - } + mainButton.setParams({ + text: config.text, + bgColor: config.color as `#${string}` | undefined, + textColor: config.textColor as `#${string}` | undefined, + isEnabled: config.isActive !== false, + isVisible: true, + isLoaderVisible: config.isLoading || false, + }); - tg.MainButton.onClick(config.onClick); - - if (config.isActive === false) { - tg.MainButton.disable(); - } else { - tg.MainButton.enable(); - } - - if (config.isLoading) { - tg.MainButton.showProgress?.(true); - } else { - tg.MainButton.hideProgress?.(); - } - - tg.MainButton.show(); + removeClickListener = mainButton.onClick(config.onClick); + mainButton.show(); }, hide() { - if (!tg?.MainButton) return; + if (!inTelegram) return; - if (currentCallback) { - tg.MainButton.offClick(currentCallback); - currentCallback = null; + if (removeClickListener) { + removeClickListener(); + removeClickListener = null; } - tg.MainButton.hideProgress?.(); - tg.MainButton.hide(); + mainButton.hideLoader(); + mainButton.hide(); }, showProgress(show: boolean) { - if (!tg?.MainButton) return; + if (!inTelegram) return; if (show) { - tg.MainButton.showProgress?.(true); + mainButton.showLoader(); } else { - tg.MainButton.hideProgress?.(); + mainButton.hideLoader(); } }, setText(text: string) { - if (!tg?.MainButton) return; - - if (tg.MainButton.setText) { - tg.MainButton.setText(text); - } else { - tg.MainButton.text = text; - } + if (!inTelegram) return; + mainButton.setText(text); }, setActive(active: boolean) { - if (!tg?.MainButton) return; + if (!inTelegram) return; if (active) { - tg.MainButton.enable(); + mainButton.enable(); } else { - tg.MainButton.disable(); + mainButton.disable(); } }, }; } function createHapticController(): HapticController { - const tg = getTelegram(); + const inTelegram = isInTelegramWebApp(); + const isSupported = inTelegram && hapticFeedback.isSupported(); return { impact(style: HapticImpactStyle = 'medium') { - tg?.HapticFeedback?.impactOccurred(style); + if (!isSupported) return; + hapticFeedback.impactOccurred(style); }, notification(type: HapticNotificationType) { - tg?.HapticFeedback?.notificationOccurred(type); + if (!isSupported) return; + hapticFeedback.notificationOccurred(type); }, selection() { - tg?.HapticFeedback?.selectionChanged(); + if (!isSupported) return; + hapticFeedback.selectionChanged(); }, }; } function createDialogController(): DialogController { - const tg = getTelegram(); + const inTelegram = isInTelegramWebApp(); + const isSupported = inTelegram && popup.isSupported(); return { alert(message: string, _title?: string): Promise { return new Promise((resolve) => { - if (tg?.showAlert) { - tg.showAlert(message, () => resolve()); + if (isSupported) { + popup + .show({ + message, + buttons: [{ type: 'ok' }], + }) + .then(() => resolve()); } else { window.alert(message); resolve(); @@ -203,8 +228,16 @@ function createDialogController(): DialogController { confirm(message: string, _title?: string): Promise { return new Promise((resolve) => { - if (tg?.showConfirm) { - tg.showConfirm(message, (confirmed) => resolve(confirmed)); + if (isSupported) { + popup + .show({ + message, + buttons: [ + { id: 'ok', type: 'ok' }, + { id: 'cancel', type: 'cancel' }, + ], + }) + .then((buttonId) => resolve(buttonId === 'ok')); } else { resolve(window.confirm(message)); } @@ -213,9 +246,9 @@ function createDialogController(): DialogController { popup(options: PopupOptions): Promise { return new Promise((resolve) => { - if (tg?.showPopup) { - tg.showPopup( - { + if (isSupported) { + popup + .show({ title: options.title, message: options.message, buttons: options.buttons?.map((btn) => ({ @@ -223,11 +256,9 @@ function createDialogController(): DialogController { type: btn.type, text: btn.text, })), - }, - (buttonId) => resolve(buttonId), - ); + }) + .then((buttonId) => resolve(buttonId ?? null)); } else { - // Fallback to confirm for web const confirmed = window.confirm(options.message); resolve(confirmed ? 'ok' : null); } @@ -237,62 +268,81 @@ function createDialogController(): DialogController { } function createThemeController(): ThemeController { - const tg = getTelegram(); + const inTelegram = isInTelegramWebApp(); + + // Mount theme params on first use + let mounted = false; + const ensureMounted = () => { + if (!mounted && inTelegram) { + try { + themeParams.mount(); + mounted = true; + } catch { + // Already mounted + } + } + }; return { setHeaderColor(color: string) { - tg?.setHeaderColor?.(color); + if (!inTelegram) return; + miniApp.setHeaderColor(color as `#${string}`); }, setBottomBarColor(color: string) { - tg?.setBottomBarColor?.(color); + if (!inTelegram) return; + try { + miniApp.setBottomBarColor(color as `#${string}`); + } catch { + // Not supported in this version + } }, getThemeParams() { - return tg?.themeParams ?? null; + if (!inTelegram) return null; + ensureMounted(); + const state = themeParams.state(); + // Convert SDK format to our format + return { + bg_color: state.bgColor, + text_color: state.textColor, + hint_color: state.hintColor, + link_color: state.linkColor, + button_color: state.buttonColor, + button_text_color: state.buttonTextColor, + secondary_bg_color: state.secondaryBgColor, + header_bg_color: state.headerBgColor, + bottom_bar_bg_color: state.bottomBarBgColor, + accent_text_color: state.accentTextColor, + section_bg_color: state.sectionBgColor, + section_header_text_color: state.sectionHeaderTextColor, + subtitle_text_color: state.subtitleTextColor, + destructive_text_color: state.destructiveTextColor, + }; }, }; } function createCloudStorageController(): CloudStorageController | null { - const tg = getTelegram(); - if (!tg?.CloudStorage) return null; + const inTelegram = isInTelegramWebApp(); + if (!inTelegram || !cloudStorage.isSupported()) return null; return { - getItem(key: string): Promise { - return new Promise((resolve, reject) => { - tg.CloudStorage.getItem(key, (error, value) => { - if (error) reject(error); - else resolve(value || null); - }); - }); + async getItem(key: string): Promise { + const value = await cloudStorage.getItem(key); + return value || null; }, - setItem(key: string, value: string): Promise { - return new Promise((resolve, reject) => { - tg.CloudStorage.setItem(key, value, (error) => { - if (error) reject(error); - else resolve(); - }); - }); + async setItem(key: string, value: string): Promise { + await cloudStorage.setItem(key, value); }, - removeItem(key: string): Promise { - return new Promise((resolve, reject) => { - tg.CloudStorage.removeItem(key, (error) => { - if (error) reject(error); - else resolve(); - }); - }); + async removeItem(key: string): Promise { + await cloudStorage.deleteItem(key); }, - getKeys(): Promise { - return new Promise((resolve, reject) => { - tg.CloudStorage.getKeys((error, keys) => { - if (error) reject(error); - else resolve(keys); - }); - }); + async getKeys(): Promise { + return cloudStorage.getKeys(); }, }; }