mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
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.
724 lines
27 KiB
TypeScript
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>
|
|
);
|
|
}
|