import { useState, useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, type DragEndEvent, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import { useDestructiveConfirm } from '@/platform'; import { useNotify } from '@/platform/hooks/useNotify'; import FortuneWheel from '../components/wheel/FortuneWheel'; import { ColorPicker } from '@/components/ColorPicker'; import { usePlatform } from '../platform/hooks/usePlatform'; import { toNumber } from '../utils/inputHelpers'; const BackIcon = () => ( ); const CogIcon = () => ( ); const GiftIcon = () => ( ); const ChartIcon = () => ( ); const PlusIcon = () => ( ); const TrashIcon = () => ( ); const GripVerticalIcon = () => ( ); const ChevronDownIcon = () => ( ); const ChevronUpIcon = () => ( ); const XMarkIcon = () => ( ); const CheckIcon = () => ( ); const StarIcon = ({ className = 'h-4 w-4' }: { className?: string }) => ( ); const AdjustmentsIcon = ({ className = 'h-4 w-4' }: { className?: string }) => ( ); const TicketIcon = ({ className = 'h-4 w-4' }: { className?: string }) => ( ); const PRIZE_TYPE_KEYS = [ { value: 'subscription_days', key: 'subscription_days', emoji: '📅' }, { value: 'balance_bonus', key: 'balance_bonus', emoji: '💰' }, { value: 'traffic_gb', key: 'traffic_gb', emoji: '📊' }, { value: 'promocode', key: 'promocode', emoji: '🎟️' }, { value: 'nothing', key: 'nothing', emoji: '😔' }, ]; type Tab = 'settings' | 'prizes' | 'statistics'; interface SortablePrizeCardProps { prize: WheelPrizeAdmin; isExpanded: boolean; onToggleExpand: () => void; onDelete: () => void; onSave: (data: Partial) => void; isLoading?: boolean; } function SortablePrizeCard({ prize, isExpanded, onToggleExpand, onDelete, onSave, isLoading, }: SortablePrizeCardProps) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: prize.id, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined, position: isDragging ? 'relative' : undefined, }; return (
{/* Prize header - always visible */}
{/* Drag handle */} {/* Prize icon */}
{prize.emoji}
{/* Prize info */}
{prize.display_name}
{t(`admin.wheel.prizes.types.${prize.prize_type}`)} •{' '} {(prize.prize_value_kopeks / 100).toFixed(0)}₽
{/* Actions */}
{/* Expanded edit form */} {isExpanded && (
)}
); } export default function AdminWheel() { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const confirmDelete = useDestructiveConfirm(); const { capabilities } = usePlatform(); const notify = useNotify(); const [activeTab, setActiveTab] = useState('settings'); const [expandedPrizeId, setExpandedPrizeId] = useState(null); const [isCreating, setIsCreating] = useState(false); const [localPrizeOrder, setLocalPrizeOrder] = useState(null); const [hasUnsavedOrder, setHasUnsavedOrder] = useState(false); // Settings form state const [settingsForm, setSettingsForm] = useState<{ is_enabled: boolean; spin_cost_stars: number | ''; spin_cost_stars_enabled: boolean; spin_cost_days: number | ''; spin_cost_days_enabled: boolean; rtp_percent: number; daily_spin_limit: number | ''; min_subscription_days_for_day_payment: number | ''; promo_prefix: string; } | null>(null); // Fetch config const { data: config, isLoading } = useQuery({ queryKey: ['admin-wheel-config'], queryFn: adminWheelApi.getConfig, }); // Fetch statistics const { data: stats } = useQuery({ queryKey: ['admin-wheel-stats'], queryFn: () => adminWheelApi.getStatistics(), enabled: activeTab === 'statistics', }); // Initialize settings form when config is loaded useEffect(() => { if (config) { setSettingsForm((prev) => { if (prev) return prev; // Already initialized, don't overwrite return { is_enabled: config.is_enabled, spin_cost_stars: config.spin_cost_stars, spin_cost_stars_enabled: config.spin_cost_stars_enabled, spin_cost_days: config.spin_cost_days, spin_cost_days_enabled: config.spin_cost_days_enabled, rtp_percent: config.rtp_percent, daily_spin_limit: config.daily_spin_limit, min_subscription_days_for_day_payment: config.min_subscription_days_for_day_payment, promo_prefix: config.promo_prefix, }; }); } }, [config]); // Check if settings form has changes const hasSettingsChanges = settingsForm && config && (settingsForm.is_enabled !== config.is_enabled || settingsForm.spin_cost_stars !== config.spin_cost_stars || settingsForm.spin_cost_stars_enabled !== config.spin_cost_stars_enabled || settingsForm.spin_cost_days !== config.spin_cost_days || settingsForm.spin_cost_days_enabled !== config.spin_cost_days_enabled || settingsForm.rtp_percent !== config.rtp_percent || settingsForm.daily_spin_limit !== config.daily_spin_limit || settingsForm.min_subscription_days_for_day_payment !== config.min_subscription_days_for_day_payment || settingsForm.promo_prefix !== config.promo_prefix); // Update config mutation const updateConfigMutation = useMutation({ mutationFn: adminWheelApi.updateConfig, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] }); queryClient.invalidateQueries({ queryKey: ['wheel-config'] }); setSettingsForm(null); // Reset form so it reloads from config }, }); // Prize mutations const createPrizeMutation = useMutation({ mutationFn: adminWheelApi.createPrize, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] }); setIsCreating(false); }, }); const updatePrizeMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial }) => adminWheelApi.updatePrize(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] }); setExpandedPrizeId(null); }, }); // Reorder prizes mutation const reorderPrizesMutation = useMutation({ mutationFn: adminWheelApi.reorderPrizes, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] }); // Reset local state after successful save setLocalPrizeOrder(null); setHasUnsavedOrder(false); }, }); // Delete prize mutation const deletePrizeMutation = useMutation({ mutationFn: adminWheelApi.deletePrize, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] }); }, }); // Handle delete with native confirm const handleDeletePrize = useCallback( async (prizeId: number) => { const confirmed = await confirmDelete( t('admin.wheel.prizes.confirmDelete'), t('common.delete'), t('admin.wheel.prizes.deletePrize'), ); if (confirmed) { deletePrizeMutation.mutate(prizeId); } }, [confirmDelete, deletePrizeMutation, t], ); // DnD sensors - PointerSensor handles both mouse and touch const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const handleDragStart = useCallback(() => { // Collapse expanded card to avoid collision detection issues with varying heights setExpandedPrizeId(null); }, []); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id && config) { // Use current displayed order (either local or from config) const currentPrizes = localPrizeOrder ? localPrizeOrder .map((id) => config.prizes.find((p) => p.id === id)) .filter((p): p is WheelPrizeAdmin => p !== undefined) : config.prizes; const oldIndex = currentPrizes.findIndex((p) => p.id === active.id); const newIndex = currentPrizes.findIndex((p) => p.id === over.id); if (oldIndex !== -1 && newIndex !== -1) { const newOrder = arrayMove(currentPrizes, oldIndex, newIndex); // Save order locally, don't trigger mutation immediately setLocalPrizeOrder(newOrder.map((p) => p.id)); setHasUnsavedOrder(true); } } }, [config, localPrizeOrder], ); // Save prize order const handleSavePrizeOrder = useCallback(() => { if (localPrizeOrder) { reorderPrizesMutation.mutate(localPrizeOrder); setHasUnsavedOrder(false); } }, [localPrizeOrder, reorderPrizesMutation]); // Discard order changes const handleDiscardOrderChanges = useCallback(() => { setLocalPrizeOrder(null); setHasUnsavedOrder(false); }, []); // Get prizes in display order (use local order if available) const displayedPrizes = config?.prizes ? localPrizeOrder ? localPrizeOrder .map((id) => config.prizes.find((p) => p.id === id)) .filter((p): p is WheelPrizeAdmin => p !== undefined) : config.prizes : []; if (isLoading) { return (
); } if (!config) { return
{t('wheel.errors.loadFailed')}
; } return (
{/* Header */}
{/* Show back button only on web, not in Telegram Mini App */} {!capabilities.hasBackButton && ( )}

{t('admin.wheel.title')}

{config.is_enabled ? t('admin.wheel.enabled') : t('admin.wheel.disabled')}
{/* Tabs */}
{/* Settings Tab */} {activeTab === 'settings' && (
{/* Enable toggle */}

{t('admin.wheel.settings.enableWheel')}

{t('admin.wheel.settings.allowSpins')}


{/* Spin Cost Section */}

{t('admin.wheel.settings.spinCost')}

{ const val = e.target.value; if (val === '') return setSettingsForm((prev) => prev ? { ...prev, spin_cost_stars: '' } : null, ); const num = parseInt(val); if (!isNaN(num)) setSettingsForm((prev) => prev ? { ...prev, spin_cost_stars: num } : null, ); }} min={1} max={1000} className="input flex-1" />
{ const val = e.target.value; if (val === '') return setSettingsForm((prev) => prev ? { ...prev, spin_cost_days: '' } : null, ); const num = parseInt(val); if (!isNaN(num)) setSettingsForm((prev) => (prev ? { ...prev, spin_cost_days: num } : null)); }} min={1} max={30} className="input flex-1" />

{/* Limits & RTP Section */}

{t('admin.wheel.settings.limitsAndRtp')}

setSettingsForm((prev) => prev ? { ...prev, rtp_percent: parseInt(e.target.value) } : null, ) } className="w-full" />
0% {settingsForm?.rtp_percent ?? config.rtp_percent}% 100%
{ const val = e.target.value; if (val === '') return setSettingsForm((prev) => prev ? { ...prev, daily_spin_limit: '' } : null, ); const num = parseInt(val); if (!isNaN(num)) setSettingsForm((prev) => (prev ? { ...prev, daily_spin_limit: num } : null)); }} min={0} max={100} className="input w-full" />
{ const val = e.target.value; if (val === '') return setSettingsForm((prev) => prev ? { ...prev, min_subscription_days_for_day_payment: '' } : null, ); const num = parseInt(val); if (!isNaN(num)) setSettingsForm((prev) => prev ? { ...prev, min_subscription_days_for_day_payment: num } : null, ); }} min={1} max={30} className="input w-full" />

{/* Promocodes Section */}

{t('admin.wheel.settings.promocodes')}

setSettingsForm((prev) => prev ? { ...prev, promo_prefix: e.target.value } : null, ) } maxLength={20} className="input w-full" />
{/* Save Button */} {hasSettingsChanges && (
)}
)} {/* Prizes Tab */} {activeTab === 'prizes' && (
{/* Prize list */}

{t('admin.wheel.prizes.dragToReorder')}

{/* Unsaved order changes banner */} {hasUnsavedOrder && (

{t('admin.wheel.prizes.unsavedOrder') || 'Есть несохраненные изменения порядка'}

{t('admin.wheel.prizes.unsavedOrderHint') || 'Сохраните изменения или отмените их'}

)} {/* Create new prize inline form */} {isCreating && ( { createPrizeMutation.mutate(data as CreateWheelPrizeData); }} onCancel={() => setIsCreating(false)} isLoading={createPrizeMutation.isPending} /> )} {/* Prize list with DnD */} p.id)} strategy={verticalListSortingStrategy} >
{displayedPrizes.map((prize) => ( setExpandedPrizeId(expandedPrizeId === prize.id ? null : prize.id) } onDelete={() => handleDeletePrize(prize.id)} onSave={(data) => updatePrizeMutation.mutate({ id: prize.id, data })} isLoading={updatePrizeMutation.isPending} /> ))}
{config.prizes.length === 0 && !isCreating && (
{t('admin.wheel.prizes.noPrizes')}
)}
{/* Wheel Preview */}

{t('admin.wheel.preview')}

{}} />
)} {/* Statistics Tab */} {activeTab === 'statistics' && stats && (
{/* Stats cards */}
{stats.total_spins}
{t('admin.wheel.statistics.totalSpins')}
{(stats.total_revenue_kopeks / 100).toFixed(0)}₽
{t('admin.wheel.statistics.revenue')}
{(stats.total_payout_kopeks / 100).toFixed(0)}₽
{t('admin.wheel.statistics.payouts')}
{stats.actual_rtp_percent.toFixed(1)}%
{t('admin.wheel.statistics.actualRtp')} ({t('admin.wheel.statistics.targetRtp')}:{' '} {stats.configured_rtp_percent}%)
{/* Prize distribution */} {stats.prizes_distribution.length > 0 && (

{t('admin.wheel.statistics.prizeDistribution')}

{stats.prizes_distribution.map((prize, i) => (
{prize.display_name} {t('admin.wheel.statistics.times', { count: prize.count })}
))}
)} {/* Top wins */} {stats.top_wins.length > 0 && (

{t('admin.wheel.statistics.topWins')}

{stats.top_wins.slice(0, 5).map((win, i) => (
{win.username || `User #${win.user_id}`} {win.prize_display_name} ({(win.prize_value_kopeks / 100).toFixed(0)}₽)
))}
)}
)}
); } // Inline Prize Form Component function InlinePrizeForm({ prize, onSave, onCancel, isLoading, }: { prize: WheelPrizeAdmin | null; onSave: (data: Partial) => void; onCancel: () => void; isLoading?: boolean; }) { const { t } = useTranslation(); const [formData, setFormData] = useState<{ prize_type: string; prize_value: number | ''; display_name: string; emoji: string; color: string; prize_value_kopeks: number | ''; is_active: boolean; manual_probability: number | null; promo_balance_bonus_kopeks: number | ''; promo_subscription_days: number | ''; promo_traffic_gb: number | ''; }>({ prize_type: prize?.prize_type || 'balance_bonus', prize_value: prize?.prize_value || 0, display_name: prize?.display_name || '', emoji: prize?.emoji || '🎁', color: prize?.color || '#3B82F6', prize_value_kopeks: prize?.prize_value_kopeks || 0, is_active: prize?.is_active ?? true, manual_probability: prize?.manual_probability || null, promo_balance_bonus_kopeks: prize?.promo_balance_bonus_kopeks || 0, promo_subscription_days: prize?.promo_subscription_days || 0, promo_traffic_gb: prize?.promo_traffic_gb || 0, }); const handleSubmit = (e: React.SyntheticEvent) => { e.preventDefault(); onSave({ ...formData, prize_value: toNumber(formData.prize_value), prize_value_kopeks: toNumber(formData.prize_value_kopeks), promo_balance_bonus_kopeks: toNumber(formData.promo_balance_bonus_kopeks), promo_subscription_days: toNumber(formData.promo_subscription_days), promo_traffic_gb: toNumber(formData.promo_traffic_gb), }); }; return (
{/* Header for new prize */} {!prize && (

{t('admin.wheel.prizes.addPrize')}

)} {/* Two-column layout for main fields */}
{/* Prize type */}
{/* Display name */}
setFormData({ ...formData, display_name: e.target.value })} required maxLength={100} className="input w-full" placeholder="e.g. 7 Days Free" />
{/* Prize value */} {formData.prize_type !== 'nothing' && (
{ const val = e.target.value; if (val === '') return setFormData({ ...formData, prize_value: '' }); const num = parseInt(val); if (!isNaN(num)) setFormData({ ...formData, prize_value: num }); }} min={0} className="input w-full" />
)} {/* Prize value in kopeks (for RTP calculation) */}
{ const val = e.target.value; if (val === '') return setFormData({ ...formData, prize_value_kopeks: '' }); const num = parseInt(val); if (!isNaN(num)) setFormData({ ...formData, prize_value_kopeks: num }); }} min={0} className="input w-full" />

= {(toNumber(formData.prize_value_kopeks) / 100).toFixed(2)} RUB

{/* Emoji */}
setFormData({ ...formData, emoji: e.target.value })} maxLength={10} className="input w-full text-center text-2xl" />
{/* Color */} setFormData({ ...formData, color })} />
{/* Active toggle */}
setFormData({ ...formData, is_active: e.target.checked })} className="rounded border-dark-600" />
{/* Promocode settings */} {formData.prize_type === 'promocode' && (

{t('admin.wheel.prizes.promo.title')}

setFormData({ ...formData, promo_balance_bonus_kopeks: e.target.value === '' ? '' : parseInt(e.target.value) || 0, }) } min={0} className="input w-full" />
setFormData({ ...formData, promo_subscription_days: e.target.value === '' ? '' : parseInt(e.target.value) || 0, }) } min={0} className="input w-full" />
)} {/* Action buttons */}
); }