import { AddLinkEventTracker, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, TransitionEvent, useEffect, useMemo, useRef, useState } from 'react'; import { LocalizeText } from '../../api'; import { Column, Flex, LayoutAvatarImageView, LayoutCurrencyIcon, Text } from '../../common'; import { useFortuneWheel, useHasPermission } from '../../hooks'; import { NitroCard } from '../../layout'; import { FortuneWheelSettingsView } from './FortuneWheelSettingsView'; import { WheelWinReveal } from './WheelWinReveal'; import { renderPrizeIcon } from './wheelPrizeIcon'; // Stock UI palette (white / light-blue / grey / black). const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ]; const RIM = '#4c606c'; const WHEEL_SIZE = 420; const ICON_RADIUS = 150; const FULL_TURNS = 5; // Spin motion (wind-back → fast spin past target → settle back). const WINDBACK_DEG = 14; const OVERSHOOT_DEG = 16; const WINDBACK_MS = 250; const SPIN_MS = 4000; const SETTLE_MS = 550; type SpinPhase = 'idle' | 'windback' | 'spin' | 'settle'; export const FortuneWheelView: FC<{}> = () => { const [ isVisible, setIsVisible ] = useState(false); const [ isSettingsOpen, setIsSettingsOpen ] = useState(false); const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel(); const canManage = useHasPermission('acc_wheeladmin'); const [ rotation, setRotation ] = useState(0); const [ phase, setPhase ] = useState('idle'); const [ revealPrize, setRevealPrize ] = useState(null); const [ wheelScale, setWheelScale ] = useState(1); const rotationRef = useRef(0); const targetRef = useRef(0); const phaseRef = useRef('idle'); const wonPrizeRef = useRef(null); const prizesRef = useRef([]); const wheelHostRef = useRef(null); prizesRef.current = prizes; const reducedMotion = useMemo(() => (typeof window !== 'undefined') && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, []); const setSpinPhase = (next: SpinPhase) => { phaseRef.current = next; setPhase(next); }; useEffect(() => { const linkTracker: ILinkEventTracker = { linkReceived: (url: string) => { const parts = url.split('/'); if(parts.length < 2) return; switch(parts[1]) { case 'show': setIsVisible(true); return; case 'hide': setIsVisible(false); return; case 'toggle': setIsVisible(prev => !prev); return; } }, eventUrlPrefix: 'fortune-wheel/', }; AddLinkEventTracker(linkTracker); return () => RemoveLinkEventTracker(linkTracker); }, []); useEffect(() => { if(isVisible) open(); }, [ isVisible, open ]); // Keep the wheel fitting its container on narrow viewports without // rewriting the px-based slice/icon math: measure the available width // and scale the whole wheel down to fit. useEffect(() => { const host = wheelHostRef.current; if(!host || (typeof ResizeObserver === 'undefined')) return; const observer = new ResizeObserver(entries => { const width = entries[0]?.contentRect.width ?? WHEEL_SIZE; setWheelScale(Math.min(1, width / WHEEL_SIZE)); }); observer.observe(host); return () => observer.disconnect(); }, [ isVisible ]); // Drive the spin animation when the server reports the winning slice. useEffect(() => { if(pendingPrizeId < 0) return; const list = prizesRef.current; const idx = list.findIndex(prize => prize.id === pendingPrizeId); if(!list.length || (idx < 0)) { finishSpin(); return; } wonPrizeRef.current = list[idx]; const sliceAngle = 360 / list.length; const centerAngle = ((idx + 0.5) * sliceAngle); const current = rotationRef.current; const target = (current - (current % 360)) + (FULL_TURNS * 360) + (360 - centerAngle); targetRef.current = target; if(reducedMotion) { // Single straightforward move to the target, no flourish. setSpinPhase('spin'); rotationRef.current = target; setRotation(target); return; } // Phase 1: tiny anticipation wind-back before the spin. setSpinPhase('windback'); const back = current - WINDBACK_DEG; rotationRef.current = back; setRotation(back); }, [ pendingPrizeId, finishSpin, reducedMotion ]); const finishReveal = () => { setSpinPhase('idle'); setRevealPrize(wonPrizeRef.current); finishSpin(); }; const handleTransitionEnd = (event: TransitionEvent) => { // Only react to the wheel's own transform transition finishing. Child // elements (prize icons, badges) can emit their own bubbling // transitionend events; without this guard they'd advance the spin // phase machine early and reveal the prize before the wheel stops. if((event.target !== event.currentTarget) || (event.propertyName !== 'transform')) return; switch(phaseRef.current) { case 'windback': // Phase 2: spin fast, overshooting the target slightly. setSpinPhase('spin'); rotationRef.current = targetRef.current + OVERSHOOT_DEG; setRotation(rotationRef.current); return; case 'spin': if(reducedMotion) { finishReveal(); return; } // Phase 3: settle back from the overshoot onto the target. setSpinPhase('settle'); rotationRef.current = targetRef.current; setRotation(rotationRef.current); return; case 'settle': finishReveal(); return; } }; const sliceAngle = prizes.length ? (360 / prizes.length) : 0; const background = useMemo(() => { if(!prizes.length) return SLICE_COLORS[0]; const stops = prizes.map((_, i) => `${ SLICE_COLORS[i % 2] } ${ i * sliceAngle }deg ${ (i + 1) * sliceAngle }deg`).join(', '); return `conic-gradient(${ stops })`; }, [ prizes, sliceAngle ]); const wheelTransition = useMemo(() => { switch(phase) { case 'windback': return `transform ${ WINDBACK_MS }ms ease-in`; case 'spin': return `transform ${ SPIN_MS }ms cubic-bezier(0.12,0.78,0.2,1)`; case 'settle': return `transform ${ SETTLE_MS }ms ease-out`; default: return 'none'; } }, [ phase ]); if(!isVisible) return null; const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0); return ( setIsVisible(false) } />
{ prizes.map((_, i) => (
)) } { prizes.map((prize, i) => { const centerAngle = ((i + 0.5) * sliceAngle); return (
{ renderPrizeIcon(prize) }
); }) }
{ LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) } { LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) } { canManage && } { LocalizeText('wheel.winners') } { recentWins.map((win, i) => (
{ win.username } { win.prizeLabel }
)) } { !recentWins.length && { LocalizeText('wheel.winners.empty') } }
{ revealPrize && setRevealPrize(null) } /> }
{ canManage && isSettingsOpen && setIsSettingsOpen(false) } /> } ); };