feat: migrate to @tma.js/sdk-react for Telegram Mini App

- Add useTelegramSDK hook with reactive signals for viewport, safe area, fullscreen
- Migrate TelegramAdapter to use SDK components (backButton, mainButton, hapticFeedback, cloudStorage, themeParams, popup)
- Update Login, TelegramRedirect to use SDK helpers
- Update PlatformProvider, api/client to use centralized SDK functions
- Simplify useTelegramWebApp as backward-compatible wrapper
- Add automatic CSS variable binding for theme and viewport
This commit is contained in:
c0mrade
2026-02-01 20:13:50 +03:00
parent 55ae55f4af
commit edb5be09ae
14 changed files with 736 additions and 513 deletions

132
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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,
}}
>

View File

@@ -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}

273
src/hooks/useTelegramSDK.ts Normal file
View File

@@ -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<T>(
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<TelegramPlatform>(() => {
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,
};
}

View File

@@ -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);
}
}
}

View File

@@ -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();

View File

@@ -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

View File

@@ -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<TicketDetail | null>(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);
},
};
};

View File

@@ -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

View File

@@ -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<number | null>(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');
}, []);
const isTelegramMiniApp = platform === 'telegram';
const {
data: config,
@@ -206,13 +205,11 @@ 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) => {
onSuccess: async (data) => {
// Use platform's openInvoice if available
if (capabilities.hasInvoice) {
const status = await openInvoice(data.invoice_url);
if (status === 'paid') {
// Mark this as a Stars spin so handleSpinComplete knows to use the pending result
isStarsSpinRef.current = true;
@@ -297,9 +294,8 @@ export default function Wheel() {
} 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({

View File

@@ -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 {

View File

@@ -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({
mainButton.setParams({
text: config.text,
color: config.color,
text_color: config.textColor,
is_active: config.isActive !== false,
is_visible: true,
bgColor: config.color as `#${string}` | undefined,
textColor: config.textColor as `#${string}` | undefined,
isEnabled: config.isActive !== false,
isVisible: true,
isLoaderVisible: config.isLoading || false,
});
} else {
tg.MainButton.text = config.text;
if (config.color) tg.MainButton.color = config.color;
if (config.textColor) tg.MainButton.textColor = config.textColor;
}
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<void> {
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<boolean> {
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<string | null> {
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<string | null> {
return new Promise((resolve, reject) => {
tg.CloudStorage.getItem(key, (error, value) => {
if (error) reject(error);
else resolve(value || null);
});
});
async getItem(key: string): Promise<string | null> {
const value = await cloudStorage.getItem(key);
return value || null;
},
setItem(key: string, value: string): Promise<void> {
return new Promise((resolve, reject) => {
tg.CloudStorage.setItem(key, value, (error) => {
if (error) reject(error);
else resolve();
});
});
async setItem(key: string, value: string): Promise<void> {
await cloudStorage.setItem(key, value);
},
removeItem(key: string): Promise<void> {
return new Promise((resolve, reject) => {
tg.CloudStorage.removeItem(key, (error) => {
if (error) reject(error);
else resolve();
});
});
async removeItem(key: string): Promise<void> {
await cloudStorage.deleteItem(key);
},
getKeys(): Promise<string[]> {
return new Promise((resolve, reject) => {
tg.CloudStorage.getKeys((error, keys) => {
if (error) reject(error);
else resolve(keys);
});
});
async getKeys(): Promise<string[]> {
return cloudStorage.getKeys();
},
};
}