mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
Add Telegram WebApp support: initialize app, enhance layout with fullscreen toggle, and improve polling mechanism in Wheel component
This commit is contained in:
@@ -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
|
||||
|
||||
113
src/hooks/useTelegramWebApp.ts
Normal file
113
src/hooks/useTelegramWebApp.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
23
src/vite-env.d.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user