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