mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
Sweep 30 files: text/bg/border/ring/from/to/via/fill/stroke/shadow/divide/decoration/ outline/placeholder-red-N → -error-N. All red usages were semantically error/danger (no brand red), so tokens now flow through the design-system CSS variables and respond to palette overrides.
839 lines
32 KiB
TypeScript
839 lines
32 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 WheelPrize, 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>
|
|
);
|
|
|
|
/**
|
|
* Calculate rotation degrees for a prize based on its position on the wheel.
|
|
* Mirrors the backend _calculate_rotation logic in wheel_service.py.
|
|
*/
|
|
function calculateRotationForPrize(prizes: WheelPrize[], result: SpinResult): number {
|
|
// Find prize index by matching display_name + emoji (both must match for safety)
|
|
let prizeIndex = prizes.findIndex(
|
|
(p) => p.display_name === result.prize_display_name && p.emoji === result.emoji,
|
|
);
|
|
|
|
// Fallback: match by display_name only
|
|
if (prizeIndex === -1) {
|
|
prizeIndex = prizes.findIndex((p) => p.display_name === result.prize_display_name);
|
|
}
|
|
|
|
// Fallback: match by emoji + prize_type
|
|
if (prizeIndex === -1) {
|
|
prizeIndex = prizes.findIndex(
|
|
(p) => p.emoji === result.emoji && p.prize_type === result.prize_type,
|
|
);
|
|
}
|
|
|
|
if (prizeIndex === -1) {
|
|
// Can't determine prize position — random angle
|
|
return Math.random() * 360;
|
|
}
|
|
|
|
const sectorAngle = 360 / prizes.length;
|
|
const baseAngle = prizeIndex * sectorAngle + sectorAngle / 2;
|
|
const offset = (Math.random() - 0.5) * sectorAngle * 0.6; // ±30% of sector
|
|
const stopAngle = 360 - baseAngle + offset;
|
|
|
|
return stopAngle;
|
|
}
|
|
|
|
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 [selectedSubscriptionId, setSelectedSubscriptionId] = useState<number | null>(null);
|
|
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');
|
|
}
|
|
|
|
// Auto-select subscription if only one eligible
|
|
if (config.eligible_subscriptions?.length === 1) {
|
|
setSelectedSubscriptionId(config.eligible_subscriptions[0].id);
|
|
}
|
|
}, [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();
|
|
|
|
// Keep isPayingStars=true to show loading state while polling for result.
|
|
// We poll FIRST to get the actual prize, then calculate the correct
|
|
// rotation angle so the wheel visually lands on the right sector.
|
|
const abortSignal = pollingAbortRef.current.signal;
|
|
pollForSpinResult(abortSignal)
|
|
.then((result) => {
|
|
if (abortSignal.aborted) return;
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['wheel-config'] });
|
|
queryClient.invalidateQueries({ queryKey: ['wheel-history'] });
|
|
|
|
setIsPayingStars(false);
|
|
|
|
if (result) {
|
|
pendingStarsResultRef.current = result;
|
|
// Calculate rotation so the wheel lands on the correct prize sector
|
|
const rotation = config?.prizes
|
|
? calculateRotationForPrize(config.prizes, result)
|
|
: Math.random() * 360;
|
|
setTargetRotation(rotation);
|
|
} else {
|
|
// Fallback: couldn't get result — use random rotation
|
|
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,
|
|
};
|
|
setTargetRotation(Math.random() * 360);
|
|
}
|
|
|
|
setIsSpinning(true);
|
|
})
|
|
.catch(() => {
|
|
if (abortSignal.aborted) return;
|
|
|
|
setIsPayingStars(false);
|
|
|
|
// Error polling — spin with random rotation and show generic message
|
|
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,
|
|
};
|
|
setTargetRotation(Math.random() * 360);
|
|
setIsSpinning(true);
|
|
});
|
|
} 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) {
|
|
// Web-only: synchronously pre-open a tab during the user gesture to dodge the
|
|
// popup blocker before the async invoice URL resolves. Not reached in Telegram
|
|
// (hasInvoice is true there, so the native invoice flow is used instead).
|
|
// eslint-disable-next-line no-restricted-properties
|
|
preOpenedWindowRef.current = window.open('about:blank', '_blank') || null;
|
|
}
|
|
starsInvoiceMutation.mutate();
|
|
};
|
|
|
|
const spinMutation = useMutation({
|
|
mutationFn: () => wheelApi.spin(paymentType, selectedSubscriptionId ?? undefined),
|
|
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 (noSubscription) return;
|
|
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);
|
|
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-error-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 noSubscription = !config.has_subscription;
|
|
const needsSubscriptionPick =
|
|
paymentType === 'subscription_days' &&
|
|
config.eligible_subscriptions &&
|
|
config.eligible_subscriptions.length > 1 &&
|
|
!selectedSubscriptionId;
|
|
|
|
const spinDisabled =
|
|
isSpinning ||
|
|
isPayingStars ||
|
|
dailyLimitReached ||
|
|
noSubscription ||
|
|
needsSubscriptionPick ||
|
|
(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 px-1 pb-1 pt-2">
|
|
<p className="mb-1 text-center text-xs text-dark-400">{t('wheel.spinCost')}</p>
|
|
<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>
|
|
)}
|
|
|
|
{/* Subscription selector for days payment in multi-tariff */}
|
|
{paymentType === 'subscription_days' &&
|
|
config.eligible_subscriptions &&
|
|
config.eligible_subscriptions.length > 1 && (
|
|
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-3">
|
|
<p className="mb-2 text-center text-xs text-dark-400">
|
|
{t('wheel.selectSubscription', 'Выберите подписку')}
|
|
</p>
|
|
<div className="space-y-1.5">
|
|
{config.eligible_subscriptions.map((sub) => (
|
|
<button
|
|
key={sub.id}
|
|
onClick={() => setSelectedSubscriptionId(sub.id)}
|
|
disabled={isSpinning}
|
|
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${
|
|
selectedSubscriptionId === sub.id
|
|
? 'bg-accent-500/15 text-accent-400'
|
|
: 'text-dark-400 hover:text-dark-200'
|
|
}`}
|
|
>
|
|
<span className="font-medium">
|
|
{sub.tariff_name || t('subscription.defaultName', 'Подписка')}
|
|
</span>
|
|
<span className="text-xs opacity-60">
|
|
{sub.days_left} {t('common.units.days', 'дней')}
|
|
</span>
|
|
</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>
|
|
)}
|
|
|
|
{/* No subscription hint */}
|
|
{!isSpinning && noSubscription && (
|
|
<div className="rounded-linear border border-warning-500/30 bg-warning-500/5 p-4 text-center">
|
|
<p className="text-warning-400">{t('wheel.errors.noSubscription')}</p>
|
|
</div>
|
|
)}
|
|
{/* Cannot spin hint — only show for days payment (Stars via invoice always works) */}
|
|
{!isSpinning &&
|
|
!noSubscription &&
|
|
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 &&
|
|
!noSubscription &&
|
|
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>
|
|
)}
|
|
{/* Subscription selection required hint */}
|
|
{!isSpinning && needsSubscriptionPick && (
|
|
<div className="rounded-linear border border-warning-500/30 bg-warning-500/5 p-4 text-center">
|
|
<p className="text-warning-400">
|
|
{t('wheel.errors.selectSubscription', 'Выберите подписку для списания дней')}
|
|
</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-error-500/30 bg-error-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>
|
|
);
|
|
}
|