import { useState, useCallback, useMemo, 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'; // 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 = () => ( ); const CalendarIcon = () => ( ); const HistoryIcon = () => ( ); const CloseIcon = () => ( ); const SparklesIcon = () => ( ); const TrophyIcon = () => ( ); export default function Wheel() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { formatAmount, currencySymbol } = useCurrency(); const [isSpinning, setIsSpinning] = useState(false); const [targetRotation, setTargetRotation] = useState(null); const [spinResult, setSpinResult] = useState(null); const [showResultModal, setShowResultModal] = useState(false); const [paymentType, setPaymentType] = useState<'telegram_stars' | 'subscription_days'>( 'telegram_stars', ); 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'); }, []); const { data: config, isLoading, error, } = useQuery({ queryKey: ['wheel-config'], queryFn: wheelApi.getConfig, }); const { data: history } = useQuery({ queryKey: ['wheel-history'], queryFn: () => wheelApi.getHistory(1, 10), enabled: showHistory, }); // Auto-select payment type based on availability useEffect(() => { if (!config) return; const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars; const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days; const canPayBalance = starsEnabled && config.can_pay_stars; const canPayDays = daysEnabled && config.can_pay_days; if (isTelegramMiniApp) { // In Mini App: prefer days if available, Stars payment is separate button if (canPayDays) { setPaymentType('subscription_days'); } } else { // In Web: prefer balance (Stars converted to rubles), fallback to days if (canPayBalance) { setPaymentType('telegram_stars'); } else if (canPayDays) { setPaymentType('subscription_days'); } } }, [config, isTelegramMiniApp]); // Function to poll for new spin result after Stars payment 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 { historyBefore = await wheelApi.getHistory(1, 1); } catch { historyBefore = { items: [], total: 0 }; } 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); // Check if we have a new spin (either new item or higher ID) if (historyAfter.items.length > 0) { const latestSpin = historyAfter.items[0]; // If we had no spins before, or this spin has a higher ID if (lastSpinIdBefore === 0 || latestSpin.id > lastSpinIdBefore) { // Found a new spin! Return it as SpinResult return { success: true, prize_id: latestSpin.id, prize_type: latestSpin.prize_type, prize_value: latestSpin.prize_value, prize_display_name: latestSpin.prize_display_name, emoji: latestSpin.emoji, color: latestSpin.color, rotation_degrees: 0, // Not needed for result display message: latestSpin.prize_type === 'nothing' ? t('wheel.noPrize') : `${t('wheel.youWon')} ${latestSpin.prize_display_name}!`, promocode: null, // Promocode is sent to bot chat error: null, } as SpinResult; } } } catch { // Continue polling on error } } // Timeout - couldn't find new spin return null; }, [t], ); // Ref to store pending Stars payment result const pendingStarsResultRef = useRef(null); const isStarsSpinRef = useRef(false); const pollingAbortRef = useRef(null); // Cleanup polling on unmount useEffect(() => { return () => { if (pollingAbortRef.current) { pollingAbortRef.current.abort(); } }; }, []); const starsInvoiceMutation = useMutation({ mutationFn: wheelApi.createStarsInvoice, onSuccess: (data) => { if (window.Telegram?.WebApp?.openInvoice) { window.Telegram.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; // Cancel any existing polling if (pollingAbortRef.current) { pollingAbortRef.current.abort(); } pollingAbortRef.current = new AbortController(); // Payment done - reset paying state immediately setIsPayingStars(false); // Start spinning animation (5 seconds duration in FortuneWheel) setIsSpinning(true); setTargetRotation(360 * 5 + Math.random() * 360); // 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; queryClient.invalidateQueries({ queryKey: ['wheel-config'] }); queryClient.invalidateQueries({ queryKey: ['wheel-history'] }); 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; // 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, }; }); } 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 { // openInvoice not available - show error 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.errors.starsNotAvailable'), promocode: null, error: 'stars_not_available', }); setShowResultModal(true); } }, onError: () => { 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.errors.networkError'), promocode: null, error: 'network_error', }); setShowResultModal(true); }, }); const handleDirectStarsPay = () => { setIsPayingStars(true); starsInvoiceMutation.mutate(); }; const spinMutation = useMutation({ mutationFn: () => wheelApi.spin(paymentType), onSuccess: (result) => { if (result.success) { setTargetRotation(result.rotation_degrees); setSpinResult(result); } else { setIsSpinning(false); setSpinResult(result); setShowResultModal(true); } }, onError: () => { setIsSpinning(false); setSpinResult({ success: false, message: t('wheel.errors.networkError'), error: 'network_error', prize_id: null, prize_type: null, prize_value: 0, prize_display_name: '', emoji: '', color: '', rotation_degrees: 0, promocode: null, }); setShowResultModal(true); }, }); const handleSpin = () => { if (!config?.can_spin || isSpinning) return; setIsSpinning(true); spinMutation.mutate(); }; const handleSpinComplete = useCallback(() => { setIsSpinning(false); // Check if this was a Stars payment spin if (isStarsSpinRef.current) { isStarsSpinRef.current = false; // Use the pending result from polling, or show a fallback if (pendingStarsResultRef.current) { setSpinResult(pendingStarsResultRef.current); pendingStarsResultRef.current = null; } else { // Polling still in progress or failed - show fallback setSpinResult({ 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); queryClient.invalidateQueries({ queryKey: ['wheel-config'] }); queryClient.invalidateQueries({ queryKey: ['wheel-history'] }); }, [queryClient, t]); const closeResultModal = () => { setShowResultModal(false); setSpinResult(null); setTargetRotation(null); }; if (isLoading) { return (
); } if (error || !config) { return (
😔

{t('wheel.errors.loadFailed')}

); } if (!config.is_enabled) { return (
🎡

{t('wheel.title')}

{t('wheel.disabled')}

); } const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars; const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days; const canPayBalance = starsEnabled && config.can_pay_stars; // For web: pay with internal balance const canPayDays = daysEnabled && config.can_pay_days; return (
{/* Hero Header */}
{/* Background decorations */}

{t('wheel.title')}

{config.daily_limit > 0 && (
{t('wheel.spinsRemaining')}: {Math.max(0, config.daily_limit - config.user_spins_today)} / {config.daily_limit}
)}
{/* Main Content Grid */}
{/* Wheel Section */}
{/* Wheel Container with glow background */}
{/* Background glow */}
{/* Wheel */}
{/* Spin Button */}
{/* Payment Options - Different for Web vs Mini App */} {isTelegramMiniApp ? ( // Mini App: Show Stars button (direct Telegram payment) and Days button
{/* Direct Stars payment via Telegram */} {starsEnabled && ( )} {/* Days payment option */} {daysEnabled && ( )}
) : ( // Web: Show Balance button (Stars converted to rubles) and Days button (starsEnabled || daysEnabled) && (
{starsEnabled && ( )} {daysEnabled && ( )}
) )} {/* Main Spin Button - Show for web always, for mini app only if days available */} {(!isTelegramMiniApp || canPayDays) && ( )} {/* Cannot spin hint */} {!config.can_spin && !isSpinning && ( <> {config.can_spin_reason === 'daily_limit_reached' ? (

{t('wheel.errors.dailyLimitReached')}

) : config.can_spin_reason === 'insufficient_balance' ? ( ) : (

{t('wheel.errors.cannotSpin')}

)} )}
{/* History Sidebar */} {showHistory && (

{t('wheel.recentSpins')}

{history && history.items.length > 0 ? (
{history.items.map((item: SpinHistoryItem, index: number) => (
{item.emoji}
{item.prize_display_name}
{new Date(item.created_at).toLocaleDateString()}
- {item.payment_type === 'telegram_stars' ? `${item.payment_amount} ⭐` : `${item.payment_amount}${t('wheel.days').charAt(0)}`}
))}
) : (
🎰
{t('wheel.noHistory')}
)}
)}
{/* Result Modal */} {showResultModal && spinResult && (
{/* Decorative elements */} {spinResult.success && ( <>
{/* Confetti effect - using pre-calculated positions */}
{CONFETTI_POSITIONS.map((pos, i) => (
))}
)} {/* Close button */} {/* Content */}
{/* Prize icon */}
{spinResult.success ? spinResult.emoji || '🎉' : '😔'}
{/* Title */}

{spinResult.success ? spinResult.prize_type === 'nothing' ? t('wheel.noLuck') : t('wheel.congratulations') : t('wheel.oops')}

{/* Message */}

{spinResult.message}

{/* Promocode if won */} {spinResult.promocode && (

{t('wheel.yourPromoCode')}

{spinResult.promocode}

)} {/* Close button */}
)}
); }