From 7e2802c5b5ee4bae1cf8d07009056e9c66688197 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 2 Feb 2026 17:51:46 +0300 Subject: [PATCH] feat: redesign fortune wheel UI and add to mobile nav - Add wheel button to mobile bottom nav (replaces Support when enabled) - Slow down LED lights animation (250ms -> 600ms) - Replace result modal with inline card in Wheel.tsx - Add haptic feedback on spin result - AdminWheel.tsx: inline prize editing instead of modal - AdminWheel.tsx: drag-and-drop for prize reordering - AdminWheel.tsx: live wheel preview on desktop - AdminWheel.tsx: group settings into sections - Replace confirm() with native dialog - Add translations for all languages --- src/components/layout/AppShell/AppShell.tsx | 3 +- .../layout/AppShell/MobileBottomNav.tsx | 14 +- src/components/wheel/FortuneWheel.tsx | 2 +- src/locales/en.json | 9 +- src/locales/fa.json | 11 +- src/locales/ru.json | 9 +- src/locales/zh.json | 9 +- src/pages/AdminWheel.tsx | 972 +++++++++++------- src/pages/Wheel.tsx | 183 ++-- 9 files changed, 740 insertions(+), 472 deletions(-) diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index f857cdc..bb8f401 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -300,7 +300,7 @@ export function AppShell({ children }: AppShellProps) { // BackButton for Telegram Mini App // Don't show back button on main tab pages (bottom nav) - users navigate via tabs - const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support']; + const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support', '/wheel']; const isMainTabPage = mainTabPaths.includes(location.pathname); const handleBack = useCallback(() => { if (mobileMenuOpen) { @@ -505,6 +505,7 @@ export function AppShell({ children }: AppShellProps) { ); diff --git a/src/components/layout/AppShell/MobileBottomNav.tsx b/src/components/layout/AppShell/MobileBottomNav.tsx index 4b6198e..a51cc30 100644 --- a/src/components/layout/AppShell/MobileBottomNav.tsx +++ b/src/components/layout/AppShell/MobileBottomNav.tsx @@ -6,14 +6,19 @@ import { cn } from '@/lib/utils'; import { usePlatform } from '@/platform'; // Icons -import { HomeIcon, SubscriptionIcon, WalletIcon, UsersIcon, ChatIcon } from './icons'; +import { HomeIcon, SubscriptionIcon, WalletIcon, UsersIcon, ChatIcon, WheelIcon } from './icons'; interface MobileBottomNavProps { isKeyboardOpen: boolean; referralEnabled?: boolean; + wheelEnabled?: boolean; } -export function MobileBottomNav({ isKeyboardOpen, referralEnabled }: MobileBottomNavProps) { +export function MobileBottomNav({ + isKeyboardOpen, + referralEnabled, + wheelEnabled, +}: MobileBottomNavProps) { const { t } = useTranslation(); const location = useLocation(); const { haptic } = usePlatform(); @@ -21,12 +26,15 @@ export function MobileBottomNav({ isKeyboardOpen, referralEnabled }: MobileBotto const isActive = (path: string) => location.pathname === path; // Core navigation items for bottom bar + // When wheel is enabled, it replaces Support in the bottom nav (Support is still accessible via hamburger menu) const coreItems = [ { path: '/', label: t('nav.dashboard'), icon: HomeIcon }, { path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon }, { path: '/balance', label: t('nav.balance'), icon: WalletIcon }, ...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []), - { path: '/support', label: t('nav.support'), icon: ChatIcon }, + ...(wheelEnabled + ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] + : [{ path: '/support', label: t('nav.support'), icon: ChatIcon }]), ]; const handleNavClick = () => { diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index b5b7429..691ee87 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -30,7 +30,7 @@ const FortuneWheel = memo(function FortuneWheel({ if (isSpinning) { const interval = setInterval(() => { setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2 - }, 250); // Slower interval = better performance + }, 600); // Slower interval = better performance return () => clearInterval(interval); } else { setLightPhase(0); diff --git a/src/locales/en.json b/src/locales/en.json index 175463e..0767006 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -19,6 +19,7 @@ "and": "and", "edit": "Edit", "delete": "Delete", + "collapse": "Collapse", "currency": "$", "refresh": "Refresh", "copy": "Copy", @@ -952,21 +953,27 @@ "prizes": "Prizes", "statistics": "Statistics" }, + "preview": "Preview", "settings": { "enableWheel": "Enable Wheel", "allowSpins": "Allow users to spin the wheel", + "spinCost": "Spin Cost", "costInStars": "Cost in Stars", "costInDays": "Cost in Days", + "limitsAndRtp": "Limits & RTP", "rtpPercent": "RTP (Return to Player) %", "dailyLimit": "Daily Spin Limit (0 = unlimited)", "minSubDays": "Min subscription days for day payment", + "promocodes": "Promocodes", "promoPrefix": "Promo code prefix" }, "prizes": { "addPrize": "Add Prize", "editPrize": "Edit Prize", "noPrizes": "No prizes configured. Add some prizes to enable the wheel.", - "deletePrize": "Delete this prize?", + "deletePrize": "Delete Prize", + "confirmDelete": "Are you sure you want to delete this prize? This action cannot be undone.", + "dragToReorder": "Drag to reorder", "types": { "subscription_days": "Subscription Days", "balance_bonus": "Balance Bonus", diff --git a/src/locales/fa.json b/src/locales/fa.json index 7e01b65..f459e69 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -29,7 +29,8 @@ "refresh": "بازخوانی", "retry": "تلاش مجدد", "saving": "در حال ذخیره...", - "understand": "متوجه شدم" + "understand": "متوجه شدم", + "collapse": "جمع کردن" }, "nav": { "dashboard": "داشبورد", @@ -676,6 +677,7 @@ }, "wheel": { "title": "تنظیمات چرخ شانس", + "preview": "پیش‌نمایش", "enabled": "فعال", "disabled": "غیرفعال", "tabs": { @@ -691,13 +693,18 @@ "rtpPercent": "درصد RTP", "dailyLimit": "محدودیت روزانه (0 = نامحدود)", "minSubDays": "حداقل روز اشتراک برای پرداخت روزانه", - "promoPrefix": "پیشوند کد تخفیف" + "promoPrefix": "پیشوند کد تخفیف", + "spinCost": "هزینه چرخش", + "limitsAndRtp": "محدودیت‌ها و RTP", + "promocodes": "کدهای تخفیف" }, "prizes": { "addPrize": "افزودن جایزه", "editPrize": "ویرایش جایزه", "noPrizes": "جایزه‌ای تنظیم نشده. برای فعال‌سازی چرخ جایزه اضافه کنید.", "deletePrize": "این جایزه حذف شود؟", + "dragToReorder": "کشیدن برای مرتب‌سازی", + "confirmDelete": "آیا از حذف این جایزه مطمئن هستید؟", "types": { "subscription_days": "روز اشتراک", "balance_bonus": "جایزه موجودی", diff --git a/src/locales/ru.json b/src/locales/ru.json index ebee1ba..3e4b9ad 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -20,6 +20,7 @@ "and": "и", "edit": "Редактировать", "delete": "Удалить", + "collapse": "Свернуть", "currency": "₽", "refresh": "Обновить", "copy": "Копировать", @@ -971,21 +972,27 @@ "prizes": "Призы", "statistics": "Статистика" }, + "preview": "Предпросмотр", "settings": { "enableWheel": "Включить колесо", "allowSpins": "Разрешить пользователям крутить колесо", + "spinCost": "Стоимость вращения", "costInStars": "Стоимость в Stars", "costInDays": "Стоимость в днях", + "limitsAndRtp": "Лимиты и RTP", "rtpPercent": "RTP (Return to Player) %", "dailyLimit": "Дневной лимит вращений (0 = безлимит)", "minSubDays": "Мин. дней подписки для оплаты днями", + "promocodes": "Промокоды", "promoPrefix": "Префикс промокодов" }, "prizes": { "addPrize": "Добавить приз", "editPrize": "Редактировать приз", "noPrizes": "Призы не настроены. Добавьте призы для активации колеса.", - "deletePrize": "Удалить этот приз?", + "deletePrize": "Удалить приз", + "confirmDelete": "Вы уверены, что хотите удалить этот приз? Это действие нельзя отменить.", + "dragToReorder": "Перетащите для изменения порядка", "types": { "subscription_days": "Дни подписки", "balance_bonus": "Бонус баланса", diff --git a/src/locales/zh.json b/src/locales/zh.json index 2bac504..a95337f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -26,6 +26,7 @@ "retry": "重试", "saving": "保存中...", "understand": "明白了", + "collapse": "收起", "units": { "gb": "GB", "perGb": "/GB" @@ -728,6 +729,7 @@ }, "wheel": { "title": "幸运转盘设置", + "preview": "预览", "enabled": "已启用", "disabled": "已禁用", "tabs": { @@ -743,13 +745,18 @@ "rtpPercent": "RTP (返还率) %", "dailyLimit": "每日限制 (0 = 无限)", "minSubDays": "天数支付最低订阅天数", - "promoPrefix": "优惠码前缀" + "promoPrefix": "优惠码前缀", + "spinCost": "旋转费用", + "limitsAndRtp": "限制和RTP", + "promocodes": "促销码" }, "prizes": { "addPrize": "添加奖品", "editPrize": "编辑奖品", "noPrizes": "未配置奖品。添加奖品以启用转盘。", "deletePrize": "删除此奖品?", + "dragToReorder": "拖动排序", + "confirmDelete": "确定要删除这个奖品吗?", "types": { "subscription_days": "订阅天数", "balance_bonus": "余额奖励", diff --git a/src/pages/AdminWheel.tsx b/src/pages/AdminWheel.tsx index 4350d0a..7b18d3c 100644 --- a/src/pages/AdminWheel.tsx +++ b/src/pages/AdminWheel.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel'; import { AdminBackButton } from '../components/admin'; +import { useDestructiveConfirm } from '@/platform'; +import FortuneWheel from '../components/wheel/FortuneWheel'; // Icons @@ -52,6 +54,88 @@ 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: '💰' }, @@ -65,10 +149,16 @@ type Tab = 'settings' | 'prizes' | 'statistics'; export default function AdminWheel() { const { t } = useTranslation(); const queryClient = useQueryClient(); + const confirmDelete = useDestructiveConfirm(); const [activeTab, setActiveTab] = useState('settings'); - const [editingPrize, setEditingPrize] = useState(null); + 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); + // Fetch config const { data: config, isLoading } = useQuery({ queryKey: ['admin-wheel-config'], @@ -104,10 +194,19 @@ export default function AdminWheel() { adminWheelApi.updatePrize(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] }); - setEditingPrize(null); + setExpandedPrizeId(null); }, }); + // Reorder prizes mutation + const reorderPrizesMutation = useMutation({ + mutationFn: adminWheelApi.reorderPrizes, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] }); + }, + }); + + // Delete prize mutation const deletePrizeMutation = useMutation({ mutationFn: adminWheelApi.deletePrize, onSuccess: () => { @@ -115,6 +214,75 @@ export default function AdminWheel() { }, }); + // 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], + ); + + // 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'; + } + }, []); + + const handleDragEnd = useCallback(() => { + setDraggedPrizeId(null); + setDragOverPrizeId(null); + if (dragRef.current) { + dragRef.current.style.opacity = '1'; + } + }, []); + + 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); + }, + [draggedPrizeId, config, reorderPrizesMutation], + ); + if (isLoading) { return (
@@ -209,139 +377,162 @@ export default function AdminWheel() {
- {/* Spin costs */} -
-
- -
- - updateConfigMutation.mutate({ spin_cost_stars: parseInt(e.target.value) || 1 }) - } - min={1} - max={1000} - className="input flex-1" - /> -