Files
bedolaga-cabinet/src/pages/Wheel.tsx
Fringg 2c0d265ff5 fix: remove incorrect ruble top-up prompt from fortune wheel
Trial users saw an "insufficient balance" prompt suggesting to top up
rubles, but the wheel only accepts Telegram Stars (via invoice) or
subscription days. Stars payment via invoice doesn't require ruble
balance, so the spin button should not be disabled for it.
2026-02-07 03:36:31 +03:00

724 lines
27 KiB
TypeScript

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 FortuneWheel from '../components/wheel/FortuneWheel';
import WheelLegend from '../components/wheel/WheelLegend';
import { usePlatform, useHaptic } from '@/platform';
import { useNotify } from '@/platform/hooks/useNotify';
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 = () => (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
);
const CalendarIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
);
const HistoryIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const CloseIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<svg
className={`h-5 w-5 transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
);
export default function Wheel() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { openInvoice, capabilities } = usePlatform();
const haptic = useHaptic();
const notify = useNotify();
const [isSpinning, setIsSpinning] = useState(false);
const [targetRotation, setTargetRotation] = useState<number | null>(null);
const [spinResult, setSpinResult] = useState<SpinResult | null>(null);
const [paymentType, setPaymentType] = useState<'telegram_stars' | 'subscription_days'>(
'telegram_stars',
);
const [isPayingStars, setIsPayingStars] = useState(false);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [showStarsConfirm, setShowStarsConfirm] = useState(false);
const paymentTypeInitialized = useRef(false);
const {
data: config,
isLoading,
error,
} = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
});
const { data: history } = useQuery({
queryKey: ['wheel-history'],
queryFn: () => wheelApi.getHistory(1, 10),
});
// Auto-select payment type based on availability (only on initial load)
useEffect(() => {
if (!config || paymentTypeInitialized.current) return;
paymentTypeInitialized.current = true;
const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars;
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days;
if (starsEnabled) {
setPaymentType('telegram_stars');
} else if (daysEnabled) {
setPaymentType('subscription_days');
}
}, [config]);
// Function to poll for new spin result after Stars payment
const pollForSpinResult = useCallback(
async (signal: AbortSignal, maxAttempts = 15, delayMs = 800) => {
// Wait a bit before first poll to give the bot time to process the payment
await new Promise((resolve) => setTimeout(resolve, 1500));
if (signal.aborted) return null;
// Get current history to find the latest spin ID
let historyBefore;
try {
historyBefore = await wheelApi.getHistory(1, 1);
} catch {
historyBefore = { items: [], total: 0 };
}
const lastSpinIdBefore = historyBefore.items.length > 0 ? historyBefore.items[0].id : 0;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (signal.aborted) return null;
await new Promise((resolve) => setTimeout(resolve, delayMs));
if (signal.aborted) return null;
try {
const historyAfter = await wheelApi.getHistory(1, 1);
// Check if we have a new spin (either new item or higher ID)
if (historyAfter.items.length > 0) {
const latestSpin = historyAfter.items[0];
// If we had no spins before, or this spin has a higher ID
if (lastSpinIdBefore === 0 || latestSpin.id > lastSpinIdBefore) {
// Found a new spin! Return it as SpinResult
return {
success: true,
prize_id: latestSpin.id,
prize_type: latestSpin.prize_type,
prize_value: latestSpin.prize_value,
prize_display_name: latestSpin.prize_display_name,
emoji: latestSpin.emoji,
color: latestSpin.color,
rotation_degrees: 0, // Not needed for result display
message:
latestSpin.prize_type === 'nothing'
? t('wheel.noPrize')
: `${t('wheel.youWon')} ${latestSpin.prize_display_name}!`,
promocode: null, // Promocode is sent to bot chat
error: null,
} as SpinResult;
}
}
} catch {
// Continue polling on error
}
}
// Timeout - couldn't find new spin
return null;
},
[t],
);
// Ref to store pending Stars payment result
const pendingStarsResultRef = useRef<SpinResult | null>(null);
const isStarsSpinRef = useRef(false);
const pollingAbortRef = useRef<AbortController | null>(null);
const preOpenedWindowRef = useRef<Window | null>(null);
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollingAbortRef.current) {
pollingAbortRef.current.abort();
}
};
}, []);
const starsInvoiceMutation = useMutation({
mutationFn: wheelApi.createStarsInvoice,
onSuccess: async (data) => {
// Use platform's openInvoice if available
if (capabilities.hasInvoice) {
const status = await openInvoice(data.invoice_url);
if (status === 'paid') {
// Mark this as a Stars spin so handleSpinComplete knows to use the pending result
isStarsSpinRef.current = true;
pendingStarsResultRef.current = null;
// Cancel any existing polling
if (pollingAbortRef.current) {
pollingAbortRef.current.abort();
}
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
const abortSignal = pollingAbortRef.current.signal;
pollForSpinResult(abortSignal)
.then((result) => {
if (abortSignal.aborted) return;
queryClient.invalidateQueries({ queryKey: ['wheel-config'] });
queryClient.invalidateQueries({ queryKey: ['wheel-history'] });
if (result) {
pendingStarsResultRef.current = result;
} else {
// Fallback: couldn't get result
pendingStarsResultRef.current = {
success: true,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '🎰',
color: '#8B5CF6',
rotation_degrees: 0,
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
};
}
})
.catch(() => {
if (abortSignal.aborted) return;
// Error polling, show generic success
pendingStarsResultRef.current = {
success: true,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '🎰',
color: '#8B5CF6',
rotation_degrees: 0,
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
};
});
} else if (status !== 'cancelled') {
setIsPayingStars(false);
setSpinResult({
success: false,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '😔',
color: '#EF4444',
rotation_degrees: 0,
message: t('wheel.starsPaymentFailed'),
promocode: null,
error: 'payment_failed',
});
} else {
setIsPayingStars(false);
}
} else {
// Fallback: redirect pre-opened window to invoice URL
setIsPayingStars(false);
if (preOpenedWindowRef.current) {
preOpenedWindowRef.current.location.href = data.invoice_url;
preOpenedWindowRef.current = null;
}
setSpinResult({
success: true,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '⭐',
color: '#8B5CF6',
rotation_degrees: 0,
message: t('wheel.starsPaymentRedirected'),
promocode: null,
error: null,
});
}
},
onError: () => {
setIsPayingStars(false);
if (preOpenedWindowRef.current) {
preOpenedWindowRef.current.close();
preOpenedWindowRef.current = null;
}
setSpinResult({
success: false,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '😔',
color: '#EF4444',
rotation_degrees: 0,
message: t('wheel.errors.networkError'),
promocode: null,
error: 'network_error',
});
},
});
const handleDirectStarsPay = () => {
setShowStarsConfirm(false);
setIsPayingStars(true);
// In browser: pre-open window synchronously (direct user gesture) to avoid popup blocker
if (!capabilities.hasInvoice) {
preOpenedWindowRef.current = window.open('about:blank', '_blank') || null;
}
starsInvoiceMutation.mutate();
};
const spinMutation = useMutation({
mutationFn: () => wheelApi.spin(paymentType),
onSuccess: (result) => {
if (result.success) {
setTargetRotation(result.rotation_degrees);
setSpinResult(result);
} else {
setIsSpinning(false);
setSpinResult(result);
}
},
onError: () => {
setIsSpinning(false);
setSpinResult({
success: false,
message: t('wheel.errors.networkError'),
error: 'network_error',
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '',
color: '',
rotation_degrees: 0,
promocode: null,
});
},
});
const handleSpin = () => {
if (!config?.can_spin || isSpinning) return;
setIsSpinning(true);
spinMutation.mutate();
};
const handleUnifiedSpin = () => {
if (paymentType === 'telegram_stars') {
if (!config?.spin_cost_stars_enabled || !config?.spin_cost_stars) {
notify.warning(t('wheel.starsNotAvailable'));
return;
}
setShowStarsConfirm(true);
} else {
handleSpin();
}
};
const handleSpinComplete = useCallback(() => {
setIsSpinning(false);
// Check if this was a Stars payment spin
if (isStarsSpinRef.current) {
isStarsSpinRef.current = false;
// Use the pending result from polling, or show a fallback
if (pendingStarsResultRef.current) {
setSpinResult(pendingStarsResultRef.current);
// Haptic feedback based on result
if (pendingStarsResultRef.current.prize_type === 'nothing') {
haptic.notification('warning');
} else {
haptic.notification('success');
}
pendingStarsResultRef.current = null;
} else {
// Polling still in progress or failed - show fallback
setSpinResult({
success: true,
prize_id: null,
prize_type: null,
prize_value: 0,
prize_display_name: '',
emoji: '🎰',
color: '#8B5CF6',
rotation_degrees: 0,
message: t('wheel.starsPaymentSuccessCheckHistory'),
promocode: null,
error: null,
});
haptic.notification('success');
}
} else if (spinResult) {
// Regular spin - haptic based on result
if (spinResult.success && spinResult.prize_type !== 'nothing') {
haptic.notification('success');
} else {
haptic.notification('warning');
}
}
queryClient.invalidateQueries({ queryKey: ['wheel-config'] });
queryClient.invalidateQueries({ queryKey: ['wheel-history'] });
}, [queryClient, t, haptic, spinResult]);
const closeResultModal = () => {
setSpinResult(null);
setTargetRotation(null);
};
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
if (error || !config) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-red-500/10">
<span className="text-4xl">😔</span>
</div>
<p className="text-lg text-dark-400">{t('wheel.errors.loadFailed')}</p>
</div>
);
}
if (!config.is_enabled) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-6">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<span className="text-5xl">🎡</span>
</div>
<div className="text-center">
<h1 className="mb-2 text-2xl font-bold text-dark-100">{t('wheel.title')}</h1>
<p className="text-dark-400">{t('wheel.disabled')}</p>
</div>
</div>
);
}
const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars;
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days;
const bothMethodsAvailable = !!(starsEnabled && daysEnabled);
// Stars via Telegram invoice don't require ruble balance, so only check daily limit
const dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit;
const spinDisabled =
isSpinning ||
isPayingStars ||
dailyLimitReached ||
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
return (
<div className="animate-fade-in space-y-6 pb-8">
{/* Simple Header */}
<div>
<h1 className="text-2xl font-bold text-dark-50">{t('wheel.title')}</h1>
{config.daily_limit > 0 && (
<p className="mt-1 text-dark-400">
{t('wheel.spinsRemaining')}:{' '}
<span className="inline-flex items-center rounded-full bg-accent-500/15 px-2 py-0.5 text-sm font-medium text-accent-400">
{Math.max(0, config.daily_limit - config.user_spins_today)}/{config.daily_limit}
</span>
</p>
)}
</div>
{/* Wheel Section */}
<Card>
<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 selector */}
{(starsEnabled || daysEnabled) && (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-1">
<div
className={`grid gap-1 ${bothMethodsAvailable ? 'grid-cols-2' : 'grid-cols-1'}`}
>
{starsEnabled && (
<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 />
{`${config.spin_cost_stars}`}
</button>
)}
{daysEnabled && (
<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>
)}
{/* Stars confirmation panel */}
{showStarsConfirm && !isSpinning && !isPayingStars ? (
<div className="space-y-3 rounded-xl border border-accent-500/30 bg-accent-500/5 p-4">
<p className="text-center text-sm text-dark-300">
{t('wheel.confirmStarsPayment')}
</p>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setShowStarsConfirm(false)}
className="rounded-lg border border-dark-700 bg-dark-800 px-4 py-2.5 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-700"
>
{t('common.cancel')}
</button>
<button
onClick={handleDirectStarsPay}
className="rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
>
{t('wheel.payStars', { count: config.spin_cost_stars ?? 0 })}
</button>
</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>
)}
{/* Cannot spin hint — only show for days payment (Stars via invoice always works) */}
{!isSpinning && paymentType !== 'telegram_stars' && !config.can_spin && (
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
<p className="text-dark-400">
{config.can_spin_reason === 'daily_limit_reached'
? t('wheel.errors.dailyLimitReached')
: t('wheel.errors.cannotSpin')}
</p>
</div>
)}
{/* Daily limit hint for Stars payment (not covered by can_spin check) */}
{!isSpinning && paymentType === 'telegram_stars' && dailyLimitReached && (
<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>
)}
{/* 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>
{/* 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>
{/* History Section - full width, collapsible */}
<Card>
<button
onClick={() => setHistoryExpanded(!historyExpanded)}
className="flex w-full items-center justify-between p-4"
>
<h3 className="flex items-center gap-2 font-semibold text-dark-100">
<HistoryIcon />
{t('wheel.recentSpins')}
{history && history.items.length > 0 && (
<span className="text-sm font-normal text-dark-500">({history.items.length})</span>
)}
</h3>
<ChevronIcon expanded={historyExpanded} />
</button>
<AnimatePresence>
{historyExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<div className="border-t border-dark-700/30 px-4 pb-4 pt-2">
{history && history.items.length > 0 ? (
<motion.div
variants={staggerContainer}
initial="hidden"
animate="show"
className="space-y-2"
>
{history.items.map((item: SpinHistoryItem) => (
<motion.div
key={item.id}
variants={staggerItem}
className="flex items-center justify-between rounded-linear border border-dark-700/30 bg-dark-800/30 p-3"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-linear bg-dark-700/50 text-xl">
{item.emoji}
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-dark-100">
{item.prize_display_name}
</div>
<div className="text-xs text-dark-500">
{new Date(item.created_at).toLocaleDateString()}
</div>
</div>
</div>
<div className="whitespace-nowrap text-sm text-dark-400">
-
{item.payment_type === 'telegram_stars'
? `${item.payment_amount}`
: `${item.payment_amount}${t('wheel.days').charAt(0)}`}
</div>
</motion.div>
))}
</motion.div>
) : (
<div className="py-6 text-center text-dark-500">
<div className="mb-2 text-3xl">🎰</div>
{t('wheel.noHistory')}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
</div>
);
}