Add Telegram WebApp support: initialize app, enhance layout with fullscreen toggle, and improve polling mechanism in Wheel component

This commit is contained in:
PEDZEO
2026-01-20 00:13:11 +03:00
parent c96ec5ecf6
commit 8753000a03
6 changed files with 219 additions and 16 deletions

View File

@@ -14,6 +14,7 @@ import { wheelApi } from '../../api/wheel'
import { themeColorsApi } from '../../api/themeColors'
import { promoApi } from '../../api/promo'
import { useTheme } from '../../hooks/useTheme'
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
// Fallback branding from environment variables
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
@@ -122,6 +123,18 @@ const WheelIcon = () => (
</svg>
)
const FullscreenIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)
const ExitFullscreenIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
</svg>
)
export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation()
const location = useLocation()
@@ -129,6 +142,7 @@ export default function Layout({ children }: LayoutProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const { toggleTheme, isDark } = useTheme()
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen } = useTelegramWebApp()
// Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({
@@ -352,6 +366,20 @@ export default function Layout({ children }: LayoutProps) {
{/* Right side */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Fullscreen toggle - only show in Telegram WebApp */}
{isTelegramWebApp && isFullscreenSupported && (
<button
onClick={toggleFullscreen}
className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</button>
)}
{/* Theme toggle button - only show if both themes are enabled */}
{canToggle && (
<button

View File

@@ -0,0 +1,113 @@
import { useEffect, useState, useCallback } from 'react'
/**
* Hook for Telegram WebApp API integration
* Provides fullscreen mode, safe area insets, and other WebApp features
*/
export function useTelegramWebApp() {
const [isFullscreen, setIsFullscreen] = useState(false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
const [safeAreaInset, setSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 })
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined
useEffect(() => {
if (!webApp) {
setIsTelegramWebApp(false)
return
}
setIsTelegramWebApp(true)
setIsFullscreen(webApp.isFullscreen || false)
setSafeAreaInset(webApp.safeAreaInset || { 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 })
}
webApp.onEvent('fullscreenChanged', handleFullscreenChanged)
return () => {
webApp.offEvent('fullscreenChanged', handleFullscreenChanged)
}
}, [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(() => {
if (webApp?.disableVerticalSwipes) {
webApp.disableVerticalSwipes()
}
}, [webApp])
const enableVerticalSwipes = useCallback(() => {
if (webApp?.enableVerticalSwipes) {
webApp.enableVerticalSwipes()
}
}, [webApp])
// Check if fullscreen is supported (Bot API 8.0+)
const isFullscreenSupported = Boolean(webApp?.requestFullscreen)
return {
isTelegramWebApp,
isFullscreen,
isFullscreenSupported,
safeAreaInset,
requestFullscreen,
exitFullscreen,
toggleFullscreen,
disableVerticalSwipes,
enableVerticalSwipes,
webApp,
}
}
/**
* Initialize Telegram WebApp on app start
* Call this in main.tsx or App.tsx
*/
export function initTelegramWebApp() {
const webApp = window.Telegram?.WebApp
if (webApp) {
webApp.ready()
webApp.expand()
// Disable vertical swipes to prevent accidental closing
if (webApp.disableVerticalSwipes) {
webApp.disableVerticalSwipes()
}
}
}

View File

@@ -6,9 +6,13 @@ import App from './App'
import { ThemeColorsProvider } from './providers/ThemeColorsProvider'
import { ToastProvider } from './components/Toast'
import { initLogoPreload } from './api/branding'
import { initTelegramWebApp } from './hooks/useTelegramWebApp'
import './i18n'
import './styles/globals.css'
// Initialize Telegram WebApp (expand, disable swipes)
initTelegramWebApp()
// Preload logo from cache immediately on page load
initLogoPreload()

View File

@@ -6,6 +6,15 @@ import FortuneWheel from '../components/wheel/FortuneWheel'
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'
import { useCurrency } from '../hooks/useCurrency'
// Pre-calculated confetti positions (stable across re-renders)
const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({
color: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
left: `${(i * 17 + 5) % 95}%`,
top: `${(i * 23 + 3) % 90}%`,
delay: `${(i * 0.1) % 2}s`,
duration: `${1 + (i % 3) * 0.3}s`,
}))
// Icons
const StarIcon = () => (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
@@ -98,10 +107,12 @@ export default function Wheel() {
}, [config, isTelegramMiniApp])
// Function to poll for new spin result after Stars payment
const pollForSpinResult = useCallback(async (maxAttempts = 15, delayMs = 800) => {
const pollForSpinResult = useCallback(async (signal: AbortSignal, maxAttempts = 15, delayMs = 800) => {
// Wait a bit before first poll to give the bot time to process the payment
await new Promise(resolve => setTimeout(resolve, 1500))
if (signal.aborted) return null
// Get current history to find the latest spin ID
let historyBefore
try {
@@ -112,8 +123,12 @@ export default function Wheel() {
const lastSpinIdBefore = historyBefore.items.length > 0 ? historyBefore.items[0].id : 0
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (signal.aborted) return null
await new Promise(resolve => setTimeout(resolve, delayMs))
if (signal.aborted) return null
try {
const historyAfter = await wheelApi.getHistory(1, 1)
@@ -152,6 +167,16 @@ export default function Wheel() {
// Ref to store pending Stars payment result
const pendingStarsResultRef = useRef<SpinResult | null>(null)
const isStarsSpinRef = useRef(false)
const pollingAbortRef = useRef<AbortController | null>(null)
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollingAbortRef.current) {
pollingAbortRef.current.abort()
}
}
}, [])
const starsInvoiceMutation = useMutation({
mutationFn: wheelApi.createStarsInvoice,
@@ -163,6 +188,12 @@ export default function Wheel() {
isStarsSpinRef.current = true
pendingStarsResultRef.current = null
// Cancel any existing polling
if (pollingAbortRef.current) {
pollingAbortRef.current.abort()
}
pollingAbortRef.current = new AbortController()
// Payment done - reset paying state immediately
setIsPayingStars(false)
@@ -172,7 +203,10 @@ export default function Wheel() {
// Poll for the result in the background - don't await here!
// The result will be stored and shown when animation completes
pollForSpinResult().then((result) => {
const abortSignal = pollingAbortRef.current.signal
pollForSpinResult(abortSignal).then((result) => {
if (abortSignal.aborted) return
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
@@ -195,6 +229,8 @@ export default function Wheel() {
}
}
}).catch(() => {
if (abortSignal.aborted) return
// Error polling, show generic success
pendingStarsResultRef.current = {
success: true,
@@ -551,7 +587,7 @@ export default function Wheel() {
}`}
style={{
backgroundSize: '200% 100%',
animation: !isSpinning && config.can_spin ? 'shimmer 3s linear infinite' : 'none',
animation: !isSpinning && config.can_spin ? 'wheel-shimmer 3s linear infinite' : 'none',
}}
>
{/* Button glow effect */}
@@ -669,18 +705,18 @@ export default function Wheel() {
<>
<div className="absolute top-0 left-0 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full blur-3xl" />
{/* Confetti effect */}
{/* Confetti effect - using pre-calculated positions */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{Array.from({ length: 20 }).map((_, i) => (
{CONFETTI_POSITIONS.map((pos, i) => (
<div
key={i}
className="absolute w-2 h-2 rounded-full animate-bounce"
style={{
background: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 2}s`,
animationDuration: `${1 + Math.random()}s`,
background: pos.color,
left: pos.left,
top: pos.top,
animationDelay: pos.delay,
animationDuration: pos.duration,
}}
/>
))}
@@ -747,12 +783,6 @@ export default function Wheel() {
</div>
)}
<style>{`
@keyframes shimmer {
0% { background-position: 200% 50%; }
100% { background-position: -200% 50%; }
}
`}</style>
</div>
)
}

View File

@@ -1003,6 +1003,11 @@
}
/* Fortune Wheel animations */
@keyframes wheel-shimmer {
0% { background-position: 200% 50%; }
100% { background-position: -200% 50%; }
}
@keyframes wheel-glow {
0%, 100% { filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.3)); }
50% { filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.6)); }

23
src/vite-env.d.ts vendored
View File

@@ -25,12 +25,35 @@ interface TelegramWebApp {
auth_date: number
hash: string
}
version: string
platform: string
isExpanded: boolean
isClosingConfirmationEnabled: boolean
isVerticalSwipesEnabled: boolean
isFullscreen: boolean
isOrientationLocked: boolean
safeAreaInset: { top: number; bottom: number; left: number; right: number }
contentSafeAreaInset: { top: number; bottom: number; left: number; right: number }
ready: () => void
expand: () => void
close: () => void
openLink: (url: string, options?: { try_instant_view?: boolean; try_browser?: boolean }) => void
openTelegramLink: (url: string) => void
openInvoice: (url: string, callback?: (status: 'paid' | 'cancelled' | 'failed' | 'pending') => void) => void
// Fullscreen API (Bot API 8.0+)
requestFullscreen: () => void
exitFullscreen: () => void
lockOrientation: () => void
unlockOrientation: () => void
// Vertical swipes control
disableVerticalSwipes: () => void
enableVerticalSwipes: () => void
// Closing confirmation
enableClosingConfirmation: () => void
disableClosingConfirmation: () => void
// Event handlers
onEvent: (eventType: string, callback: () => void) => void
offEvent: (eventType: string, callback: () => void) => void
MainButton: {
text: string
color: string