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:
c0mrade
2026-02-02 17:51:46 +03:00
parent e9af285dad
commit 7e2802c5b5
9 changed files with 740 additions and 472 deletions

View File

@@ -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>
); );

View File

@@ -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 = () => {

View File

@@ -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);

View File

@@ -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",

View File

@@ -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": "جایزه موجودی",

View File

@@ -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": "Бонус баланса",

View File

@@ -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": "余额奖励",

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }