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'; // Icons 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'; // ============ Sortable Prize Card ============ 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 */} {isExpanded ? : } {/* 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 && ( navigate('/admin')} className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600" > )} {t('admin.wheel.title')} {config.is_enabled ? t('admin.wheel.enabled') : t('admin.wheel.disabled')} {/* Tabs */} setActiveTab('settings')} className={`flex items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm transition-colors sm:gap-2 sm:px-4 sm:text-base ${ activeTab === 'settings' ? 'border-b-2 border-accent-500 bg-dark-800 text-accent-400' : 'text-dark-400 hover:text-dark-200' }`} > {t('admin.wheel.tabs.settings')} setActiveTab('prizes')} className={`flex items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm transition-colors sm:gap-2 sm:px-4 sm:text-base ${ activeTab === 'prizes' ? 'border-b-2 border-accent-500 bg-dark-800 text-accent-400' : 'text-dark-400 hover:text-dark-200' }`} > {t('admin.wheel.tabs.prizes')} ({config.prizes.length}) setActiveTab('statistics')} className={`flex items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm transition-colors sm:gap-2 sm:px-4 sm:text-base ${ activeTab === 'statistics' ? 'border-b-2 border-accent-500 bg-dark-800 text-accent-400' : 'text-dark-400 hover:text-dark-200' }`} > {t('admin.wheel.tabs.statistics')} {/* Settings Tab */} {activeTab === 'settings' && ( {/* Enable toggle */} {t('admin.wheel.settings.enableWheel')} {t('admin.wheel.settings.allowSpins')} setSettingsForm((prev) => (prev ? { ...prev, is_enabled: !prev.is_enabled } : null)) } className={`relative h-6 w-11 rounded-full transition-colors ${ (settingsForm?.is_enabled ?? config.is_enabled) ? 'bg-accent-500' : 'bg-dark-600' }`} > {/* Spin Cost Section */} {t('admin.wheel.settings.spinCost')} {t('admin.wheel.settings.costInStars')} { 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" /> setSettingsForm((prev) => prev ? { ...prev, spin_cost_stars_enabled: e.target.checked } : null, ) } className="rounded border-dark-600" /> {t('admin.wheel.enabled')} {t('admin.wheel.settings.costInDays')} { 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" /> setSettingsForm((prev) => prev ? { ...prev, spin_cost_days_enabled: e.target.checked } : null, ) } className="rounded border-dark-600" /> {t('admin.wheel.enabled')} {/* Limits & RTP Section */} {t('admin.wheel.settings.limitsAndRtp')} {t('admin.wheel.settings.rtpPercent')} setSettingsForm((prev) => prev ? { ...prev, rtp_percent: parseInt(e.target.value) } : null, ) } className="w-full" /> 0% {settingsForm?.rtp_percent ?? config.rtp_percent}% 100% {t('admin.wheel.settings.dailyLimit')} { 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" /> {t('admin.wheel.settings.minSubDays')} { 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')} {t('admin.wheel.settings.promoPrefix')} setSettingsForm((prev) => prev ? { ...prev, promo_prefix: e.target.value } : null, ) } maxLength={20} className="input w-full" /> {/* Save Button */} {hasSettingsChanges && ( { if (!settingsForm) return; if (settingsForm.spin_cost_stars_enabled) { try { const methods = await adminPaymentMethodsApi.getAll(); const starsMethod = methods.find((m) => m.method_id === 'telegram_stars'); if (!starsMethod?.is_enabled) { notify.warning(t('admin.wheel.starsNotEnabledGlobally')); return; } } catch { // If we can't check, allow saving } } updateConfigMutation.mutate({ ...settingsForm, spin_cost_stars: toNumber(settingsForm.spin_cost_stars, config.spin_cost_stars), spin_cost_days: toNumber(settingsForm.spin_cost_days, config.spin_cost_days), daily_spin_limit: toNumber( settingsForm.daily_spin_limit, config.daily_spin_limit, ), min_subscription_days_for_day_payment: toNumber( settingsForm.min_subscription_days_for_day_payment, config.min_subscription_days_for_day_payment, ), }); }} disabled={updateConfigMutation.isPending} className="btn-primary flex items-center gap-2" > {updateConfigMutation.isPending ? ( ) : ( )} {t('common.save')} )} )} {/* Prizes Tab */} {activeTab === 'prizes' && ( {/* Prize list */} {t('admin.wheel.prizes.dragToReorder')} setIsCreating(true)} className="btn-primary flex flex-shrink-0 items-center gap-2" > {t('admin.wheel.prizes.addPrize')} {/* Unsaved order changes banner */} {hasUnsavedOrder && ( {t('admin.wheel.prizes.unsavedOrder') || 'Есть несохраненные изменения порядка'} {t('admin.wheel.prizes.unsavedOrderHint') || 'Сохраните изменения или отмените их'} {t('common.cancel') || 'Отменить'} {reorderPrizesMutation.isPending ? t('common.saving') || 'Сохранение...' : t('common.save') || 'Сохранить'} )} {/* 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.FormEvent) => { 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 */} {t('admin.wheel.prizes.fields.type')} setFormData({ ...formData, prize_type: e.target.value })} className="input w-full" > {PRIZE_TYPE_KEYS.map((type) => ( {type.emoji} {t(`admin.wheel.prizes.types.${type.key}`)} ))} {/* Display name */} {t('admin.wheel.prizes.fields.displayName')} 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' && ( {t('admin.wheel.prizes.fields.value')} ( {formData.prize_type === 'balance_bonus' ? 'kopeks' : formData.prize_type === 'subscription_days' ? 'days' : 'GB'} ) { 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) */} {t('admin.wheel.prizes.fields.valueKopeks')} { 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 */} {t('admin.wheel.prizes.fields.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" /> {t('admin.wheel.prizes.fields.active')} {/* Promocode settings */} {formData.prize_type === 'promocode' && ( {t('admin.wheel.prizes.promo.title')} {t('admin.wheel.prizes.promo.balanceBonus')} setFormData({ ...formData, promo_balance_bonus_kopeks: e.target.value === '' ? '' : parseInt(e.target.value) || 0, }) } min={0} className="input w-full" /> {t('admin.wheel.prizes.promo.subscriptionDays')} setFormData({ ...formData, promo_subscription_days: e.target.value === '' ? '' : parseInt(e.target.value) || 0, }) } min={0} className="input w-full" /> )} {/* Action buttons */} {t('common.cancel')} {isLoading ? ( ) : ( )} {prize ? t('common.save') : t('common.confirm')} ); }
{t('admin.wheel.settings.allowSpins')}
{t('admin.wheel.prizes.dragToReorder')}
{t('admin.wheel.prizes.unsavedOrder') || 'Есть несохраненные изменения порядка'}
{t('admin.wheel.prizes.unsavedOrderHint') || 'Сохраните изменения или отмените их'}
= {(toNumber(formData.prize_value_kopeks) / 100).toFixed(2)} RUB