diff --git a/src/pages/AdminWheel.tsx b/src/pages/AdminWheel.tsx index 7b18d3c..cf63b52 100644 --- a/src/pages/AdminWheel.tsx +++ b/src/pages/AdminWheel.tsx @@ -1,6 +1,23 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { useTelegramDnd } from '../hooks/useTelegramDnd'; +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 { AdminBackButton } from '../components/admin'; import { useDestructiveConfirm } from '@/platform'; @@ -146,6 +163,108 @@ const PRIZE_TYPE_KEYS = [ 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 */} +
+ + +
+
+ + {/* Expanded edit form */} + {isExpanded && ( +
+ +
+ )} +
+ ); +} + export default function AdminWheel() { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -154,10 +273,18 @@ export default function AdminWheel() { const [expandedPrizeId, setExpandedPrizeId] = useState(null); const [isCreating, setIsCreating] = useState(false); - // Drag and drop state - const [draggedPrizeId, setDraggedPrizeId] = useState(null); - const [dragOverPrizeId, setDragOverPrizeId] = useState(null); - const dragRef = useRef(null); + // 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({ @@ -172,11 +299,47 @@ export default function AdminWheel() { 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'] }); + setSettingsForm(null); // Reset form so it reloads from config }, }); @@ -229,58 +392,33 @@ export default function AdminWheel() { [confirmDelete, deletePrizeMutation, t], ); - // Drag and drop handlers - const handleDragStart = useCallback((e: React.DragEvent, prizeId: number) => { - setDraggedPrizeId(prizeId); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', prizeId.toString()); - if (dragRef.current) { - dragRef.current.style.opacity = '0.5'; - } - }, []); + // DnD sensors - PointerSensor handles both mouse and touch + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); - const handleDragEnd = useCallback(() => { - setDraggedPrizeId(null); - setDragOverPrizeId(null); - if (dragRef.current) { - dragRef.current.style.opacity = '1'; - } - }, []); + // Telegram DnD support + const { + onDragStart: onTelegramDragStart, + onDragEnd: onTelegramDragEnd, + onDragCancel: onTelegramDragCancel, + } = useTelegramDnd(); - const handleDragOver = useCallback((e: React.DragEvent, prizeId: number) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - setDragOverPrizeId(prizeId); - }, []); - - const handleDragLeave = useCallback(() => { - setDragOverPrizeId(null); - }, []); - - const handleDrop = useCallback( - (e: React.DragEvent, targetPrizeId: number) => { - e.preventDefault(); - setDragOverPrizeId(null); - - if (!draggedPrizeId || draggedPrizeId === targetPrizeId || !config) return; - - const prizes = [...config.prizes]; - const draggedIndex = prizes.findIndex((p) => p.id === draggedPrizeId); - const targetIndex = prizes.findIndex((p) => p.id === targetPrizeId); - - if (draggedIndex === -1 || targetIndex === -1) return; - - // Reorder the array - const [draggedPrize] = prizes.splice(draggedIndex, 1); - prizes.splice(targetIndex, 0, draggedPrize); - - // Get new order of IDs - const newOrder = prizes.map((p) => p.id); - reorderPrizesMutation.mutate(newOrder); - - setDraggedPrizeId(null); + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + onTelegramDragEnd(); + const { active, over } = event; + if (over && active.id !== over.id && config) { + const oldIndex = config.prizes.findIndex((p) => p.id === active.id); + const newIndex = config.prizes.findIndex((p) => p.id === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + const newOrder = arrayMove(config.prizes, oldIndex, newIndex); + reorderPrizesMutation.mutate(newOrder.map((p) => p.id)); + } + } }, - [draggedPrizeId, config, reorderPrizesMutation], + [config, onTelegramDragEnd, reorderPrizesMutation], ); if (isLoading) { @@ -301,7 +439,7 @@ export default function AdminWheel() {
-

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

+

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

{/* Tabs */} -
- - - +
+
+ + + +
{/* Settings Tab */} @@ -364,15 +504,23 @@ export default function AdminWheel() {

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

-

@@ -391,11 +539,11 @@ export default function AdminWheel() {
- updateConfigMutation.mutate({ - spin_cost_stars: parseInt(e.target.value) || 1, - }) + setSettingsForm((prev) => + prev ? { ...prev, spin_cost_stars: parseInt(e.target.value) || 1 } : null, + ) } min={1} max={1000} @@ -404,9 +552,13 @@ export default function AdminWheel() { - updateConfigMutation.mutate({ daily_spin_limit: parseInt(e.target.value) || 0 }) + setSettingsForm((prev) => + prev ? { ...prev, daily_spin_limit: parseInt(e.target.value) || 0 } : null, + ) } min={0} max={100} @@ -498,11 +662,19 @@ export default function AdminWheel() { - updateConfigMutation.mutate({ - min_subscription_days_for_day_payment: parseInt(e.target.value) || 1, - }) + setSettingsForm((prev) => + prev + ? { + ...prev, + min_subscription_days_for_day_payment: parseInt(e.target.value) || 1, + } + : null, + ) } min={1} max={30} @@ -527,14 +699,40 @@ export default function AdminWheel() { updateConfigMutation.mutate({ promo_prefix: e.target.value })} + value={settingsForm?.promo_prefix ?? config.promo_prefix} + onChange={(e) => + setSettingsForm((prev) => + prev ? { ...prev, promo_prefix: e.target.value } : null, + ) + } maxLength={20} className="input w-full" />
+ + {/* Save Button */} + {hasSettingsChanges && ( +
+ +
+ )} )} @@ -543,14 +741,14 @@ export default function AdminWheel() {
{/* Prize list */}
-
+

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

@@ -566,91 +764,34 @@ export default function AdminWheel() { /> )} - {/* Prize list */} -
- {config.prizes.map((prize) => ( -
handleDragStart(e, prize.id)} - onDragEnd={handleDragEnd} - onDragOver={(e) => handleDragOver(e, prize.id)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, prize.id)} - className={`card overflow-hidden transition-all duration-200 ${ - !prize.is_active ? 'opacity-50' : '' - } ${dragOverPrizeId === prize.id ? 'ring-2 ring-accent-500 ring-offset-2 ring-offset-dark-900' : ''} ${ - draggedPrizeId === prize.id ? 'opacity-50' : '' - }`} - > - {/* Prize header - always visible */} -
- {/* Drag handle */} -
- -
- - {/* Prize icon */} -
- {prize.emoji} -
- - {/* Prize info */} -
-
{prize.display_name}
-
- {t(`admin.wheel.prizes.types.${prize.prize_type}`)} •{' '} - {t('admin.wheel.prizes.fields.value')}: {prize.prize_value} •{' '} - {(prize.prize_value_kopeks / 100).toFixed(2)}₽ -
-
- - {/* Actions */} -
- - -
-
- - {/* Expanded edit form */} - {expandedPrizeId === prize.id && ( -
- { - updatePrizeMutation.mutate({ id: prize.id, data }); - }} - onCancel={() => setExpandedPrizeId(null)} - isLoading={updatePrizeMutation.isPending} - /> -
- )} + {/* Prize list with DnD */} + + p.id)} + strategy={verticalListSortingStrategy} + > +
+ {config.prizes.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 && (
diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 4b24e0d..85b4a9d 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -73,7 +73,6 @@ export default function Wheel() { const [paymentType, setPaymentType] = useState<'telegram_stars' | 'subscription_days'>( 'telegram_stars', ); - const [showHistory, setShowHistory] = useState(false); const [isPayingStars, setIsPayingStars] = useState(false); // Check if we're in Telegram Mini App environment @@ -91,7 +90,6 @@ export default function Wheel() { const { data: history } = useQuery({ queryKey: ['wheel-history'], queryFn: () => wheelApi.getHistory(1, 10), - enabled: showHistory, }); // Auto-select payment type based on availability @@ -481,17 +479,6 @@ export default function Wheel() {
)}
- -
@@ -729,64 +716,56 @@ export default function Wheel() {
- {/* History Sidebar */} - {showHistory && ( -
-
-
-

- - {t('wheel.recentSpins')} -

- -
+ {/* History Sidebar - always visible on desktop */} +
+
+
+

+ + {t('wheel.recentSpins')} +

+
- {history && history.items.length > 0 ? ( -
- {history.items.map((item: SpinHistoryItem, index: number) => ( + {history && history.items.length > 0 ? ( +
+ {history.items.map((item: SpinHistoryItem, index: number) => ( +
-
- {item.emoji} + {item.emoji} +
+
+
+ {item.prize_display_name}
-
-
- {item.prize_display_name} -
-
- {new Date(item.created_at).toLocaleDateString()} -
-
-
- - - {item.payment_type === 'telegram_stars' - ? `${item.payment_amount} ⭐` - : `${item.payment_amount}${t('wheel.days').charAt(0)}`} +
+ {new Date(item.created_at).toLocaleDateString()}
- ))} -
- ) : ( -
-
🎰
- {t('wheel.noHistory')} -
- )} -
+
+ - + {item.payment_type === 'telegram_stars' + ? `${item.payment_amount} ⭐` + : `${item.payment_amount}${t('wheel.days').charAt(0)}`} +
+
+ ))} +
+ ) : ( +
+
🎰
+ {t('wheel.noHistory')} +
+ )}
- )} +
);