mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
132
package-lock.json
generated
132
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
273
src/hooks/useTelegramSDK.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 webApp = window.Telegram?.WebApp;
|
const isTelegramMiniApp = platform === 'telegram';
|
||||||
return !!(webApp && typeof webApp.initData === 'string');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: config,
|
data: config,
|
||||||
@@ -206,13 +205,11 @@ 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') {
|
if (status === 'paid') {
|
||||||
// Mark this as a Stars spin so handleSpinComplete knows to use the pending result
|
// Mark this as a Stars spin so handleSpinComplete knows to use the pending result
|
||||||
isStarsSpinRef.current = true;
|
isStarsSpinRef.current = true;
|
||||||
@@ -297,9 +294,8 @@ export default function Wheel() {
|
|||||||
} else {
|
} else {
|
||||||
setIsPayingStars(false);
|
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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
color: config.color,
|
bgColor: config.color as `#${string}` | undefined,
|
||||||
text_color: config.textColor,
|
textColor: config.textColor as `#${string}` | undefined,
|
||||||
is_active: config.isActive !== false,
|
isEnabled: config.isActive !== false,
|
||||||
is_visible: true,
|
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);
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user