diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index 691ee87..e1e1ef6 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -22,15 +22,16 @@ const FortuneWheel = memo(function FortuneWheel({ onSpinComplete, }: FortuneWheelProps) { const wheelRef = useRef(null); - const [currentRotation, setCurrentRotation] = useState(0); + const accumulatedRotation = useRef(0); + const [displayRotation, setDisplayRotation] = useState(0); const [lightPhase, setLightPhase] = useState(0); - // Animated lights effect - use phase instead of random array (less re-renders) + // Animated lights effect - running lights when spinning useEffect(() => { if (isSpinning) { const interval = setInterval(() => { - setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2 - }, 600); // Slower interval = better performance + setLightPhase((p) => (p + 1) % 20); // Shift position around the wheel + }, 150); // Fast interval for running effect return () => clearInterval(interval); } else { setLightPhase(0); @@ -39,7 +40,13 @@ const FortuneWheel = memo(function FortuneWheel({ useEffect(() => { if (isSpinning && targetRotation !== null && wheelRef.current) { - setCurrentRotation(targetRotation); + const currentPos = accumulatedRotation.current % 360; + let delta = targetRotation - currentPos; + // Normalize delta to positive + while (delta < 0) delta += 360; + const newRotation = accumulatedRotation.current + 1800 + delta; + accumulatedRotation.current = newRotation; + setDisplayRotation(newRotation); const timeout = setTimeout(() => { onSpinComplete(); @@ -52,8 +59,12 @@ const FortuneWheel = memo(function FortuneWheel({ // Memoize light pattern calculation const lightPattern = useMemo(() => { return Array.from({ length: 20 }, (_, i) => { - if (!isSpinning) return i % 2 === 0; - return (i + lightPhase) % 3 !== 0; + if (!isSpinning) { + return i % 2 === 0; // Static alternating pattern + } + // Running lights - 3 lit lights move around the wheel + const pos = (i - lightPhase + 20) % 20; + return pos < 3; }); }, [isSpinning, lightPhase]); @@ -265,11 +276,12 @@ const FortuneWheel = memo(function FortuneWheel({ strokeWidth="2" /> - {/* LED lights on outer ring */} + {/* LED lights on outer ring - positioned toward outer edge to avoid bleeding into sectors */} {Array.from({ length: 20 }).map((_, i) => { const angle = (i * 18 - 90) * (Math.PI / 180); - const dotX = center + outerRadius * Math.cos(angle); - const dotY = center + outerRadius * Math.sin(angle); + const ledRadius = outerRadius + 3; + const dotX = center + ledRadius * Math.cos(angle); + const dotY = center + ledRadius * Math.sin(angle); const isLit = lightPattern[i] ?? i % 2 === 0; return ( @@ -277,16 +289,16 @@ const FortuneWheel = memo(function FortuneWheel({ )} diff --git a/src/pages/AdminWheel.tsx b/src/pages/AdminWheel.tsx index cf63b52..6f0c5e0 100644 --- a/src/pages/AdminWheel.tsx +++ b/src/pages/AdminWheel.tsx @@ -7,6 +7,7 @@ import { PointerSensor, useSensor, useSensors, + closestCenter, type DragEndEvent, } from '@dnd-kit/core'; import { useTelegramDnd } from '../hooks/useTelegramDnd'; @@ -198,9 +199,13 @@ function SortablePrizeCard({
{/* Prize header - always visible */}
@@ -339,6 +344,7 @@ export default function AdminWheel() { mutationFn: adminWheelApi.updateConfig, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] }); + queryClient.invalidateQueries({ queryKey: ['wheel-config'] }); setSettingsForm(null); // Reset form so it reloads from config }, }); @@ -405,6 +411,12 @@ export default function AdminWheel() { onDragCancel: onTelegramDragCancel, } = useTelegramDnd(); + const handleDragStart = useCallback(() => { + onTelegramDragStart(); + // Collapse expanded card to avoid collision detection issues with varying heights + setExpandedPrizeId(null); + }, [onTelegramDragStart]); + const handleDragEnd = useCallback( (event: DragEndEvent) => { onTelegramDragEnd(); @@ -767,7 +779,8 @@ export default function AdminWheel() { {/* Prize list with DnD */} diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 85b4a9d..9184a67 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -6,6 +6,10 @@ import FortuneWheel from '../components/wheel/FortuneWheel'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import { useCurrency } from '../hooks/useCurrency'; import { usePlatform, useHaptic } from '@/platform'; +import { Card } from '@/components/data-display/Card/Card'; +import { Button } from '@/components/primitives/Button/Button'; +import { motion, AnimatePresence } from 'framer-motion'; +import { staggerContainer, staggerItem } from '@/components/motion/transitions'; // Icons const StarIcon = () => ( @@ -40,23 +44,15 @@ const CloseIcon = () => ( ); -const SparklesIcon = () => ( - - - -); - -const TrophyIcon = () => ( - - +const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( + + ); @@ -74,6 +70,7 @@ export default function Wheel() { 'telegram_stars', ); const [isPayingStars, setIsPayingStars] = useState(false); + const [historyExpanded, setHistoryExpanded] = useState(false); // Check if we're in Telegram Mini App environment const isTelegramMiniApp = platform === 'telegram'; @@ -102,8 +99,10 @@ export default function Wheel() { const canPayDays = daysEnabled && config.can_pay_days; if (isTelegramMiniApp) { - // In Mini App: prefer days if available, Stars payment is separate button - if (canPayDays) { + // In Mini App: prefer stars if available + if (starsEnabled) { + setPaymentType('telegram_stars'); + } else if (canPayDays) { setPaymentType('subscription_days'); } } else { @@ -359,6 +358,14 @@ export default function Wheel() { spinMutation.mutate(); }; + const handleUnifiedSpin = () => { + if (isTelegramMiniApp && paymentType === 'telegram_stars') { + handleDirectStarsPay(); + } else { + handleSpin(); + } + }; + const handleSpinComplete = useCallback(() => { setIsSpinning(false); @@ -414,12 +421,7 @@ export default function Wheel() { if (isLoading) { return (
-
-
-
- -
-
+
); } @@ -453,320 +455,253 @@ export default function Wheel() { 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; + const bothMethodsAvailable = isTelegramMiniApp + ? starsEnabled && daysEnabled && canPayDays + : starsEnabled && daysEnabled && canPayBalance && canPayDays; + + const spinDisabled = + !config.can_spin || + isSpinning || + isPayingStars || + (config.daily_limit > 0 && config.user_spins_today >= config.daily_limit); + + // Build cost subtitle for the spin button + const getCostSubtitle = () => { + if (bothMethodsAvailable) return null; // toggle handles it + if (paymentType === 'telegram_stars' && starsEnabled) { + if (isTelegramMiniApp) { + return `${config.spin_cost_stars} ⭐`; + } + return `${formatAmount(config.required_balance_kopeks / 100)} ${currencySymbol} (${config.spin_cost_stars} ⭐)`; + } + if (paymentType === 'subscription_days' && daysEnabled) { + return t('wheel.days', { count: config.spin_cost_days ?? 0 }); + } + return null; + }; + + const costSubtitle = getCostSubtitle(); 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} - -
- )} -
-
+
+ {/* Simple Header */} +
+

{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 Section */} + +
+ {/* Wheel */} + - {/* 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')}

-
- )} - - )} - - {/* Inline Result Card */} - {spinResult && !isSpinning && ( -
-
-
- {spinResult.success ? spinResult.emoji || '🎉' : '😔'} -
-
-
- {spinResult.success && spinResult.prize_display_name - ? spinResult.prize_display_name - : spinResult.success - ? spinResult.prize_type === 'nothing' - ? t('wheel.noLuck') - : t('wheel.congratulations') - : t('wheel.oops')} -
-
{spinResult.message}
-
- -
- - {/* Promocode if won */} - {spinResult.promocode && ( -
-

{t('wheel.yourPromoCode')}

-

- {spinResult.promocode} -

-
- )} -
- )} -
-
-
- - {/* History Sidebar - always visible on desktop */} -
-
-
-

- - {t('wheel.recentSpins')} -

-
- - {history && history.items.length > 0 ? ( -
- {history.items.map((item: SpinHistoryItem, index: number) => ( -
+ {/* Payment type toggle - only if both methods available */} + {bothMethodsAvailable && ( +
+
+
- ))} + + {isTelegramMiniApp + ? `${config.spin_cost_stars} ⭐` + : `${formatAmount(config.required_balance_kopeks / 100)} ${currencySymbol}`} + + +
- ) : ( -
-
🎰
- {t('wheel.noHistory')} + )} + + {/* Single Spin Button */} + + + {/* Cost subtitle when no toggle */} + {costSubtitle && !bothMethodsAvailable && ( +

{costSubtitle}

+ )} + + {/* 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')}

+
+ )} + + )} + + {/* Inline Result Card */} + {spinResult && !isSpinning && ( +
+
+
+ {spinResult.success ? spinResult.emoji || '🎉' : '😔'} +
+
+
+ {spinResult.success && spinResult.prize_display_name + ? spinResult.prize_display_name + : spinResult.success + ? spinResult.prize_type === 'nothing' + ? t('wheel.noLuck') + : t('wheel.congratulations') + : t('wheel.oops')} +
+
{spinResult.message}
+
+ +
+ + {/* Promocode if won */} + {spinResult.promocode && ( +
+

{t('wheel.yourPromoCode')}

+

+ {spinResult.promocode} +

+
+ )}
)}
-
+ + + {/* History Section - full width, collapsible */} + + + + + {historyExpanded && ( + +
+ {history && history.items.length > 0 ? ( + + {history.items.map((item: SpinHistoryItem) => ( + +
+
+ {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')} +
+ )} +
+
+ )} +
+
); }