mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23: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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user