From 22bda66e81714a2ff3e8de02b216af70509ced3e Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 16 Feb 2026 02:06:23 +0300 Subject: [PATCH] fix: wheel lands on correct prize sector for Stars payment spins Stars payment flow previously used Math.random() for wheel rotation, causing visual mismatch between where the wheel lands and the actual prize won. Now polls for backend result first, calculates the correct rotation angle matching the prize sector, then starts the animation. --- src/pages/Wheel.tsx | 67 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 1bb03f1..63b2dcc 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -1,7 +1,7 @@ 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 { wheelApi, type WheelPrize, type SpinResult, type SpinHistoryItem } from '../api/wheel'; import FortuneWheel from '../components/wheel/FortuneWheel'; import WheelLegend from '../components/wheel/WheelLegend'; import { usePlatform, useHaptic } from '@/platform'; @@ -56,6 +56,41 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( ); +/** + * Calculate rotation degrees for a prize based on its position on the wheel. + * Mirrors the backend _calculate_rotation logic in wheel_service.py. + */ +function calculateRotationForPrize(prizes: WheelPrize[], result: SpinResult): number { + // Find prize index by matching display_name + emoji (both must match for safety) + let prizeIndex = prizes.findIndex( + (p) => p.display_name === result.prize_display_name && p.emoji === result.emoji, + ); + + // Fallback: match by display_name only + if (prizeIndex === -1) { + prizeIndex = prizes.findIndex((p) => p.display_name === result.prize_display_name); + } + + // Fallback: match by emoji + prize_type + if (prizeIndex === -1) { + prizeIndex = prizes.findIndex( + (p) => p.emoji === result.emoji && p.prize_type === result.prize_type, + ); + } + + if (prizeIndex === -1) { + // Can't determine prize position — random angle + return Math.random() * 360; + } + + const sectorAngle = 360 / prizes.length; + const baseAngle = prizeIndex * sectorAngle + sectorAngle / 2; + const offset = (Math.random() - 0.5) * sectorAngle * 0.6; // ±30% of sector + const stopAngle = 360 - baseAngle + offset; + + return stopAngle; +} + export default function Wheel() { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -198,15 +233,9 @@ export default function Wheel() { } 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 + // Keep isPayingStars=true to show loading state while polling for result. + // We poll FIRST to get the actual prize, then calculate the correct + // rotation angle so the wheel visually lands on the right sector. const abortSignal = pollingAbortRef.current.signal; pollForSpinResult(abortSignal) .then((result) => { @@ -215,10 +244,17 @@ export default function Wheel() { queryClient.invalidateQueries({ queryKey: ['wheel-config'] }); queryClient.invalidateQueries({ queryKey: ['wheel-history'] }); + setIsPayingStars(false); + if (result) { pendingStarsResultRef.current = result; + // Calculate rotation so the wheel lands on the correct prize sector + const rotation = config?.prizes + ? calculateRotationForPrize(config.prizes, result) + : Math.random() * 360; + setTargetRotation(rotation); } else { - // Fallback: couldn't get result + // Fallback: couldn't get result — use random rotation pendingStarsResultRef.current = { success: true, prize_id: null, @@ -232,12 +268,17 @@ export default function Wheel() { promocode: null, error: null, }; + setTargetRotation(Math.random() * 360); } + + setIsSpinning(true); }) .catch(() => { if (abortSignal.aborted) return; - // Error polling, show generic success + setIsPayingStars(false); + + // Error polling — spin with random rotation and show generic message pendingStarsResultRef.current = { success: true, prize_id: null, @@ -251,6 +292,8 @@ export default function Wheel() { promocode: null, error: null, }; + setTargetRotation(Math.random() * 360); + setIsSpinning(true); }); } else if (status !== 'cancelled') { setIsPayingStars(false);