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
return () => clearInterval(interval);
} else {
setLightPhase(0);
}
// 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);
}, [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,133 +500,145 @@ export default function Wheel() {
{/* Wheel Section */}
<Card>
<div className="p-6 sm:p-8">
{/* Wheel */}
<FortuneWheel
prizes={config.prizes}
isSpinning={isSpinning}
targetRotation={targetRotation}
onSpinComplete={handleSpinComplete}
/>
<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}
isSpinning={isSpinning}
targetRotation={targetRotation}
onSpinComplete={handleSpinComplete}
/>
{/* Spin Controls */}
<div className="mt-8 space-y-4">
{/* Payment type toggle - only if both methods available */}
{bothMethodsAvailable && (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-1">
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => setPaymentType('telegram_stars')}
disabled={isSpinning}
className={`flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium transition-all ${
paymentType === 'telegram_stars'
? 'bg-accent-500/15 text-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<StarIcon />
{isTelegramMiniApp
? `${config.spin_cost_stars}`
: `${formatAmount(config.required_balance_kopeks / 100)} ${currencySymbol}`}
</button>
<button
onClick={() => setPaymentType('subscription_days')}
disabled={isSpinning}
className={`flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium transition-all ${
paymentType === 'subscription_days'
? 'bg-accent-500/15 text-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<CalendarIcon />
{t('wheel.days', { count: config.spin_cost_days ?? 0 })}
</button>
{/* Spin Controls */}
<div className="mt-8 space-y-4">
{/* Payment type toggle - only if both methods available */}
{bothMethodsAvailable && (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-1">
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => setPaymentType('telegram_stars')}
disabled={isSpinning}
className={`flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium transition-all ${
paymentType === 'telegram_stars'
? 'bg-accent-500/15 text-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<StarIcon />
{isTelegramMiniApp
? `${config.spin_cost_stars}`
: `${formatAmount(config.required_balance_kopeks / 100)} ${currencySymbol}`}
</button>
<button
onClick={() => setPaymentType('subscription_days')}
disabled={isSpinning}
className={`flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium transition-all ${
paymentType === 'subscription_days'
? 'bg-accent-500/15 text-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<CalendarIcon />
{t('wheel.days', { count: config.spin_cost_days ?? 0 })}
</button>
</div>
</div>
</div>
)}
)}
{/* Single Spin Button */}
<Button
variant="primary"
size="lg"
fullWidth
onClick={handleUnifiedSpin}
disabled={spinDisabled}
loading={isSpinning || isPayingStars}
>
{isSpinning ? t('wheel.spinning') : t('wheel.spin')}
</Button>
{/* Cost subtitle when no toggle */}
{costSubtitle && !bothMethodsAvailable && (
<p className="text-center text-sm text-dark-400">{costSubtitle}</p>
)}
{/* Cannot spin hint */}
{!config.can_spin && !isSpinning && (
<>
{config.can_spin_reason === 'daily_limit_reached' ? (
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
</div>
) : config.can_spin_reason === 'insufficient_balance' ? (
<InsufficientBalancePrompt
missingAmountKopeks={
config.required_balance_kopeks - config.user_balance_kopeks
}
/>
) : (
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
<p className="text-dark-400">{t('wheel.errors.cannotSpin')}</p>
</div>
)}
</>
)}
{/* Inline Result Card */}
{spinResult && !isSpinning && (
<div
className={`animate-fade-in rounded-linear border p-4 ${
spinResult.success
? 'border-accent-500/30 bg-accent-500/10'
: 'border-red-500/30 bg-red-500/10'
}`}
{/* Single Spin Button */}
<Button
variant="primary"
size="lg"
fullWidth
onClick={handleUnifiedSpin}
disabled={spinDisabled}
loading={isSpinning || isPayingStars}
>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-linear bg-dark-700/50 text-2xl">
{spinResult.success ? spinResult.emoji || '🎉' : '😔'}
</div>
<div className="min-w-0 flex-1">
<div className="font-semibold text-dark-100">
{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')}
</div>
<div className="text-sm text-dark-400">{spinResult.message}</div>
</div>
<button
onClick={closeResultModal}
className="shrink-0 rounded-lg p-2 text-dark-400 transition-colors hover:bg-white/5 hover:text-dark-200"
>
<CloseIcon />
</button>
</div>
{isSpinning ? t('wheel.spinning') : t('wheel.spin')}
</Button>
{/* Promocode if won */}
{spinResult.promocode && (
<div className="mt-3 rounded-linear border border-accent-500/20 bg-accent-500/10 p-3 text-center">
<p className="mb-1 text-xs text-accent-400">{t('wheel.yourPromoCode')}</p>
<p className="select-all font-mono text-lg font-bold tracking-wider text-white">
{spinResult.promocode}
</p>
{/* Cost subtitle when no toggle */}
{costSubtitle && !bothMethodsAvailable && (
<p className="text-center text-sm text-dark-400">{costSubtitle}</p>
)}
{/* Cannot spin hint */}
{!config.can_spin && !isSpinning && (
<>
{config.can_spin_reason === 'daily_limit_reached' ? (
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
</div>
) : config.can_spin_reason === 'insufficient_balance' ? (
<InsufficientBalancePrompt
missingAmountKopeks={
config.required_balance_kopeks - config.user_balance_kopeks
}
/>
) : (
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
<p className="text-dark-400">{t('wheel.errors.cannotSpin')}</p>
</div>
)}
</>
)}
{/* Inline Result Card */}
{spinResult && !isSpinning && (
<div
className={`animate-fade-in rounded-linear border p-4 ${
spinResult.success
? 'border-accent-500/30 bg-accent-500/10'
: 'border-red-500/30 bg-red-500/10'
}`}
>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-linear bg-dark-700/50 text-2xl">
{spinResult.success ? spinResult.emoji || '🎉' : '😔'}
</div>
<div className="min-w-0 flex-1">
<div className="font-semibold text-dark-100">
{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')}
</div>
<div className="text-sm text-dark-400">{spinResult.message}</div>
</div>
<button
onClick={closeResultModal}
className="shrink-0 rounded-lg p-2 text-dark-400 transition-colors hover:bg-white/5 hover:text-dark-200"
>
<CloseIcon />
</button>
</div>
)}
</div>
)}
{/* Promocode if won */}
{spinResult.promocode && (
<div className="mt-3 rounded-linear border border-accent-500/20 bg-accent-500/10 p-3 text-center">
<p className="mb-1 text-xs text-accent-400">{t('wheel.yourPromoCode')}</p>
<p className="select-all font-mono text-lg font-bold tracking-wider text-white">
{spinResult.promocode}
</p>
</div>
)}
</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>