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

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

View File

@@ -14,6 +14,7 @@ import {
type EmailAuthEnabled,
} from '../api/branding';
import { getAndClearReturnUrl } from '../utils/token';
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton';
@@ -111,14 +112,13 @@ export default function Login() {
// Try Telegram WebApp authentication on mount
useEffect(() => {
const tryTelegramAuth = async () => {
const tg = window.Telegram?.WebApp;
if (tg?.initData) {
const initData = getTelegramInitData();
if (isInTelegramWebApp() && initData) {
setIsTelegramWebApp(true);
tg.ready();
tg.expand();
// Note: ready() and expand() are already called by initTelegramSDK in main.tsx
setIsLoading(true);
try {
await loginWithTelegram(tg.initData);
await loginWithTelegram(initData);
navigate(getReturnUrl(), { replace: true });
} catch (err) {
// Log only status code to avoid leaking sensitive data

View File

@@ -11,6 +11,7 @@ import type { TicketDetail, TicketMessage } from '../types';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import { usePlatform } from '@/platform';
const log = logger.createLogger('Support');
@@ -152,6 +153,7 @@ export default function Support() {
const { t } = useTranslation();
const { isAdmin } = useAuthStore();
const queryClient = useQueryClient();
const { openTelegramLink, openLink } = usePlatform();
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newTitle, setNewTitle] = useState('');
@@ -314,7 +316,6 @@ export default function Support() {
buttonText: t('support.contactUs'),
buttonAction: () => {
log.debug('Button clicked, opening:', supportUsername);
const webApp = window.Telegram?.WebApp;
// Extract username without @
const username = supportUsername.startsWith('@')
@@ -322,36 +323,10 @@ export default function Support() {
: supportUsername;
const webUrl = `https://t.me/${username}`;
log.debug('Web URL:', webUrl, 'WebApp methods:', {
openTelegramLink: !!webApp?.openTelegramLink,
openLink: !!webApp?.openLink,
});
log.debug('Web URL:', webUrl);
// Try openTelegramLink first (for tg:// links)
if (webApp?.openTelegramLink) {
log.debug('Using openTelegramLink with web URL');
try {
webApp.openTelegramLink(webUrl);
return;
} catch (e) {
log.error('openTelegramLink failed:', e);
}
}
// Fallback to openLink
if (webApp?.openLink) {
log.debug('Using openLink');
try {
webApp.openLink(webUrl, { try_browser: true });
return;
} catch (e) {
log.error('openLink failed:', e);
}
}
// Last resort - window.open
log.debug('Using window.open');
window.open(webUrl, '_blank');
// Use platform's openTelegramLink
openTelegramLink(webUrl);
},
};
}
@@ -362,12 +337,7 @@ export default function Support() {
message: t('support.useExternalLink'),
buttonText: t('support.openSupport'),
buttonAction: () => {
const webApp = window.Telegram?.WebApp;
if (webApp?.openLink) {
webApp.openLink(supportConfig.support_url!, { try_browser: true });
} else {
window.open(supportConfig.support_url!, '_blank');
}
openLink(supportConfig.support_url!, { tryInstantView: false });
},
};
}
@@ -381,7 +351,6 @@ export default function Support() {
buttonText: t('support.contactUs'),
buttonAction: () => {
log.debug('Fallback button clicked, opening:', supportUsername);
const webApp = window.Telegram?.WebApp;
// Extract username without @
const username = supportUsername.startsWith('@')
@@ -391,16 +360,8 @@ export default function Support() {
const webUrl = `https://t.me/${username}`;
log.debug('Fallback opening URL:', webUrl);
if (webApp?.openTelegramLink) {
log.debug('Fallback using openTelegramLink');
webApp.openTelegramLink(webUrl);
} else if (webApp?.openLink) {
log.debug('Fallback using openLink');
webApp.openLink(webUrl, { try_browser: true });
} else {
log.debug('Fallback using window.open');
window.open(webUrl, '_blank');
}
// Use platform's openTelegramLink
openTelegramLink(webUrl);
},
};
};

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../store/auth';
import { brandingApi } from '../api/branding';
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
// Validate redirect URL to prevent open redirect attacks
const getSafeRedirectUrl = (url: string | null): string => {
@@ -64,33 +65,19 @@ export default function TelegramRedirect() {
const initTelegram = async () => {
// Check if running in Telegram WebApp
const tg = window.Telegram?.WebApp;
const initData = getTelegramInitData();
if (!tg?.initData) {
if (!isInTelegramWebApp() || !initData) {
// Not in Telegram, show message and redirect to login
setStatus('not-telegram');
setTimeout(() => navigate('/login'), 2000);
return;
}
// Initialize Telegram WebApp
tg.ready();
tg.expand();
// Set theme colors if available
if (tg.themeParams) {
document.documentElement.style.setProperty(
'--tg-theme-bg-color',
tg.themeParams.bg_color || '#1a1a2e',
);
document.documentElement.style.setProperty(
'--tg-theme-text-color',
tg.themeParams.text_color || '#ffffff',
);
}
// Note: ready(), expand(), and theme CSS vars are already handled by initTelegramSDK in main.tsx
try {
await loginWithTelegram(tg.initData);
await loginWithTelegram(initData);
setStatus('success');
// Small delay for nice UX

View File

@@ -1,10 +1,11 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel';
import FortuneWheel from '../components/wheel/FortuneWheel';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { useCurrency } from '../hooks/useCurrency';
import { usePlatform } from '@/platform';
// Pre-calculated confetti positions (stable across re-renders)
const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({
@@ -72,6 +73,7 @@ export default function Wheel() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency();
const { platform, openInvoice, capabilities } = usePlatform();
const [isSpinning, setIsSpinning] = useState(false);
const [targetRotation, setTargetRotation] = useState<number | null>(null);
@@ -83,11 +85,8 @@ export default function Wheel() {
const [showHistory, setShowHistory] = useState(false);
const [isPayingStars, setIsPayingStars] = useState(false);
const isTelegramMiniApp = useMemo(() => {
// Check if we're in Telegram Mini App environment
const webApp = window.Telegram?.WebApp;
return !!(webApp && typeof webApp.initData === 'string');
}, []);
// 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({