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 { AdjustmentsIcon, BackIcon, ChartIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon, CogIcon, GiftIcon, GripVerticalIcon, PlusIcon, StarIcon, TicketIcon, TrashIcon, WalletIcon, WheelIcon, XMarkIcon, } from '@/components/icons'; import { StatCard } from '@/components/stats'; import { BreakdownList } from '@/components/sales-stats/BreakdownList'; import { useCurrency } from '@/hooks/useCurrency'; import { usePlatform } from '../platform/hooks/usePlatform'; import { toNumber } from '../utils/inputHelpers'; 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 { formatWithCurrency } = useCurrency(); 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; promo_validity_days: number | ''; } | null>(null); // Fetch config const { data: config, isLoading } = useQuery({ queryKey: ['admin-wheel-config'], queryFn: adminWheelApi.getConfig, }); // Fetch statistics const { data: stats, isLoading: statsLoading, error: statsError, } = 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, promo_validity_days: config.promo_validity_days, }; }); } }, [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 || settingsForm.promo_validity_days !== config.promo_validity_days); // 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" />
{ const val = e.target.value; setSettingsForm((prev) => prev ? { ...prev, promo_validity_days: val === '' ? '' : parseInt(val) || 0 } : null, ); }} min={1} max={365} 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' && statsLoading && (
)} {activeTab === 'statistics' && !statsLoading && statsError && (
{t('admin.wheel.statistics.loadError')}
)} {activeTab === 'statistics' && !statsLoading && !statsError && stats && stats.total_spins === 0 && (
{t('admin.wheel.statistics.empty')}
)} {activeTab === 'statistics' && stats && stats.total_spins > 0 && (
{/* Headline metrics โ€” shared StatCard, like the rest of the admin stats */}
} tone="accent" /> } tone="success" /> } tone="warning" /> } tone={stats.actual_rtp_percent <= stats.configured_rtp_percent ? 'success' : 'error'} />
{/* Prize distribution โ€” shared BreakdownList (ranked bars + share %) */} {stats.prizes_distribution.length > 0 && ( ({ key: prize.display_name, label: prize.display_name, value: prize.count, }))} valueFormatter={(v) => t('admin.wheel.statistics.times', { count: v })} /> )} {/* Top wins โ€” same BreakdownList, ranked by win value */} {stats.top_wins.length > 0 && ( ({ key: `${win.user_id}-${i}`, label: `${win.username || `#${win.user_id}`} ยท ${win.prize_display_name}`, value: win.prize_value_kopeks / 100, }))} valueFormatter={(v) => formatWithCurrency(v, 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" />
{/* Manual probability override (optional; empty = auto by RTP) */}
{ const val = e.target.value; setFormData({ ...formData, manual_probability: val === '' ? null : Math.min(1, Math.max(0, parseFloat(val) || 0)), }); }} min={0} max={1} step={0.01} placeholder="0.00 โ€“ 1.00" className="input w-full sm:w-1/2" />

{t('admin.wheel.prizes.fields.probabilityHint')}

{/* 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 */}
); }