mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +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
|
||||
// Don't show back button on main tab pages (bottom nav) - users navigate via tabs
|
||||
const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support'];
|
||||
const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support', '/wheel'];
|
||||
const isMainTabPage = mainTabPaths.includes(location.pathname);
|
||||
const handleBack = useCallback(() => {
|
||||
if (mobileMenuOpen) {
|
||||
@@ -505,6 +505,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<MobileBottomNav
|
||||
isKeyboardOpen={isKeyboardOpen}
|
||||
referralEnabled={referralTerms?.is_enabled}
|
||||
wheelEnabled={wheelConfig?.is_enabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,14 +6,19 @@ import { cn } from '@/lib/utils';
|
||||
import { usePlatform } from '@/platform';
|
||||
|
||||
// Icons
|
||||
import { HomeIcon, SubscriptionIcon, WalletIcon, UsersIcon, ChatIcon } from './icons';
|
||||
import { HomeIcon, SubscriptionIcon, WalletIcon, UsersIcon, ChatIcon, WheelIcon } from './icons';
|
||||
|
||||
interface MobileBottomNavProps {
|
||||
isKeyboardOpen: boolean;
|
||||
referralEnabled?: boolean;
|
||||
wheelEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function MobileBottomNav({ isKeyboardOpen, referralEnabled }: MobileBottomNavProps) {
|
||||
export function MobileBottomNav({
|
||||
isKeyboardOpen,
|
||||
referralEnabled,
|
||||
wheelEnabled,
|
||||
}: MobileBottomNavProps) {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { haptic } = usePlatform();
|
||||
@@ -21,12 +26,15 @@ export function MobileBottomNav({ isKeyboardOpen, referralEnabled }: MobileBotto
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
// Core navigation items for bottom bar
|
||||
// When wheel is enabled, it replaces Support in the bottom nav (Support is still accessible via hamburger menu)
|
||||
const coreItems = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
||||
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
||||
...(wheelEnabled
|
||||
? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }]
|
||||
: [{ path: '/support', label: t('nav.support'), icon: ChatIcon }]),
|
||||
];
|
||||
|
||||
const handleNavClick = () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
if (isSpinning) {
|
||||
const interval = setInterval(() => {
|
||||
setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2
|
||||
}, 250); // Slower interval = better performance
|
||||
}, 600); // Slower interval = better performance
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
setLightPhase(0);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"and": "and",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"collapse": "Collapse",
|
||||
"currency": "$",
|
||||
"refresh": "Refresh",
|
||||
"copy": "Copy",
|
||||
@@ -952,21 +953,27 @@
|
||||
"prizes": "Prizes",
|
||||
"statistics": "Statistics"
|
||||
},
|
||||
"preview": "Preview",
|
||||
"settings": {
|
||||
"enableWheel": "Enable Wheel",
|
||||
"allowSpins": "Allow users to spin the wheel",
|
||||
"spinCost": "Spin Cost",
|
||||
"costInStars": "Cost in Stars",
|
||||
"costInDays": "Cost in Days",
|
||||
"limitsAndRtp": "Limits & RTP",
|
||||
"rtpPercent": "RTP (Return to Player) %",
|
||||
"dailyLimit": "Daily Spin Limit (0 = unlimited)",
|
||||
"minSubDays": "Min subscription days for day payment",
|
||||
"promocodes": "Promocodes",
|
||||
"promoPrefix": "Promo code prefix"
|
||||
},
|
||||
"prizes": {
|
||||
"addPrize": "Add Prize",
|
||||
"editPrize": "Edit Prize",
|
||||
"noPrizes": "No prizes configured. Add some prizes to enable the wheel.",
|
||||
"deletePrize": "Delete this prize?",
|
||||
"deletePrize": "Delete Prize",
|
||||
"confirmDelete": "Are you sure you want to delete this prize? This action cannot be undone.",
|
||||
"dragToReorder": "Drag to reorder",
|
||||
"types": {
|
||||
"subscription_days": "Subscription Days",
|
||||
"balance_bonus": "Balance Bonus",
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"refresh": "بازخوانی",
|
||||
"retry": "تلاش مجدد",
|
||||
"saving": "در حال ذخیره...",
|
||||
"understand": "متوجه شدم"
|
||||
"understand": "متوجه شدم",
|
||||
"collapse": "جمع کردن"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "داشبورد",
|
||||
@@ -676,6 +677,7 @@
|
||||
},
|
||||
"wheel": {
|
||||
"title": "تنظیمات چرخ شانس",
|
||||
"preview": "پیشنمایش",
|
||||
"enabled": "فعال",
|
||||
"disabled": "غیرفعال",
|
||||
"tabs": {
|
||||
@@ -691,13 +693,18 @@
|
||||
"rtpPercent": "درصد RTP",
|
||||
"dailyLimit": "محدودیت روزانه (0 = نامحدود)",
|
||||
"minSubDays": "حداقل روز اشتراک برای پرداخت روزانه",
|
||||
"promoPrefix": "پیشوند کد تخفیف"
|
||||
"promoPrefix": "پیشوند کد تخفیف",
|
||||
"spinCost": "هزینه چرخش",
|
||||
"limitsAndRtp": "محدودیتها و RTP",
|
||||
"promocodes": "کدهای تخفیف"
|
||||
},
|
||||
"prizes": {
|
||||
"addPrize": "افزودن جایزه",
|
||||
"editPrize": "ویرایش جایزه",
|
||||
"noPrizes": "جایزهای تنظیم نشده. برای فعالسازی چرخ جایزه اضافه کنید.",
|
||||
"deletePrize": "این جایزه حذف شود؟",
|
||||
"dragToReorder": "کشیدن برای مرتبسازی",
|
||||
"confirmDelete": "آیا از حذف این جایزه مطمئن هستید؟",
|
||||
"types": {
|
||||
"subscription_days": "روز اشتراک",
|
||||
"balance_bonus": "جایزه موجودی",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"and": "и",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить",
|
||||
"collapse": "Свернуть",
|
||||
"currency": "₽",
|
||||
"refresh": "Обновить",
|
||||
"copy": "Копировать",
|
||||
@@ -971,21 +972,27 @@
|
||||
"prizes": "Призы",
|
||||
"statistics": "Статистика"
|
||||
},
|
||||
"preview": "Предпросмотр",
|
||||
"settings": {
|
||||
"enableWheel": "Включить колесо",
|
||||
"allowSpins": "Разрешить пользователям крутить колесо",
|
||||
"spinCost": "Стоимость вращения",
|
||||
"costInStars": "Стоимость в Stars",
|
||||
"costInDays": "Стоимость в днях",
|
||||
"limitsAndRtp": "Лимиты и RTP",
|
||||
"rtpPercent": "RTP (Return to Player) %",
|
||||
"dailyLimit": "Дневной лимит вращений (0 = безлимит)",
|
||||
"minSubDays": "Мин. дней подписки для оплаты днями",
|
||||
"promocodes": "Промокоды",
|
||||
"promoPrefix": "Префикс промокодов"
|
||||
},
|
||||
"prizes": {
|
||||
"addPrize": "Добавить приз",
|
||||
"editPrize": "Редактировать приз",
|
||||
"noPrizes": "Призы не настроены. Добавьте призы для активации колеса.",
|
||||
"deletePrize": "Удалить этот приз?",
|
||||
"deletePrize": "Удалить приз",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить этот приз? Это действие нельзя отменить.",
|
||||
"dragToReorder": "Перетащите для изменения порядка",
|
||||
"types": {
|
||||
"subscription_days": "Дни подписки",
|
||||
"balance_bonus": "Бонус баланса",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"retry": "重试",
|
||||
"saving": "保存中...",
|
||||
"understand": "明白了",
|
||||
"collapse": "收起",
|
||||
"units": {
|
||||
"gb": "GB",
|
||||
"perGb": "/GB"
|
||||
@@ -728,6 +729,7 @@
|
||||
},
|
||||
"wheel": {
|
||||
"title": "幸运转盘设置",
|
||||
"preview": "预览",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"tabs": {
|
||||
@@ -743,13 +745,18 @@
|
||||
"rtpPercent": "RTP (返还率) %",
|
||||
"dailyLimit": "每日限制 (0 = 无限)",
|
||||
"minSubDays": "天数支付最低订阅天数",
|
||||
"promoPrefix": "优惠码前缀"
|
||||
"promoPrefix": "优惠码前缀",
|
||||
"spinCost": "旋转费用",
|
||||
"limitsAndRtp": "限制和RTP",
|
||||
"promocodes": "促销码"
|
||||
},
|
||||
"prizes": {
|
||||
"addPrize": "添加奖品",
|
||||
"editPrize": "编辑奖品",
|
||||
"noPrizes": "未配置奖品。添加奖品以启用转盘。",
|
||||
"deletePrize": "删除此奖品?",
|
||||
"dragToReorder": "拖动排序",
|
||||
"confirmDelete": "确定要删除这个奖品吗?",
|
||||
"types": {
|
||||
"subscription_days": "订阅天数",
|
||||
"balance_bonus": "余额奖励",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,7 @@ import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel';
|
||||
import FortuneWheel from '../components/wheel/FortuneWheel';
|
||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { usePlatform } 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`,
|
||||
}));
|
||||
import { usePlatform, useHaptic } from '@/platform';
|
||||
|
||||
// Icons
|
||||
const StarIcon = () => (
|
||||
@@ -74,11 +65,11 @@ export default function Wheel() {
|
||||
const queryClient = useQueryClient();
|
||||
const { formatAmount, currencySymbol } = useCurrency();
|
||||
const { platform, openInvoice, capabilities } = usePlatform();
|
||||
const haptic = useHaptic();
|
||||
|
||||
const [isSpinning, setIsSpinning] = useState(false);
|
||||
const [targetRotation, setTargetRotation] = useState<number | null>(null);
|
||||
const [spinResult, setSpinResult] = useState<SpinResult | null>(null);
|
||||
const [showResultModal, setShowResultModal] = useState(false);
|
||||
const [paymentType, setPaymentType] = useState<'telegram_stars' | 'subscription_days'>(
|
||||
'telegram_stars',
|
||||
);
|
||||
@@ -290,7 +281,6 @@ export default function Wheel() {
|
||||
promocode: null,
|
||||
error: 'payment_failed',
|
||||
});
|
||||
setShowResultModal(true);
|
||||
} else {
|
||||
setIsPayingStars(false);
|
||||
}
|
||||
@@ -311,7 +301,6 @@ export default function Wheel() {
|
||||
promocode: null,
|
||||
error: null,
|
||||
});
|
||||
setShowResultModal(true);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
@@ -329,7 +318,6 @@ export default function Wheel() {
|
||||
promocode: null,
|
||||
error: 'network_error',
|
||||
});
|
||||
setShowResultModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -347,7 +335,6 @@ export default function Wheel() {
|
||||
} else {
|
||||
setIsSpinning(false);
|
||||
setSpinResult(result);
|
||||
setShowResultModal(true);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
@@ -365,7 +352,6 @@ export default function Wheel() {
|
||||
rotation_degrees: 0,
|
||||
promocode: null,
|
||||
});
|
||||
setShowResultModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -385,6 +371,12 @@ export default function Wheel() {
|
||||
// Use the pending result from polling, or show a fallback
|
||||
if (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;
|
||||
} else {
|
||||
// Polling still in progress or failed - show fallback
|
||||
@@ -401,16 +393,22 @@ export default function Wheel() {
|
||||
promocode: 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-history'] });
|
||||
}, [queryClient, t]);
|
||||
}, [queryClient, t, haptic, spinResult]);
|
||||
|
||||
const closeResultModal = () => {
|
||||
setShowResultModal(false);
|
||||
setSpinResult(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>
|
||||
@@ -697,7 +747,7 @@ export default function Wheel() {
|
||||
</div>
|
||||
|
||||
{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) => (
|
||||
<div
|
||||
key={item.id}
|
||||
@@ -738,99 +788,6 @@ export default function Wheel() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user