mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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
This commit is contained in:
@@ -300,7 +300,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
|
|
||||||
// BackButton for Telegram Mini App
|
// BackButton for Telegram Mini App
|
||||||
// Don't show back button on main tab pages (bottom nav) - users navigate via tabs
|
// 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 isMainTabPage = mainTabPaths.includes(location.pathname);
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (mobileMenuOpen) {
|
if (mobileMenuOpen) {
|
||||||
@@ -505,6 +505,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
<MobileBottomNav
|
<MobileBottomNav
|
||||||
isKeyboardOpen={isKeyboardOpen}
|
isKeyboardOpen={isKeyboardOpen}
|
||||||
referralEnabled={referralTerms?.is_enabled}
|
referralEnabled={referralTerms?.is_enabled}
|
||||||
|
wheelEnabled={wheelConfig?.is_enabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ import { cn } from '@/lib/utils';
|
|||||||
import { usePlatform } from '@/platform';
|
import { usePlatform } from '@/platform';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import { HomeIcon, SubscriptionIcon, WalletIcon, UsersIcon, ChatIcon } from './icons';
|
import { HomeIcon, SubscriptionIcon, WalletIcon, UsersIcon, ChatIcon, WheelIcon } from './icons';
|
||||||
|
|
||||||
interface MobileBottomNavProps {
|
interface MobileBottomNavProps {
|
||||||
isKeyboardOpen: boolean;
|
isKeyboardOpen: boolean;
|
||||||
referralEnabled?: boolean;
|
referralEnabled?: boolean;
|
||||||
|
wheelEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileBottomNav({ isKeyboardOpen, referralEnabled }: MobileBottomNavProps) {
|
export function MobileBottomNav({
|
||||||
|
isKeyboardOpen,
|
||||||
|
referralEnabled,
|
||||||
|
wheelEnabled,
|
||||||
|
}: MobileBottomNavProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { haptic } = usePlatform();
|
const { haptic } = usePlatform();
|
||||||
@@ -21,12 +26,15 @@ export function MobileBottomNav({ isKeyboardOpen, referralEnabled }: MobileBotto
|
|||||||
const isActive = (path: string) => location.pathname === path;
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
// Core navigation items for bottom bar
|
// 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 = [
|
const coreItems = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||||
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||||
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||||
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
...(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 = () => {
|
const handleNavClick = () => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
if (isSpinning) {
|
if (isSpinning) {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2
|
setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2
|
||||||
}, 250); // Slower interval = better performance
|
}, 600); // Slower interval = better performance
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
} else {
|
} else {
|
||||||
setLightPhase(0);
|
setLightPhase(0);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"and": "and",
|
"and": "and",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"collapse": "Collapse",
|
||||||
"currency": "$",
|
"currency": "$",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
@@ -952,21 +953,27 @@
|
|||||||
"prizes": "Prizes",
|
"prizes": "Prizes",
|
||||||
"statistics": "Statistics"
|
"statistics": "Statistics"
|
||||||
},
|
},
|
||||||
|
"preview": "Preview",
|
||||||
"settings": {
|
"settings": {
|
||||||
"enableWheel": "Enable Wheel",
|
"enableWheel": "Enable Wheel",
|
||||||
"allowSpins": "Allow users to spin the wheel",
|
"allowSpins": "Allow users to spin the wheel",
|
||||||
|
"spinCost": "Spin Cost",
|
||||||
"costInStars": "Cost in Stars",
|
"costInStars": "Cost in Stars",
|
||||||
"costInDays": "Cost in Days",
|
"costInDays": "Cost in Days",
|
||||||
|
"limitsAndRtp": "Limits & RTP",
|
||||||
"rtpPercent": "RTP (Return to Player) %",
|
"rtpPercent": "RTP (Return to Player) %",
|
||||||
"dailyLimit": "Daily Spin Limit (0 = unlimited)",
|
"dailyLimit": "Daily Spin Limit (0 = unlimited)",
|
||||||
"minSubDays": "Min subscription days for day payment",
|
"minSubDays": "Min subscription days for day payment",
|
||||||
|
"promocodes": "Promocodes",
|
||||||
"promoPrefix": "Promo code prefix"
|
"promoPrefix": "Promo code prefix"
|
||||||
},
|
},
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "Add Prize",
|
"addPrize": "Add Prize",
|
||||||
"editPrize": "Edit Prize",
|
"editPrize": "Edit Prize",
|
||||||
"noPrizes": "No prizes configured. Add some prizes to enable the wheel.",
|
"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": {
|
"types": {
|
||||||
"subscription_days": "Subscription Days",
|
"subscription_days": "Subscription Days",
|
||||||
"balance_bonus": "Balance Bonus",
|
"balance_bonus": "Balance Bonus",
|
||||||
|
|||||||
@@ -29,7 +29,8 @@
|
|||||||
"refresh": "بازخوانی",
|
"refresh": "بازخوانی",
|
||||||
"retry": "تلاش مجدد",
|
"retry": "تلاش مجدد",
|
||||||
"saving": "در حال ذخیره...",
|
"saving": "در حال ذخیره...",
|
||||||
"understand": "متوجه شدم"
|
"understand": "متوجه شدم",
|
||||||
|
"collapse": "جمع کردن"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "داشبورد",
|
"dashboard": "داشبورد",
|
||||||
@@ -676,6 +677,7 @@
|
|||||||
},
|
},
|
||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "تنظیمات چرخ شانس",
|
"title": "تنظیمات چرخ شانس",
|
||||||
|
"preview": "پیشنمایش",
|
||||||
"enabled": "فعال",
|
"enabled": "فعال",
|
||||||
"disabled": "غیرفعال",
|
"disabled": "غیرفعال",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -691,13 +693,18 @@
|
|||||||
"rtpPercent": "درصد RTP",
|
"rtpPercent": "درصد RTP",
|
||||||
"dailyLimit": "محدودیت روزانه (0 = نامحدود)",
|
"dailyLimit": "محدودیت روزانه (0 = نامحدود)",
|
||||||
"minSubDays": "حداقل روز اشتراک برای پرداخت روزانه",
|
"minSubDays": "حداقل روز اشتراک برای پرداخت روزانه",
|
||||||
"promoPrefix": "پیشوند کد تخفیف"
|
"promoPrefix": "پیشوند کد تخفیف",
|
||||||
|
"spinCost": "هزینه چرخش",
|
||||||
|
"limitsAndRtp": "محدودیتها و RTP",
|
||||||
|
"promocodes": "کدهای تخفیف"
|
||||||
},
|
},
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "افزودن جایزه",
|
"addPrize": "افزودن جایزه",
|
||||||
"editPrize": "ویرایش جایزه",
|
"editPrize": "ویرایش جایزه",
|
||||||
"noPrizes": "جایزهای تنظیم نشده. برای فعالسازی چرخ جایزه اضافه کنید.",
|
"noPrizes": "جایزهای تنظیم نشده. برای فعالسازی چرخ جایزه اضافه کنید.",
|
||||||
"deletePrize": "این جایزه حذف شود؟",
|
"deletePrize": "این جایزه حذف شود؟",
|
||||||
|
"dragToReorder": "کشیدن برای مرتبسازی",
|
||||||
|
"confirmDelete": "آیا از حذف این جایزه مطمئن هستید؟",
|
||||||
"types": {
|
"types": {
|
||||||
"subscription_days": "روز اشتراک",
|
"subscription_days": "روز اشتراک",
|
||||||
"balance_bonus": "جایزه موجودی",
|
"balance_bonus": "جایزه موجودی",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"and": "и",
|
"and": "и",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
|
"collapse": "Свернуть",
|
||||||
"currency": "₽",
|
"currency": "₽",
|
||||||
"refresh": "Обновить",
|
"refresh": "Обновить",
|
||||||
"copy": "Копировать",
|
"copy": "Копировать",
|
||||||
@@ -971,21 +972,27 @@
|
|||||||
"prizes": "Призы",
|
"prizes": "Призы",
|
||||||
"statistics": "Статистика"
|
"statistics": "Статистика"
|
||||||
},
|
},
|
||||||
|
"preview": "Предпросмотр",
|
||||||
"settings": {
|
"settings": {
|
||||||
"enableWheel": "Включить колесо",
|
"enableWheel": "Включить колесо",
|
||||||
"allowSpins": "Разрешить пользователям крутить колесо",
|
"allowSpins": "Разрешить пользователям крутить колесо",
|
||||||
|
"spinCost": "Стоимость вращения",
|
||||||
"costInStars": "Стоимость в Stars",
|
"costInStars": "Стоимость в Stars",
|
||||||
"costInDays": "Стоимость в днях",
|
"costInDays": "Стоимость в днях",
|
||||||
|
"limitsAndRtp": "Лимиты и RTP",
|
||||||
"rtpPercent": "RTP (Return to Player) %",
|
"rtpPercent": "RTP (Return to Player) %",
|
||||||
"dailyLimit": "Дневной лимит вращений (0 = безлимит)",
|
"dailyLimit": "Дневной лимит вращений (0 = безлимит)",
|
||||||
"minSubDays": "Мин. дней подписки для оплаты днями",
|
"minSubDays": "Мин. дней подписки для оплаты днями",
|
||||||
|
"promocodes": "Промокоды",
|
||||||
"promoPrefix": "Префикс промокодов"
|
"promoPrefix": "Префикс промокодов"
|
||||||
},
|
},
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "Добавить приз",
|
"addPrize": "Добавить приз",
|
||||||
"editPrize": "Редактировать приз",
|
"editPrize": "Редактировать приз",
|
||||||
"noPrizes": "Призы не настроены. Добавьте призы для активации колеса.",
|
"noPrizes": "Призы не настроены. Добавьте призы для активации колеса.",
|
||||||
"deletePrize": "Удалить этот приз?",
|
"deletePrize": "Удалить приз",
|
||||||
|
"confirmDelete": "Вы уверены, что хотите удалить этот приз? Это действие нельзя отменить.",
|
||||||
|
"dragToReorder": "Перетащите для изменения порядка",
|
||||||
"types": {
|
"types": {
|
||||||
"subscription_days": "Дни подписки",
|
"subscription_days": "Дни подписки",
|
||||||
"balance_bonus": "Бонус баланса",
|
"balance_bonus": "Бонус баланса",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"understand": "明白了",
|
"understand": "明白了",
|
||||||
|
"collapse": "收起",
|
||||||
"units": {
|
"units": {
|
||||||
"gb": "GB",
|
"gb": "GB",
|
||||||
"perGb": "/GB"
|
"perGb": "/GB"
|
||||||
@@ -728,6 +729,7 @@
|
|||||||
},
|
},
|
||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "幸运转盘设置",
|
"title": "幸运转盘设置",
|
||||||
|
"preview": "预览",
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -743,13 +745,18 @@
|
|||||||
"rtpPercent": "RTP (返还率) %",
|
"rtpPercent": "RTP (返还率) %",
|
||||||
"dailyLimit": "每日限制 (0 = 无限)",
|
"dailyLimit": "每日限制 (0 = 无限)",
|
||||||
"minSubDays": "天数支付最低订阅天数",
|
"minSubDays": "天数支付最低订阅天数",
|
||||||
"promoPrefix": "优惠码前缀"
|
"promoPrefix": "优惠码前缀",
|
||||||
|
"spinCost": "旋转费用",
|
||||||
|
"limitsAndRtp": "限制和RTP",
|
||||||
|
"promocodes": "促销码"
|
||||||
},
|
},
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "添加奖品",
|
"addPrize": "添加奖品",
|
||||||
"editPrize": "编辑奖品",
|
"editPrize": "编辑奖品",
|
||||||
"noPrizes": "未配置奖品。添加奖品以启用转盘。",
|
"noPrizes": "未配置奖品。添加奖品以启用转盘。",
|
||||||
"deletePrize": "删除此奖品?",
|
"deletePrize": "删除此奖品?",
|
||||||
|
"dragToReorder": "拖动排序",
|
||||||
|
"confirmDelete": "确定要删除这个奖品吗?",
|
||||||
"types": {
|
"types": {
|
||||||
"subscription_days": "订阅天数",
|
"subscription_days": "订阅天数",
|
||||||
"balance_bonus": "余额奖励",
|
"balance_bonus": "余额奖励",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel';
|
import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
import { useDestructiveConfirm } from '@/platform';
|
||||||
|
import FortuneWheel from '../components/wheel/FortuneWheel';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
|
|
||||||
@@ -52,6 +54,88 @@ const TrashIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const GripVerticalIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChevronDownIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChevronUpIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const XMarkIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const StarIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AdjustmentsIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TicketIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const PRIZE_TYPE_KEYS = [
|
const PRIZE_TYPE_KEYS = [
|
||||||
{ value: 'subscription_days', key: 'subscription_days', emoji: '📅' },
|
{ value: 'subscription_days', key: 'subscription_days', emoji: '📅' },
|
||||||
{ value: 'balance_bonus', key: 'balance_bonus', emoji: '💰' },
|
{ value: 'balance_bonus', key: 'balance_bonus', emoji: '💰' },
|
||||||
@@ -65,10 +149,16 @@ type Tab = 'settings' | 'prizes' | 'statistics';
|
|||||||
export default function AdminWheel() {
|
export default function AdminWheel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const confirmDelete = useDestructiveConfirm();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('settings');
|
const [activeTab, setActiveTab] = useState<Tab>('settings');
|
||||||
const [editingPrize, setEditingPrize] = useState<WheelPrizeAdmin | null>(null);
|
const [expandedPrizeId, setExpandedPrizeId] = useState<number | null>(null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
// Drag and drop state
|
||||||
|
const [draggedPrizeId, setDraggedPrizeId] = useState<number | null>(null);
|
||||||
|
const [dragOverPrizeId, setDragOverPrizeId] = useState<number | null>(null);
|
||||||
|
const dragRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Fetch config
|
// Fetch config
|
||||||
const { data: config, isLoading } = useQuery({
|
const { data: config, isLoading } = useQuery({
|
||||||
queryKey: ['admin-wheel-config'],
|
queryKey: ['admin-wheel-config'],
|
||||||
@@ -104,10 +194,19 @@ export default function AdminWheel() {
|
|||||||
adminWheelApi.updatePrize(id, data),
|
adminWheelApi.updatePrize(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-wheel-config'] });
|
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({
|
const deletePrizeMutation = useMutation({
|
||||||
mutationFn: adminWheelApi.deletePrize,
|
mutationFn: adminWheelApi.deletePrize,
|
||||||
onSuccess: () => {
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[400px] items-center justify-center">
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
@@ -209,7 +377,12 @@ export default function AdminWheel() {
|
|||||||
|
|
||||||
<hr className="border-dark-700" />
|
<hr className="border-dark-700" />
|
||||||
|
|
||||||
{/* Spin costs */}
|
{/* Spin Cost Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-medium text-dark-400">
|
||||||
|
<StarIcon className="h-4 w-4" />
|
||||||
|
{t('admin.wheel.settings.spinCost')}
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||||
@@ -220,7 +393,9 @@ export default function AdminWheel() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={config.spin_cost_stars}
|
value={config.spin_cost_stars}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateConfigMutation.mutate({ spin_cost_stars: parseInt(e.target.value) || 1 })
|
updateConfigMutation.mutate({
|
||||||
|
spin_cost_stars: parseInt(e.target.value) || 1,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
max={1000}
|
max={1000}
|
||||||
@@ -269,10 +444,16 @@ export default function AdminWheel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr className="border-dark-700" />
|
<hr className="border-dark-700" />
|
||||||
|
|
||||||
{/* RTP and limits */}
|
{/* Limits & RTP Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-medium text-dark-400">
|
||||||
|
<AdjustmentsIcon className="h-4 w-4" />
|
||||||
|
{t('admin.wheel.settings.limitsAndRtp')}
|
||||||
|
</h3>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||||
@@ -310,9 +491,7 @@ export default function AdminWheel() {
|
|||||||
className="input w-full"
|
className="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.wheel.settings.minSubDays')}
|
{t('admin.wheel.settings.minSubDays')}
|
||||||
@@ -330,7 +509,18 @@ export default function AdminWheel() {
|
|||||||
className="input w-full"
|
className="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-dark-700" />
|
||||||
|
|
||||||
|
{/* Promocodes Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-medium text-dark-400">
|
||||||
|
<TicketIcon className="h-4 w-4" />
|
||||||
|
{t('admin.wheel.settings.promocodes')}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.wheel.settings.promoPrefix')}
|
{t('admin.wheel.settings.promoPrefix')}
|
||||||
@@ -345,12 +535,16 @@ export default function AdminWheel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Prizes Tab */}
|
{/* Prizes Tab */}
|
||||||
{activeTab === 'prizes' && (
|
{activeTab === 'prizes' && (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr,300px]">
|
||||||
|
{/* Prize list */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-end">
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.wheel.prizes.dragToReorder')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => setIsCreating(true)}
|
||||||
className="btn-primary flex items-center gap-2"
|
className="btn-primary flex items-center gap-2"
|
||||||
@@ -360,52 +554,126 @@ export default function AdminWheel() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create new prize inline form */}
|
||||||
|
{isCreating && (
|
||||||
|
<InlinePrizeForm
|
||||||
|
prize={null}
|
||||||
|
onSave={(data) => {
|
||||||
|
createPrizeMutation.mutate(data as CreateWheelPrizeData);
|
||||||
|
}}
|
||||||
|
onCancel={() => setIsCreating(false)}
|
||||||
|
isLoading={createPrizeMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Prize list */}
|
{/* Prize list */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{config.prizes.map((prize) => (
|
{config.prizes.map((prize) => (
|
||||||
<div key={prize.id} className={`card p-4 ${!prize.is_active ? 'opacity-50' : ''}`}>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div
|
<div
|
||||||
className="flex h-12 w-12 items-center justify-center rounded-lg text-2xl"
|
key={prize.id}
|
||||||
|
ref={draggedPrizeId === prize.id ? dragRef : null}
|
||||||
|
draggable={expandedPrizeId !== prize.id}
|
||||||
|
onDragStart={(e) => 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 */}
|
||||||
|
<div className="flex items-center gap-3 p-4">
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div
|
||||||
|
className="cursor-grab text-dark-500 hover:text-dark-300 active:cursor-grabbing"
|
||||||
|
title={t('admin.wheel.prizes.dragToReorder')}
|
||||||
|
>
|
||||||
|
<GripVerticalIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prize icon */}
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg text-xl"
|
||||||
style={{ backgroundColor: prize.color + '30' }}
|
style={{ backgroundColor: prize.color + '30' }}
|
||||||
>
|
>
|
||||||
{prize.emoji}
|
{prize.emoji}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
|
||||||
|
{/* Prize info */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-semibold text-dark-100">{prize.display_name}</div>
|
<div className="font-semibold text-dark-100">{prize.display_name}</div>
|
||||||
<div className="text-sm text-dark-400">
|
<div className="truncate text-sm text-dark-400">
|
||||||
{t(`admin.wheel.prizes.types.${prize.prize_type}`)} •
|
{t(`admin.wheel.prizes.types.${prize.prize_type}`)} •{' '}
|
||||||
{t('admin.wheel.prizes.fields.value')}: {prize.prize_value} •
|
{t('admin.wheel.prizes.fields.value')}: {prize.prize_value} •{' '}
|
||||||
{t('admin.wheel.prizes.fields.worth')}:{' '}
|
|
||||||
{(prize.prize_value_kopeks / 100).toFixed(2)}₽
|
{(prize.prize_value_kopeks / 100).toFixed(2)}₽
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button onClick={() => setEditingPrize(prize)} className="btn-ghost text-sm">
|
{/* Actions */}
|
||||||
{t('common.edit')}
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedPrizeId(expandedPrizeId === prize.id ? null : prize.id)
|
||||||
|
}
|
||||||
|
className="btn-ghost p-2"
|
||||||
|
title={
|
||||||
|
expandedPrizeId === prize.id ? t('common.collapse') : t('common.edit')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expandedPrizeId === prize.id ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => handleDeletePrize(prize.id)}
|
||||||
if (confirm(t('admin.wheel.prizes.deletePrize'))) {
|
className="btn-ghost p-2 text-error-400 hover:bg-error-500/10"
|
||||||
deletePrizeMutation.mutate(prize.id);
|
title={t('common.delete')}
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="btn-ghost text-error-400"
|
|
||||||
>
|
>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded edit form */}
|
||||||
|
{expandedPrizeId === prize.id && (
|
||||||
|
<div className="border-t border-dark-700 bg-dark-800/50 p-4">
|
||||||
|
<InlinePrizeForm
|
||||||
|
prize={prize}
|
||||||
|
onSave={(data) => {
|
||||||
|
updatePrizeMutation.mutate({ id: prize.id, data });
|
||||||
|
}}
|
||||||
|
onCancel={() => setExpandedPrizeId(null)}
|
||||||
|
isLoading={updatePrizeMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.prizes.length === 0 && (
|
{config.prizes.length === 0 && !isCreating && (
|
||||||
<div className="py-12 text-center text-dark-400">
|
<div className="py-12 text-center text-dark-400">
|
||||||
{t('admin.wheel.prizes.noPrizes')}
|
{t('admin.wheel.prizes.noPrizes')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Wheel Preview */}
|
||||||
|
<div className="hidden lg:sticky lg:top-24 lg:block">
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3 className="mb-4 text-sm font-medium text-dark-400">{t('admin.wheel.preview')}</h3>
|
||||||
|
<div className="mx-auto max-w-[250px]">
|
||||||
|
<FortuneWheel
|
||||||
|
prizes={config.prizes}
|
||||||
|
isSpinning={false}
|
||||||
|
targetRotation={null}
|
||||||
|
onSpinComplete={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Statistics Tab */}
|
{/* Statistics Tab */}
|
||||||
@@ -485,37 +753,21 @@ export default function AdminWheel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create/Edit Prize Modal */}
|
|
||||||
{(isCreating || editingPrize) && (
|
|
||||||
<PrizeModal
|
|
||||||
prize={editingPrize}
|
|
||||||
onClose={() => {
|
|
||||||
setIsCreating(false);
|
|
||||||
setEditingPrize(null);
|
|
||||||
}}
|
|
||||||
onSave={(data) => {
|
|
||||||
if (editingPrize) {
|
|
||||||
updatePrizeMutation.mutate({ id: editingPrize.id, data });
|
|
||||||
} else {
|
|
||||||
createPrizeMutation.mutate(data as CreateWheelPrizeData);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prize Modal Component
|
// Inline Prize Form Component
|
||||||
function PrizeModal({
|
function InlinePrizeForm({
|
||||||
prize,
|
prize,
|
||||||
onClose,
|
|
||||||
onSave,
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
prize: WheelPrizeAdmin | null;
|
prize: WheelPrizeAdmin | null;
|
||||||
onClose: () => void;
|
|
||||||
onSave: (data: Partial<WheelPrizeAdmin>) => void;
|
onSave: (data: Partial<WheelPrizeAdmin>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -538,13 +790,19 @@ function PrizeModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
<form onSubmit={handleSubmit} className={`space-y-4 ${!prize ? 'card p-4' : ''}`}>
|
||||||
<div className="card max-h-[90vh] w-full max-w-md overflow-y-auto p-6">
|
{/* Header for new prize */}
|
||||||
<h2 className="mb-4 text-xl font-bold text-dark-50">
|
{!prize && (
|
||||||
{prize ? t('admin.wheel.prizes.editPrize') : t('admin.wheel.prizes.addPrize')}
|
<div className="flex items-center justify-between border-b border-dark-700 pb-3">
|
||||||
</h2>
|
<h3 className="font-semibold text-dark-100">{t('admin.wheel.prizes.addPrize')}</h3>
|
||||||
|
<button type="button" onClick={onCancel} className="btn-ghost p-1">
|
||||||
|
<XMarkIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
{/* Two-column layout for main fields */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{/* Prize type */}
|
{/* Prize type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||||
@@ -622,8 +880,7 @@ function PrizeModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Emoji and color */}
|
{/* Emoji */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.wheel.prizes.fields.emoji')}
|
{t('admin.wheel.prizes.fields.emoji')}
|
||||||
@@ -636,6 +893,8 @@ function PrizeModal({
|
|||||||
className="input w-full text-center text-2xl"
|
className="input w-full text-center text-2xl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Color */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.wheel.prizes.fields.color')}
|
{t('admin.wheel.prizes.fields.color')}
|
||||||
@@ -662,20 +921,21 @@ function PrizeModal({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="is_active"
|
id={`is_active_${prize?.id || 'new'}`}
|
||||||
checked={formData.is_active}
|
checked={formData.is_active}
|
||||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
className="rounded border-dark-600"
|
className="rounded border-dark-600"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="is_active" className="text-sm text-dark-300">
|
<label htmlFor={`is_active_${prize?.id || 'new'}`} className="text-sm text-dark-300">
|
||||||
{t('admin.wheel.prizes.fields.active')}
|
{t('admin.wheel.prizes.fields.active')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Promocode settings */}
|
{/* Promocode settings */}
|
||||||
{formData.prize_type === 'promocode' && (
|
{formData.prize_type === 'promocode' && (
|
||||||
<div className="space-y-3 rounded-lg bg-dark-800 p-3">
|
<div className="space-y-3 rounded-lg bg-dark-700/50 p-3">
|
||||||
<h4 className="font-medium text-dark-200">{t('admin.wheel.prizes.promo.title')}</h4>
|
<h4 className="font-medium text-dark-200">{t('admin.wheel.prizes.promo.title')}</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm text-dark-400">
|
<label className="mb-1 block text-sm text-dark-400">
|
||||||
{t('admin.wheel.prizes.promo.balanceBonus')}
|
{t('admin.wheel.prizes.promo.balanceBonus')}
|
||||||
@@ -711,19 +971,33 @@ function PrizeModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex justify-end gap-2 border-t border-dark-700 pt-4">
|
||||||
<button type="button" onClick={onClose} className="btn-secondary flex-1">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="btn-ghost flex items-center gap-1 px-3 py-1.5"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<XMarkIcon />
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-primary flex-1">
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary flex items-center gap-1 px-3 py-1.5"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<CheckIcon />
|
||||||
|
)}
|
||||||
{prize ? t('common.save') : t('common.confirm')}
|
{prize ? t('common.save') : t('common.confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,7 @@ import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel';
|
|||||||
import FortuneWheel from '../components/wheel/FortuneWheel';
|
import FortuneWheel from '../components/wheel/FortuneWheel';
|
||||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { usePlatform } from '@/platform';
|
import { usePlatform, useHaptic } from '@/platform';
|
||||||
|
|
||||||
// Pre-calculated confetti positions (stable across re-renders)
|
|
||||||
const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({
|
|
||||||
color: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
|
|
||||||
left: `${(i * 17 + 5) % 95}%`,
|
|
||||||
top: `${(i * 23 + 3) % 90}%`,
|
|
||||||
delay: `${(i * 0.1) % 2}s`,
|
|
||||||
duration: `${1 + (i % 3) * 0.3}s`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const StarIcon = () => (
|
const StarIcon = () => (
|
||||||
@@ -74,11 +65,11 @@ export default function Wheel() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
const { platform, openInvoice, capabilities } = usePlatform();
|
const { platform, openInvoice, capabilities } = usePlatform();
|
||||||
|
const haptic = useHaptic();
|
||||||
|
|
||||||
const [isSpinning, setIsSpinning] = useState(false);
|
const [isSpinning, setIsSpinning] = useState(false);
|
||||||
const [targetRotation, setTargetRotation] = useState<number | null>(null);
|
const [targetRotation, setTargetRotation] = useState<number | null>(null);
|
||||||
const [spinResult, setSpinResult] = useState<SpinResult | null>(null);
|
const [spinResult, setSpinResult] = useState<SpinResult | null>(null);
|
||||||
const [showResultModal, setShowResultModal] = useState(false);
|
|
||||||
const [paymentType, setPaymentType] = useState<'telegram_stars' | 'subscription_days'>(
|
const [paymentType, setPaymentType] = useState<'telegram_stars' | 'subscription_days'>(
|
||||||
'telegram_stars',
|
'telegram_stars',
|
||||||
);
|
);
|
||||||
@@ -290,7 +281,6 @@ export default function Wheel() {
|
|||||||
promocode: null,
|
promocode: null,
|
||||||
error: 'payment_failed',
|
error: 'payment_failed',
|
||||||
});
|
});
|
||||||
setShowResultModal(true);
|
|
||||||
} else {
|
} else {
|
||||||
setIsPayingStars(false);
|
setIsPayingStars(false);
|
||||||
}
|
}
|
||||||
@@ -311,7 +301,6 @@ export default function Wheel() {
|
|||||||
promocode: null,
|
promocode: null,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
setShowResultModal(true);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -329,7 +318,6 @@ export default function Wheel() {
|
|||||||
promocode: null,
|
promocode: null,
|
||||||
error: 'network_error',
|
error: 'network_error',
|
||||||
});
|
});
|
||||||
setShowResultModal(true);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -347,7 +335,6 @@ export default function Wheel() {
|
|||||||
} else {
|
} else {
|
||||||
setIsSpinning(false);
|
setIsSpinning(false);
|
||||||
setSpinResult(result);
|
setSpinResult(result);
|
||||||
setShowResultModal(true);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -365,7 +352,6 @@ export default function Wheel() {
|
|||||||
rotation_degrees: 0,
|
rotation_degrees: 0,
|
||||||
promocode: null,
|
promocode: null,
|
||||||
});
|
});
|
||||||
setShowResultModal(true);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -385,6 +371,12 @@ export default function Wheel() {
|
|||||||
// Use the pending result from polling, or show a fallback
|
// Use the pending result from polling, or show a fallback
|
||||||
if (pendingStarsResultRef.current) {
|
if (pendingStarsResultRef.current) {
|
||||||
setSpinResult(pendingStarsResultRef.current);
|
setSpinResult(pendingStarsResultRef.current);
|
||||||
|
// Haptic feedback based on result
|
||||||
|
if (pendingStarsResultRef.current.prize_type === 'nothing') {
|
||||||
|
haptic.notification('warning');
|
||||||
|
} else {
|
||||||
|
haptic.notification('success');
|
||||||
|
}
|
||||||
pendingStarsResultRef.current = null;
|
pendingStarsResultRef.current = null;
|
||||||
} else {
|
} else {
|
||||||
// Polling still in progress or failed - show fallback
|
// Polling still in progress or failed - show fallback
|
||||||
@@ -401,16 +393,22 @@ export default function Wheel() {
|
|||||||
promocode: null,
|
promocode: null,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
haptic.notification('success');
|
||||||
|
}
|
||||||
|
} else if (spinResult) {
|
||||||
|
// Regular spin - haptic based on result
|
||||||
|
if (spinResult.success && spinResult.prize_type !== 'nothing') {
|
||||||
|
haptic.notification('success');
|
||||||
|
} else {
|
||||||
|
haptic.notification('warning');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowResultModal(true);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['wheel-config'] });
|
queryClient.invalidateQueries({ queryKey: ['wheel-config'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wheel-history'] });
|
queryClient.invalidateQueries({ queryKey: ['wheel-history'] });
|
||||||
}, [queryClient, t]);
|
}, [queryClient, t, haptic, spinResult]);
|
||||||
|
|
||||||
const closeResultModal = () => {
|
const closeResultModal = () => {
|
||||||
setShowResultModal(false);
|
|
||||||
setSpinResult(null);
|
setSpinResult(null);
|
||||||
setTargetRotation(null);
|
setTargetRotation(null);
|
||||||
};
|
};
|
||||||
@@ -675,6 +673,58 @@ export default function Wheel() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Inline Result Card */}
|
||||||
|
{spinResult && !isSpinning && (
|
||||||
|
<div
|
||||||
|
className={`animate-fade-in rounded-xl border p-4 ${
|
||||||
|
spinResult.success
|
||||||
|
? 'border-purple-500/30 bg-gradient-to-br from-purple-500/10 to-indigo-500/10'
|
||||||
|
: 'border-red-500/30 bg-red-500/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-2xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: spinResult.success
|
||||||
|
? `${spinResult.color || '#8B5CF6'}20`
|
||||||
|
: 'rgba(239, 68, 68, 0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{spinResult.success ? spinResult.emoji || '🎉' : '😔'}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-semibold text-dark-100">
|
||||||
|
{spinResult.success && spinResult.prize_display_name
|
||||||
|
? spinResult.prize_display_name
|
||||||
|
: spinResult.success
|
||||||
|
? spinResult.prize_type === 'nothing'
|
||||||
|
? t('wheel.noLuck')
|
||||||
|
: t('wheel.congratulations')
|
||||||
|
: t('wheel.oops')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-dark-400">{spinResult.message}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={closeResultModal}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-dark-400 transition-colors hover:bg-white/5 hover:text-dark-200"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Promocode if won */}
|
||||||
|
{spinResult.promocode && (
|
||||||
|
<div className="mt-3 rounded-lg border border-purple-500/20 bg-purple-500/10 p-3 text-center">
|
||||||
|
<p className="mb-1 text-xs text-purple-300">{t('wheel.yourPromoCode')}</p>
|
||||||
|
<p className="select-all font-mono text-lg font-bold tracking-wider text-white">
|
||||||
|
{spinResult.promocode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -697,7 +747,7 @@ export default function Wheel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{history && history.items.length > 0 ? (
|
{history && history.items.length > 0 ? (
|
||||||
<div className="max-h-[400px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
{history.items.map((item: SpinHistoryItem, index: number) => (
|
{history.items.map((item: SpinHistoryItem, index: number) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -738,99 +788,6 @@ export default function Wheel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Result Modal */}
|
|
||||||
{showResultModal && spinResult && (
|
|
||||||
<div className="fixed inset-0 z-[60] flex animate-fade-in items-center justify-center bg-black/80 p-4 backdrop-blur-md">
|
|
||||||
<div
|
|
||||||
className={`relative w-full max-w-md overflow-hidden rounded-3xl p-8 text-center ${
|
|
||||||
spinResult.success
|
|
||||||
? 'bg-gradient-to-br from-purple-900/90 via-indigo-900/90 to-purple-900/90'
|
|
||||||
: 'bg-gradient-to-br from-dark-800 via-dark-900 to-dark-800'
|
|
||||||
} border ${spinResult.success ? 'border-purple-500/30' : 'border-dark-700'}`}
|
|
||||||
>
|
|
||||||
{/* Decorative elements */}
|
|
||||||
{spinResult.success && (
|
|
||||||
<>
|
|
||||||
<div className="absolute left-0 top-0 h-32 w-32 rounded-full bg-purple-500/20 blur-3xl" />
|
|
||||||
<div className="absolute bottom-0 right-0 h-32 w-32 rounded-full bg-indigo-500/20 blur-3xl" />
|
|
||||||
{/* Confetti effect - using pre-calculated positions */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
|
||||||
{CONFETTI_POSITIONS.map((pos, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="absolute h-2 w-2 animate-bounce rounded-full"
|
|
||||||
style={{
|
|
||||||
background: pos.color,
|
|
||||||
left: pos.left,
|
|
||||||
top: pos.top,
|
|
||||||
animationDelay: pos.delay,
|
|
||||||
animationDuration: pos.duration,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={closeResultModal}
|
|
||||||
className="absolute right-4 top-4 rounded-lg p-2 text-dark-400 transition-colors hover:bg-white/5 hover:text-dark-200"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="relative space-y-6">
|
|
||||||
{/* Prize icon */}
|
|
||||||
<div
|
|
||||||
className="mx-auto flex h-28 w-28 items-center justify-center rounded-full text-6xl shadow-2xl"
|
|
||||||
style={{
|
|
||||||
background: spinResult.success
|
|
||||||
? `linear-gradient(135deg, ${spinResult.color || '#8B5CF6'}40, ${spinResult.color || '#8B5CF6'}20)`
|
|
||||||
: 'rgba(239, 68, 68, 0.1)',
|
|
||||||
boxShadow: spinResult.success
|
|
||||||
? `0 0 60px ${spinResult.color || '#8B5CF6'}40`
|
|
||||||
: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{spinResult.success ? spinResult.emoji || '🎉' : '😔'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h2 className="text-3xl font-bold text-white">
|
|
||||||
{spinResult.success
|
|
||||||
? spinResult.prize_type === 'nothing'
|
|
||||||
? t('wheel.noLuck')
|
|
||||||
: t('wheel.congratulations')
|
|
||||||
: t('wheel.oops')}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Message */}
|
|
||||||
<p className="text-lg text-dark-200">{spinResult.message}</p>
|
|
||||||
|
|
||||||
{/* Promocode if won */}
|
|
||||||
{spinResult.promocode && (
|
|
||||||
<div className="rounded-2xl border border-purple-500/30 bg-gradient-to-r from-purple-500/20 via-indigo-500/20 to-purple-500/20 p-5">
|
|
||||||
<p className="mb-3 text-sm text-purple-300">{t('wheel.yourPromoCode')}</p>
|
|
||||||
<p className="select-all font-mono text-2xl font-bold tracking-wider text-white">
|
|
||||||
{spinResult.promocode}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={closeResultModal}
|
|
||||||
className="w-full rounded-xl bg-white/10 py-4 font-semibold text-white transition-all hover:bg-white/20"
|
|
||||||
>
|
|
||||||
{t('wheel.close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user