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", "name": "cabinet-frontend",
"version": "1.5.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -24,6 +24,7 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4", "@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.8.0", "@tanstack/react-query": "^5.8.0",
"@tma.js/sdk-react": "^3.0.15",
"axios": "^1.6.0", "axios": "^1.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -2606,6 +2607,92 @@
"react": "^18 || ^19" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3194,6 +3281,15 @@
"baseline-browser-mapping": "dist/cli.js" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3660,6 +3756,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -4139,6 +4241,12 @@
"node": ">= 6" "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": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -4876,6 +4984,12 @@
"node": "*" "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": { "node_modules/motion-dom": {
"version": "12.29.2", "version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz",
@@ -6164,7 +6278,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "peer": true,
"bin": { "bin": {
@@ -6299,6 +6413,20 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "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-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4", "@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.8.0", "@tanstack/react-query": "^5.8.0",
"@tma.js/sdk-react": "^3.0.15",
"axios": "^1.6.0", "axios": "^1.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -6,6 +6,7 @@ import {
safeRedirectToLogin, safeRedirectToLogin,
} from '../utils/token'; } from '../utils/token';
import { useBlockingStore } from '../store/blocking'; import { useBlockingStore } from '../store/blocking';
import { getTelegramInitData as getSDKInitData } from '../hooks/useTelegramSDK';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
@@ -41,7 +42,8 @@ function ensureCsrfToken(): string {
const getTelegramInitData = (): string | null => { const getTelegramInitData = (): string | null => {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
const initData = window.Telegram?.WebApp?.initData; // Use SDK helper to get initData
const initData = getSDKInitData();
if (initData) { if (initData) {
tokenStorage.setTelegramInitData(initData); tokenStorage.setTelegramInitData(initData);
return initData; return initData;

View File

@@ -45,14 +45,17 @@ import {
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'; const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'; const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
import type { TelegramPlatform } from '@/hooks/useTelegramSDK';
interface AppHeaderProps { interface AppHeaderProps {
mobileMenuOpen: boolean; mobileMenuOpen: boolean;
setMobileMenuOpen: (open: boolean) => void; setMobileMenuOpen: (open: boolean) => void;
onCommandPaletteOpen: () => void; onCommandPaletteOpen: () => void;
headerHeight: number; headerHeight: number;
isFullscreen: boolean; isFullscreen: boolean;
safeAreaInset: { top: number; bottom: number }; safeAreaInset: { top: number; bottom: number; left: number; right: number };
contentSafeAreaInset: { top: number; bottom: number }; contentSafeAreaInset: { top: number; bottom: number; left: number; right: number };
telegramPlatform?: TelegramPlatform;
wheelEnabled?: boolean; wheelEnabled?: boolean;
referralEnabled?: boolean; referralEnabled?: boolean;
hasContests?: boolean; hasContests?: boolean;
@@ -67,6 +70,7 @@ export function AppHeader({
isFullscreen, isFullscreen,
safeAreaInset, safeAreaInset,
contentSafeAreaInset, contentSafeAreaInset,
telegramPlatform,
wheelEnabled, wheelEnabled,
referralEnabled, referralEnabled,
hasContests, 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" className="glass fixed left-0 right-0 top-0 z-50 shadow-lg shadow-black/10 lg:hidden"
style={{ style={{
paddingTop: isFullscreen paddingTop: isFullscreen
? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (telegramPlatform === 'android' ? 0 : 45)}px`
: undefined, : undefined,
}} }}
> >

View File

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/auth'; import { useAuthStore } from '@/store/auth';
import { useBackButton, useHaptic } from '@/platform'; import { useBackButton, useHaptic } from '@/platform';
import { useTelegramWebApp } from '@/hooks/useTelegramWebApp'; import { useTelegramSDK } from '@/hooks/useTelegramSDK';
import { referralApi } from '@/api/referral'; import { referralApi } from '@/api/referral';
import { wheelApi } from '@/api/wheel'; import { wheelApi } from '@/api/wheel';
import { contestsApi } from '@/api/contests'; import { contestsApi } from '@/api/contests';
@@ -18,7 +18,7 @@ import {
preloadLogo, preloadLogo,
isLogoPreloaded, isLogoPreloaded,
} from '@/api/branding'; } from '@/api/branding';
import { setCachedFullscreenEnabled, isTelegramMobile } from '@/hooks/useTelegramWebApp'; import { setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import WebSocketNotifications from '@/components/WebSocketNotifications'; import WebSocketNotifications from '@/components/WebSocketNotifications';
@@ -157,12 +157,19 @@ export function AppShell({ children }: AppShellProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { isAdmin, isAuthenticated, logout } = useAuthStore(); const { isAdmin, isAuthenticated, logout } = useAuthStore();
const { isFullscreen, safeAreaInset, contentSafeAreaInset, requestFullscreen, isTelegramWebApp } = const {
useTelegramWebApp(); isFullscreen,
safeAreaInset,
contentSafeAreaInset,
requestFullscreen,
isTelegramWebApp,
platform,
isMobile,
} = useTelegramSDK();
const haptic = useHaptic(); const haptic = useHaptic();
// Only apply fullscreen UI adjustments on mobile Telegram (iOS/Android) // Only apply fullscreen UI adjustments on mobile Telegram (iOS/Android)
const isMobileFullscreen = isFullscreen && isTelegramMobile(); const isMobileFullscreen = isFullscreen && isMobile;
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false); const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
@@ -256,10 +263,10 @@ export function AppShell({ children }: AppShellProps) {
setCachedFullscreenEnabled(fullscreenSetting.enabled); setCachedFullscreenEnabled(fullscreenSetting.enabled);
// Request fullscreen if enabled, not already fullscreen, and on mobile Telegram // Request fullscreen if enabled, not already fullscreen, and on mobile Telegram
if (fullscreenSetting.enabled && !isFullscreen && isTelegramMobile()) { if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
requestFullscreen(); requestFullscreen();
} }
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen]); }, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
// Feature flags // Feature flags
const { data: referralTerms } = useQuery({ const { data: referralTerms } = useQuery({
@@ -356,8 +363,9 @@ export function AppShell({ children }: AppShellProps) {
}; };
// Calculate header height based on fullscreen mode (only on mobile Telegram) // 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 const headerHeight = isMobileFullscreen
? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 ? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (platform === 'android' ? 0 : 45)
: 64; : 64;
return ( return (
@@ -478,6 +486,7 @@ export function AppShell({ children }: AppShellProps) {
isFullscreen={isMobileFullscreen} isFullscreen={isMobileFullscreen}
safeAreaInset={safeAreaInset} safeAreaInset={safeAreaInset}
contentSafeAreaInset={contentSafeAreaInset} contentSafeAreaInset={contentSafeAreaInset}
telegramPlatform={platform}
wheelEnabled={wheelConfig?.is_enabled} wheelEnabled={wheelConfig?.is_enabled}
referralEnabled={referralTerms?.is_enabled} referralEnabled={referralTerms?.is_enabled}
hasContests={(contestsCount?.count ?? 0) > 0} 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) * @deprecated This hook is deprecated. Use useTelegramSDK instead.
* Fullscreen mode should only be applied on mobile platforms * 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 import { useTelegramSDK } from './useTelegramSDK';
// tdesktop, macos, web, unknown - all are desktop/non-mobile
return webApp.platform === 'ios' || webApp.platform === 'android';
}
// Get cached fullscreen setting // Re-export everything from useTelegramSDK for backward compatibility
export const getCachedFullscreenEnabled = (): boolean => { export {
try { getCachedFullscreenEnabled,
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'; setCachedFullscreenEnabled,
} catch { isInTelegramWebApp,
return false; isTelegramMobile,
} initTelegramSDK as initTelegramWebApp, // Alias for backward compatibility
}; } from './useTelegramSDK';
// Set cached fullscreen setting
export const setCachedFullscreenEnabled = (enabled: boolean) => {
try {
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
} catch {
// localStorage not available
}
};
/** /**
* Hook for Telegram WebApp API integration * @deprecated Use useTelegramSDK instead
* Provides fullscreen mode, safe area insets, and other WebApp features * Hook for Telegram WebApp API integration - backward compatible wrapper
*/ */
export function useTelegramWebApp() { export function useTelegramWebApp() {
// Initialize synchronously to avoid flash/flicker on first render const sdk = useTelegramSDK();
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);
return { return {
isTelegramWebApp, isTelegramWebApp: sdk.isTelegramWebApp,
isFullscreen, isFullscreen: sdk.isFullscreen,
isFullscreenSupported, isFullscreenSupported: sdk.isFullscreenSupported,
safeAreaInset, safeAreaInset: sdk.safeAreaInset,
contentSafeAreaInset, contentSafeAreaInset: sdk.contentSafeAreaInset,
requestFullscreen, requestFullscreen: sdk.requestFullscreen,
exitFullscreen, exitFullscreen: sdk.exitFullscreen,
toggleFullscreen, toggleFullscreen: sdk.toggleFullscreen,
disableVerticalSwipes, disableVerticalSwipes: sdk.disableVerticalSwipes,
enableVerticalSwipes, enableVerticalSwipes: sdk.enableVerticalSwipes,
webApp, // 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 { ToastProvider } from './components/Toast';
import { TooltipProvider } from './components/primitives/Tooltip'; import { TooltipProvider } from './components/primitives/Tooltip';
import { initLogoPreload } from './api/branding'; import { initLogoPreload } from './api/branding';
import { initTelegramWebApp } from './hooks/useTelegramWebApp'; import { initTelegramSDK } from './hooks/useTelegramSDK';
import './i18n'; import './i18n';
import './styles/globals.css'; import './styles/globals.css';
// Initialize Telegram WebApp (expand, disable swipes) // Initialize Telegram SDK (init, viewport mount, CSS vars binding, swipe control)
initTelegramWebApp(); initTelegramSDK();
// Preload logo from cache immediately on page load // Preload logo from cache immediately on page load
initLogoPreload(); initLogoPreload();

View File

@@ -14,6 +14,7 @@ import {
type EmailAuthEnabled, type EmailAuthEnabled,
} from '../api/branding'; } from '../api/branding';
import { getAndClearReturnUrl } from '../utils/token'; import { getAndClearReturnUrl } from '../utils/token';
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton'; import TelegramLoginButton from '../components/TelegramLoginButton';
@@ -111,14 +112,13 @@ export default function Login() {
// Try Telegram WebApp authentication on mount // Try Telegram WebApp authentication on mount
useEffect(() => { useEffect(() => {
const tryTelegramAuth = async () => { const tryTelegramAuth = async () => {
const tg = window.Telegram?.WebApp; const initData = getTelegramInitData();
if (tg?.initData) { if (isInTelegramWebApp() && initData) {
setIsTelegramWebApp(true); setIsTelegramWebApp(true);
tg.ready(); // Note: ready() and expand() are already called by initTelegramSDK in main.tsx
tg.expand();
setIsLoading(true); setIsLoading(true);
try { try {
await loginWithTelegram(tg.initData); await loginWithTelegram(initData);
navigate(getReturnUrl(), { replace: true }); navigate(getReturnUrl(), { replace: true });
} catch (err) { } catch (err) {
// Log only status code to avoid leaking sensitive data // 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 { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import { usePlatform } from '@/platform';
const log = logger.createLogger('Support'); const log = logger.createLogger('Support');
@@ -152,6 +153,7 @@ export default function Support() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useAuthStore(); const { isAdmin } = useAuthStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { openTelegramLink, openLink } = usePlatform();
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null); const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
@@ -314,7 +316,6 @@ export default function Support() {
buttonText: t('support.contactUs'), buttonText: t('support.contactUs'),
buttonAction: () => { buttonAction: () => {
log.debug('Button clicked, opening:', supportUsername); log.debug('Button clicked, opening:', supportUsername);
const webApp = window.Telegram?.WebApp;
// Extract username without @ // Extract username without @
const username = supportUsername.startsWith('@') const username = supportUsername.startsWith('@')
@@ -322,36 +323,10 @@ export default function Support() {
: supportUsername; : supportUsername;
const webUrl = `https://t.me/${username}`; const webUrl = `https://t.me/${username}`;
log.debug('Web URL:', webUrl, 'WebApp methods:', { log.debug('Web URL:', webUrl);
openTelegramLink: !!webApp?.openTelegramLink,
openLink: !!webApp?.openLink,
});
// Try openTelegramLink first (for tg:// links) // Use platform's openTelegramLink
if (webApp?.openTelegramLink) { openTelegramLink(webUrl);
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');
}, },
}; };
} }
@@ -362,12 +337,7 @@ export default function Support() {
message: t('support.useExternalLink'), message: t('support.useExternalLink'),
buttonText: t('support.openSupport'), buttonText: t('support.openSupport'),
buttonAction: () => { buttonAction: () => {
const webApp = window.Telegram?.WebApp; openLink(supportConfig.support_url!, { tryInstantView: false });
if (webApp?.openLink) {
webApp.openLink(supportConfig.support_url!, { try_browser: true });
} else {
window.open(supportConfig.support_url!, '_blank');
}
}, },
}; };
} }
@@ -381,7 +351,6 @@ export default function Support() {
buttonText: t('support.contactUs'), buttonText: t('support.contactUs'),
buttonAction: () => { buttonAction: () => {
log.debug('Fallback button clicked, opening:', supportUsername); log.debug('Fallback button clicked, opening:', supportUsername);
const webApp = window.Telegram?.WebApp;
// Extract username without @ // Extract username without @
const username = supportUsername.startsWith('@') const username = supportUsername.startsWith('@')
@@ -391,16 +360,8 @@ export default function Support() {
const webUrl = `https://t.me/${username}`; const webUrl = `https://t.me/${username}`;
log.debug('Fallback opening URL:', webUrl); log.debug('Fallback opening URL:', webUrl);
if (webApp?.openTelegramLink) { // Use platform's openTelegramLink
log.debug('Fallback using openTelegramLink'); openTelegramLink(webUrl);
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');
}
}, },
}; };
}; };

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { brandingApi } from '../api/branding'; import { brandingApi } from '../api/branding';
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
// Validate redirect URL to prevent open redirect attacks // Validate redirect URL to prevent open redirect attacks
const getSafeRedirectUrl = (url: string | null): string => { const getSafeRedirectUrl = (url: string | null): string => {
@@ -64,33 +65,19 @@ export default function TelegramRedirect() {
const initTelegram = async () => { const initTelegram = async () => {
// Check if running in Telegram WebApp // 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 // Not in Telegram, show message and redirect to login
setStatus('not-telegram'); setStatus('not-telegram');
setTimeout(() => navigate('/login'), 2000); setTimeout(() => navigate('/login'), 2000);
return; return;
} }
// Initialize Telegram WebApp // Note: ready(), expand(), and theme CSS vars are already handled by initTelegramSDK in main.tsx
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',
);
}
try { try {
await loginWithTelegram(tg.initData); await loginWithTelegram(initData);
setStatus('success'); setStatus('success');
// Small delay for nice UX // 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel'; import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel';
import FortuneWheel from '../components/wheel/FortuneWheel'; import FortuneWheel from '../components/wheel/FortuneWheel';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { usePlatform } from '@/platform';
// Pre-calculated confetti positions (stable across re-renders) // Pre-calculated confetti positions (stable across re-renders)
const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({ const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({
@@ -72,6 +73,7 @@ export default function Wheel() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency(); const { formatAmount, currencySymbol } = useCurrency();
const { platform, openInvoice, capabilities } = usePlatform();
const [isSpinning, setIsSpinning] = useState(false); const [isSpinning, setIsSpinning] = useState(false);
const [targetRotation, setTargetRotation] = useState<number | null>(null); const [targetRotation, setTargetRotation] = useState<number | null>(null);
@@ -83,11 +85,8 @@ export default function Wheel() {
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [isPayingStars, setIsPayingStars] = useState(false); const [isPayingStars, setIsPayingStars] = useState(false);
const isTelegramMiniApp = useMemo(() => { // Check if we're in Telegram Mini App environment
// Check if we're in Telegram Mini App environment const isTelegramMiniApp = platform === 'telegram';
const webApp = window.Telegram?.WebApp;
return !!(webApp && typeof webApp.initData === 'string');
}, []);
const { const {
data: config, data: config,
@@ -206,64 +205,43 @@ export default function Wheel() {
const starsInvoiceMutation = useMutation({ const starsInvoiceMutation = useMutation({
mutationFn: wheelApi.createStarsInvoice, mutationFn: wheelApi.createStarsInvoice,
onSuccess: (data) => { onSuccess: async (data) => {
const webApp = window.Telegram?.WebApp; // Use platform's openInvoice if available
// openInvoice requires WebApp version 6.1+ if (capabilities.hasInvoice) {
const supportsInvoice = const status = await openInvoice(data.invoice_url);
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;
// Cancel any existing polling if (status === 'paid') {
if (pollingAbortRef.current) { // Mark this as a Stars spin so handleSpinComplete knows to use the pending result
pollingAbortRef.current.abort(); isStarsSpinRef.current = true;
} pendingStarsResultRef.current = null;
pollingAbortRef.current = new AbortController();
// Payment done - reset paying state immediately // Cancel any existing polling
setIsPayingStars(false); if (pollingAbortRef.current) {
pollingAbortRef.current.abort();
}
pollingAbortRef.current = new AbortController();
// Start spinning animation (5 seconds duration in FortuneWheel) // Payment done - reset paying state immediately
setIsSpinning(true); setIsPayingStars(false);
setTargetRotation(360 * 5 + Math.random() * 360);
// Poll for the result in the background - don't await here! // Start spinning animation (5 seconds duration in FortuneWheel)
// The result will be stored and shown when animation completes setIsSpinning(true);
const abortSignal = pollingAbortRef.current.signal; setTargetRotation(360 * 5 + Math.random() * 360);
pollForSpinResult(abortSignal)
.then((result) => {
if (abortSignal.aborted) return;
queryClient.invalidateQueries({ queryKey: ['wheel-config'] }); // Poll for the result in the background - don't await here!
queryClient.invalidateQueries({ queryKey: ['wheel-history'] }); // 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) { queryClient.invalidateQueries({ queryKey: ['wheel-config'] });
pendingStarsResultRef.current = result; queryClient.invalidateQueries({ queryKey: ['wheel-history'] });
} 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;
// Error polling, show generic success if (result) {
pendingStarsResultRef.current = result;
} else {
// Fallback: couldn't get result
pendingStarsResultRef.current = { pendingStarsResultRef.current = {
success: true, success: true,
prize_id: null, prize_id: null,
@@ -277,29 +255,47 @@ export default function Wheel() {
promocode: null, promocode: null,
error: null, error: null,
}; };
}); }
} else if (status !== 'cancelled') { })
setIsPayingStars(false); .catch(() => {
setSpinResult({ if (abortSignal.aborted) return;
success: false,
prize_id: null, // Error polling, show generic success
prize_type: null, pendingStarsResultRef.current = {
prize_value: 0, success: true,
prize_display_name: '', prize_id: null,
emoji: '😔', prize_type: null,
color: '#EF4444', prize_value: 0,
rotation_degrees: 0, prize_display_name: '',
message: t('wheel.starsPaymentFailed'), emoji: '🎰',
promocode: null, color: '#8B5CF6',
error: 'payment_failed', rotation_degrees: 0,
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
};
}); });
setShowResultModal(true); } else if (status !== 'cancelled') {
} else { setIsPayingStars(false);
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 { } else {
// Fallback: open invoice URL in Telegram (browser or unsupported WebApp version) // Fallback: open invoice URL in browser (unsupported platform)
setIsPayingStars(false); setIsPayingStars(false);
window.open(data.invoice_url, '_blank', 'noopener,noreferrer'); window.open(data.invoice_url, '_blank', 'noopener,noreferrer');
setSpinResult({ setSpinResult({

View File

@@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from 'react';
import { PlatformContext } from '@/platform/PlatformContext'; import { PlatformContext } from '@/platform/PlatformContext';
import { createTelegramAdapter } from '@/platform/adapters/TelegramAdapter'; import { createTelegramAdapter } from '@/platform/adapters/TelegramAdapter';
import { createWebAdapter } from '@/platform/adapters/WebAdapter'; import { createWebAdapter } from '@/platform/adapters/WebAdapter';
import { isInTelegramWebApp } from '@/hooks/useTelegramSDK';
import type { PlatformContext as PlatformContextType } from '@/platform/types'; import type { PlatformContext as PlatformContextType } from '@/platform/types';
interface PlatformProviderProps { interface PlatformProviderProps {
@@ -9,16 +10,7 @@ interface PlatformProviderProps {
} }
function detectPlatform(): 'telegram' | 'web' { function detectPlatform(): 'telegram' | 'web' {
// Check if Telegram WebApp is available and actually initialized with data return isInTelegramWebApp() ? 'telegram' : 'web';
// 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';
} }
function createAdapter(): PlatformContextType { 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 { import type {
PlatformContext, PlatformContext,
PlatformCapabilities, PlatformCapabilities,
@@ -14,186 +24,201 @@ import type {
HapticNotificationType, HapticNotificationType,
} from '@/platform/types'; } from '@/platform/types';
// Keep reference to raw Telegram WebApp for features not in SDK
function getTelegram(): TelegramWebApp | null { function getTelegram(): TelegramWebApp | null {
return window.Telegram?.WebApp ?? 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 { function createCapabilities(): PlatformCapabilities {
const tg = getTelegram(); const tg = getTelegram();
const inTelegram = isInTelegramWebApp();
return { return {
hasBackButton: !!tg?.BackButton, hasBackButton: inTelegram && backButton.isSupported(),
hasMainButton: !!tg?.MainButton, hasMainButton: inTelegram,
hasHapticFeedback: isVersionAtLeast('6.1') && !!tg?.HapticFeedback, hasHapticFeedback: inTelegram && hapticFeedback.isSupported(),
hasNativeDialogs: isVersionAtLeast('6.2'), hasNativeDialogs: inTelegram && popup.isSupported(),
hasThemeSync: isVersionAtLeast('6.1'), hasThemeSync: inTelegram,
hasInvoice: !!tg?.openInvoice, hasInvoice: !!tg?.openInvoice,
hasCloudStorage: isVersionAtLeast('6.9') && !!tg?.CloudStorage, hasCloudStorage: inTelegram && cloudStorage.isSupported(),
hasShare: true, // openTelegramLink is always available hasShare: true,
version: tg?.version, version: tg?.version,
}; };
} }
function createBackButtonController(): BackButtonController { function createBackButtonController(): BackButtonController {
const tg = getTelegram(); const inTelegram = isInTelegramWebApp();
let currentCallback: (() => void) | null = null; 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 { return {
get isVisible() { get isVisible() {
return tg?.BackButton?.isVisible ?? false; if (!inTelegram) return false;
return backButton.isVisible();
}, },
show(onClick: () => void) { show(onClick: () => void) {
if (!tg?.BackButton) return; if (!inTelegram || !backButton.isSupported()) return;
ensureMounted();
// Remove previous callback if exists // Remove previous callback if exists
if (currentCallback) { if (removeClickListener) {
tg.BackButton.offClick(currentCallback); removeClickListener();
removeClickListener = null;
} }
currentCallback = onClick; removeClickListener = backButton.onClick(onClick);
tg.BackButton.onClick(onClick); backButton.show();
tg.BackButton.show();
}, },
hide() { hide() {
if (!tg?.BackButton) return; if (!inTelegram || !backButton.isSupported()) return;
if (currentCallback) { if (removeClickListener) {
tg.BackButton.offClick(currentCallback); removeClickListener();
currentCallback = null; removeClickListener = null;
} }
tg.BackButton.hide(); backButton.hide();
}, },
}; };
} }
function createMainButtonController(): MainButtonController { function createMainButtonController(): MainButtonController {
const tg = getTelegram(); const inTelegram = isInTelegramWebApp();
let currentCallback: (() => void) | null = null; 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 { return {
get isVisible() { get isVisible() {
return tg?.MainButton?.isVisible ?? false; if (!inTelegram) return false;
return mainButton.isVisible();
}, },
show(config: MainButtonConfig) { show(config: MainButtonConfig) {
if (!tg?.MainButton) return; if (!inTelegram) return;
ensureMounted();
// Remove previous callback if exists // Remove previous callback if exists
if (currentCallback) { if (removeClickListener) {
tg.MainButton.offClick(currentCallback); removeClickListener();
removeClickListener = null;
} }
currentCallback = config.onClick;
// Set button parameters // Set button parameters
if (tg.MainButton.setParams) { mainButton.setParams({
tg.MainButton.setParams({ text: config.text,
text: config.text, bgColor: config.color as `#${string}` | undefined,
color: config.color, textColor: config.textColor as `#${string}` | undefined,
text_color: config.textColor, isEnabled: config.isActive !== false,
is_active: config.isActive !== false, isVisible: true,
is_visible: 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); removeClickListener = mainButton.onClick(config.onClick);
mainButton.show();
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();
}, },
hide() { hide() {
if (!tg?.MainButton) return; if (!inTelegram) return;
if (currentCallback) { if (removeClickListener) {
tg.MainButton.offClick(currentCallback); removeClickListener();
currentCallback = null; removeClickListener = null;
} }
tg.MainButton.hideProgress?.(); mainButton.hideLoader();
tg.MainButton.hide(); mainButton.hide();
}, },
showProgress(show: boolean) { showProgress(show: boolean) {
if (!tg?.MainButton) return; if (!inTelegram) return;
if (show) { if (show) {
tg.MainButton.showProgress?.(true); mainButton.showLoader();
} else { } else {
tg.MainButton.hideProgress?.(); mainButton.hideLoader();
} }
}, },
setText(text: string) { setText(text: string) {
if (!tg?.MainButton) return; if (!inTelegram) return;
mainButton.setText(text);
if (tg.MainButton.setText) {
tg.MainButton.setText(text);
} else {
tg.MainButton.text = text;
}
}, },
setActive(active: boolean) { setActive(active: boolean) {
if (!tg?.MainButton) return; if (!inTelegram) return;
if (active) { if (active) {
tg.MainButton.enable(); mainButton.enable();
} else { } else {
tg.MainButton.disable(); mainButton.disable();
} }
}, },
}; };
} }
function createHapticController(): HapticController { function createHapticController(): HapticController {
const tg = getTelegram(); const inTelegram = isInTelegramWebApp();
const isSupported = inTelegram && hapticFeedback.isSupported();
return { return {
impact(style: HapticImpactStyle = 'medium') { impact(style: HapticImpactStyle = 'medium') {
tg?.HapticFeedback?.impactOccurred(style); if (!isSupported) return;
hapticFeedback.impactOccurred(style);
}, },
notification(type: HapticNotificationType) { notification(type: HapticNotificationType) {
tg?.HapticFeedback?.notificationOccurred(type); if (!isSupported) return;
hapticFeedback.notificationOccurred(type);
}, },
selection() { selection() {
tg?.HapticFeedback?.selectionChanged(); if (!isSupported) return;
hapticFeedback.selectionChanged();
}, },
}; };
} }
function createDialogController(): DialogController { function createDialogController(): DialogController {
const tg = getTelegram(); const inTelegram = isInTelegramWebApp();
const isSupported = inTelegram && popup.isSupported();
return { return {
alert(message: string, _title?: string): Promise<void> { alert(message: string, _title?: string): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (tg?.showAlert) { if (isSupported) {
tg.showAlert(message, () => resolve()); popup
.show({
message,
buttons: [{ type: 'ok' }],
})
.then(() => resolve());
} else { } else {
window.alert(message); window.alert(message);
resolve(); resolve();
@@ -203,8 +228,16 @@ function createDialogController(): DialogController {
confirm(message: string, _title?: string): Promise<boolean> { confirm(message: string, _title?: string): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (tg?.showConfirm) { if (isSupported) {
tg.showConfirm(message, (confirmed) => resolve(confirmed)); popup
.show({
message,
buttons: [
{ id: 'ok', type: 'ok' },
{ id: 'cancel', type: 'cancel' },
],
})
.then((buttonId) => resolve(buttonId === 'ok'));
} else { } else {
resolve(window.confirm(message)); resolve(window.confirm(message));
} }
@@ -213,9 +246,9 @@ function createDialogController(): DialogController {
popup(options: PopupOptions): Promise<string | null> { popup(options: PopupOptions): Promise<string | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (tg?.showPopup) { if (isSupported) {
tg.showPopup( popup
{ .show({
title: options.title, title: options.title,
message: options.message, message: options.message,
buttons: options.buttons?.map((btn) => ({ buttons: options.buttons?.map((btn) => ({
@@ -223,11 +256,9 @@ function createDialogController(): DialogController {
type: btn.type, type: btn.type,
text: btn.text, text: btn.text,
})), })),
}, })
(buttonId) => resolve(buttonId), .then((buttonId) => resolve(buttonId ?? null));
);
} else { } else {
// Fallback to confirm for web
const confirmed = window.confirm(options.message); const confirmed = window.confirm(options.message);
resolve(confirmed ? 'ok' : null); resolve(confirmed ? 'ok' : null);
} }
@@ -237,62 +268,81 @@ function createDialogController(): DialogController {
} }
function createThemeController(): ThemeController { 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 { return {
setHeaderColor(color: string) { setHeaderColor(color: string) {
tg?.setHeaderColor?.(color); if (!inTelegram) return;
miniApp.setHeaderColor(color as `#${string}`);
}, },
setBottomBarColor(color: string) { setBottomBarColor(color: string) {
tg?.setBottomBarColor?.(color); if (!inTelegram) return;
try {
miniApp.setBottomBarColor(color as `#${string}`);
} catch {
// Not supported in this version
}
}, },
getThemeParams() { 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 { function createCloudStorageController(): CloudStorageController | null {
const tg = getTelegram(); const inTelegram = isInTelegramWebApp();
if (!tg?.CloudStorage) return null; if (!inTelegram || !cloudStorage.isSupported()) return null;
return { return {
getItem(key: string): Promise<string | null> { async getItem(key: string): Promise<string | null> {
return new Promise((resolve, reject) => { const value = await cloudStorage.getItem(key);
tg.CloudStorage.getItem(key, (error, value) => { return value || null;
if (error) reject(error);
else resolve(value || null);
});
});
}, },
setItem(key: string, value: string): Promise<void> { async setItem(key: string, value: string): Promise<void> {
return new Promise((resolve, reject) => { await cloudStorage.setItem(key, value);
tg.CloudStorage.setItem(key, value, (error) => {
if (error) reject(error);
else resolve();
});
});
}, },
removeItem(key: string): Promise<void> { async removeItem(key: string): Promise<void> {
return new Promise((resolve, reject) => { await cloudStorage.deleteItem(key);
tg.CloudStorage.removeItem(key, (error) => {
if (error) reject(error);
else resolve();
});
});
}, },
getKeys(): Promise<string[]> { async getKeys(): Promise<string[]> {
return new Promise((resolve, reject) => { return cloudStorage.getKeys();
tg.CloudStorage.getKeys((error, keys) => {
if (error) reject(error);
else resolve(keys);
});
});
}, },
}; };
} }