Improve wheel: fix lights animation and add prize legend

- Fix lights animation to run continuously (even when idle)
- Lights now move sequentially around the circle
- Remove text labels from wheel, keep only emoji
- Add WheelLegend component with color indicators
- Display legend on right (desktop) and bottom (mobile)
- Add 'prizes' translation key for all languages
This commit is contained in:
c0mrade
2026-02-03 23:42:52 +03:00
parent b24f5083d0
commit 4fa9d7f47c
7 changed files with 227 additions and 177 deletions

View File

@@ -26,16 +26,17 @@ const FortuneWheel = memo(function FortuneWheel({
const [displayRotation, setDisplayRotation] = useState(0);
const [lightPhase, setLightPhase] = useState(0);
// Animated lights effect - running lights when spinning
// Animated lights effect - always running, speed depends on spinning state
useEffect(() => {
if (isSpinning) {
const interval = setInterval(() => {
setLightPhase((p) => (p + 1) % 20); // Shift position around the wheel
}, 150); // Fast interval for running effect
// Faster animation when spinning, slower when idle
const interval = setInterval(
() => {
setLightPhase((p) => (p + 1) % 20);
},
isSpinning ? 100 : 300,
); // 100ms when spinning, 300ms when idle
return () => clearInterval(interval);
} else {
setLightPhase(0);
}
}, [isSpinning]);
useEffect(() => {
@@ -58,13 +59,16 @@ const FortuneWheel = memo(function FortuneWheel({
// Memoize light pattern calculation
const lightPattern = useMemo(() => {
const numLights = isSpinning ? 3 : 4; // 3 lights when spinning, 4 when idle
return Array.from({ length: 20 }, (_, i) => {
if (!isSpinning) {
return i % 2 === 0; // Static alternating pattern
// Check if this light index is within the active range
for (let j = 0; j < numLights; j++) {
if (i === (lightPhase + j) % 20) {
return true;
}
// Running lights - 3 lit lights move around the wheel
const pos = (i - lightPhase + 20) % 20;
return pos < 3;
}
return false;
});
}, [isSpinning, lightPhase]);
@@ -118,17 +122,6 @@ const FortuneWheel = memo(function FortuneWheel({
};
};
// Position for text - between hub and emoji
const getTextPosition = (index: number) => {
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
const textRadius = prizeRadius * 0.45;
return {
x: center + textRadius * Math.cos(angle),
y: center + textRadius * Math.sin(angle),
rotation: index * sectorAngle + sectorAngle / 2,
};
};
// Alternate colors for sectors
const getSectorColors = (index: number, baseColor?: string) => {
if (baseColor) return baseColor;
@@ -145,15 +138,6 @@ const FortuneWheel = memo(function FortuneWheel({
return colors[index % colors.length];
};
// Truncate text intelligently
const truncateText = (text: string, maxLen: number) => {
if (text.length <= maxLen) return text;
return text.substring(0, maxLen - 1) + '..';
};
// Calculate max text length based on number of sectors
const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8;
return (
<div className="relative mx-auto w-full max-w-[380px] select-none">
{/* Outer glow effect */}
@@ -366,28 +350,7 @@ const FortuneWheel = memo(function FortuneWheel({
);
})}
{/* Prize content - Text */}
{prizes.map((prize, index) => {
const pos = getTextPosition(index);
const displayText = truncateText(prize.display_name, maxTextLength);
return (
<text
key={`text-${prize.id}`}
x={pos.x}
y={pos.y}
textAnchor="middle"
dominantBaseline="middle"
fontSize={prizes.length <= 4 ? '13' : prizes.length <= 6 ? '11' : '9'}
fontWeight="700"
fill="#FFFFFF"
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
filter="url(#textShadow)"
style={{ letterSpacing: '0.03em' }}
>
{displayText}
</text>
);
})}
{/* Prize content - Text removed, only emoji visible on wheel */}
</g>
{/* Center hub */}

View File

@@ -0,0 +1,62 @@
import { memo } from 'react';
import type { WheelPrize } from '../../api/wheel';
interface WheelLegendProps {
prizes: WheelPrize[];
}
const WheelLegend = memo(function WheelLegend({ prizes }: WheelLegendProps) {
// Get sector color (same logic as in FortuneWheel)
const getSectorColor = (index: number, baseColor?: string) => {
if (baseColor) return baseColor;
const colors = [
'#8B5CF6',
'#EC4899',
'#3B82F6',
'#10B981',
'#F59E0B',
'#EF4444',
'#6366F1',
'#14B8A6',
];
return colors[index % colors.length];
};
return (
<div className="space-y-2">
{prizes.map((prize, index) => {
const color = getSectorColor(index, prize.color);
return (
<div
key={prize.id}
className="flex items-center gap-3 rounded-lg border border-dark-700/30 bg-dark-800/50 p-2.5 transition-colors hover:bg-dark-800"
>
{/* Color indicator */}
<div className="h-8 w-1 shrink-0 rounded-full" style={{ backgroundColor: color }} />
{/* Emoji */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center text-xl">
{prize.emoji}
</div>
{/* Prize name */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-dark-100">{prize.display_name}</div>
</div>
{/* Prize value (if applicable) */}
{prize.prize_type !== 'nothing' && prize.value && (
<div className="shrink-0 text-xs text-dark-400">
{prize.prize_type === 'balance' && `${prize.value}`}
{prize.prize_type === 'subscription_days' && `${prize.value}д`}
{prize.prize_type === 'promocode' && '🎫'}
</div>
)}
</div>
);
})}
</div>
);
});
export default WheelLegend;

View File

@@ -678,6 +678,7 @@
"wheel": {
"title": "Fortune Wheel",
"disabled": "Fortune Wheel is currently unavailable",
"prizes": "Prizes",
"spinsRemaining": "Spins remaining today",
"choosePayment": "Choose payment method",
"stars": "Stars",
@@ -983,6 +984,8 @@
"deletePrize": "Delete Prize",
"confirmDelete": "Are you sure you want to delete this prize? This action cannot be undone.",
"dragToReorder": "Drag to reorder",
"unsavedOrder": "Unsaved order changes",
"unsavedOrderHint": "Save or discard your changes",
"types": {
"subscription_days": "Subscription Days",
"balance_bonus": "Balance Bonus",

View File

@@ -567,6 +567,7 @@
"wheel": {
"title": "چرخ شانس",
"disabled": "چرخ شانس فعلاً در دسترس نیست",
"prizes": "جوایز",
"spinsRemaining": "تعداد چرخش باقی‌مانده امروز",
"choosePayment": "روش پرداخت را انتخاب کنید",
"stars": "Stars",
@@ -704,6 +705,8 @@
"noPrizes": "جایزه‌ای تنظیم نشده. برای فعال‌سازی چرخ جایزه اضافه کنید.",
"deletePrize": "این جایزه حذف شود؟",
"dragToReorder": "کشیدن برای مرتب‌سازی",
"unsavedOrder": "تغییرات ترتیب ذخیره نشده",
"unsavedOrderHint": "تغییرات را ذخیره یا لغو کنید",
"confirmDelete": "آیا از حذف این جایزه مطمئن هستید؟",
"types": {
"subscription_days": "روز اشتراک",

View File

@@ -705,6 +705,7 @@
"wheel": {
"title": "Колесо удачи",
"disabled": "Колесо удачи временно недоступно",
"prizes": "Призы",
"spinsRemaining": "Осталось вращений сегодня",
"choosePayment": "Выберите способ оплаты",
"stars": "Stars",
@@ -1007,6 +1008,8 @@
"deletePrize": "Удалить приз",
"confirmDelete": "Вы уверены, что хотите удалить этот приз? Это действие нельзя отменить.",
"dragToReorder": "Перетащите для изменения порядка",
"unsavedOrder": "Есть несохраненные изменения порядка",
"unsavedOrderHint": "Сохраните изменения или отмените их",
"types": {
"subscription_days": "Дни подписки",
"balance_bonus": "Бонус баланса",

View File

@@ -567,6 +567,7 @@
"wheel": {
"title": "幸运转盘",
"disabled": "幸运转盘暂时不可用",
"prizes": "奖品",
"spinsRemaining": "今日剩余次数",
"choosePayment": "选择支付方式",
"stars": "Stars",
@@ -756,6 +757,8 @@
"noPrizes": "未配置奖品。添加奖品以启用转盘。",
"deletePrize": "删除此奖品?",
"dragToReorder": "拖动排序",
"unsavedOrder": "有未保存的顺序更改",
"unsavedOrderHint": "保存或放弃更改",
"confirmDelete": "确定要删除这个奖品吗?",
"types": {
"subscription_days": "订阅天数",

View File

@@ -3,6 +3,7 @@ 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 WheelLegend from '../components/wheel/WheelLegend';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { useCurrency } from '../hooks/useCurrency';
import { usePlatform, useHaptic } from '@/platform';
@@ -499,7 +500,9 @@ export default function Wheel() {
{/* Wheel Section */}
<Card>
<div className="p-6 sm:p-8">
<div className="grid gap-6 p-6 sm:p-8 lg:grid-cols-[1fr,280px]">
{/* Left: Wheel and Controls */}
<div>
{/* Wheel */}
<FortuneWheel
prizes={config.prizes}
@@ -627,6 +630,16 @@ export default function Wheel() {
</div>
)}
</div>
{/* End of left column: wheel and controls */}
</div>
{/* Right column (desktop) / Bottom (mobile): Prize Legend */}
<div className="flex flex-col">
<h3 className="mb-3 text-sm font-semibold text-dark-300">
{t('wheel.prizes') || 'Призы'}
</h3>
<WheelLegend prizes={config.prizes} />
</div>
</div>
</Card>