From 576893f5c6b67c19bee0cd562cd0430a88350619 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 14:36:16 +0300 Subject: [PATCH 1/8] feat: replace payment modals with page-based navigation - Add /balance/top-up route for payment method selection - Add /balance/top-up/:methodId route for amount entry and payment - Remove TopUpModal component (619 lines) - Simplify InsufficientBalancePrompt to use navigate instead of modals - Support ?amount and ?returnTo query params for cross-page data flow - Fix nav isActive to highlight Balance on sub-routes - Remove unused MainButton platform abstraction - Increase toast accent opacity for better visibility --- src/App.tsx | 22 ++ src/components/InsufficientBalancePrompt.tsx | 325 ++++------------ src/components/Toast.tsx | 16 +- src/components/layout/AppShell/AppHeader.tsx | 5 +- src/main.tsx | 7 - src/pages/Balance.tsx | 11 +- .../TopUpModal.tsx => pages/TopUpAmount.tsx} | 351 ++++++------------ src/pages/TopUpMethodSelect.tsx | 97 +++++ src/platform/adapters/TelegramAdapter.ts | 94 +---- src/platform/adapters/WebAdapter.ts | 34 +- src/platform/hooks/useMainButton.ts | 111 ------ src/platform/index.ts | 3 - src/platform/types.ts | 23 +- 13 files changed, 333 insertions(+), 766 deletions(-) rename src/{components/TopUpModal.tsx => pages/TopUpAmount.tsx} (67%) create mode 100644 src/pages/TopUpMethodSelect.tsx delete mode 100644 src/platform/hooks/useMainButton.ts diff --git a/src/App.tsx b/src/App.tsx index a762d57..28eda68 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,8 @@ const Contests = lazy(() => import('./pages/Contests')); const Polls = lazy(() => import('./pages/Polls')); const Info = lazy(() => import('./pages/Info')); const Wheel = lazy(() => import('./pages/Wheel')); +const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); +const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); // Admin pages - lazy load (only for admins) const AdminPanel = lazy(() => import('./pages/AdminPanel')); @@ -170,6 +172,26 @@ function App() { } /> + + + + + + } + /> + + + + + + } + /> (null); const [isPreparingTopUp, setIsPreparingTopUp] = useState(false); - // Auto-close modals when success notification appears - const handleCloseAll = useCallback(() => { - setShowMethodSelect(false); - setSelectedMethod(null); - }, []); - useCloseOnSuccessNotification(handleCloseAll); - - const { data: paymentMethods } = useQuery({ - queryKey: ['payment-methods'], - queryFn: balanceApi.getPaymentMethods, - enabled: showMethodSelect, - }); - const missingRubles = missingAmountKopeks / 100; const displayAmount = formatAmount(missingRubles); - const handleMethodSelect = (method: PaymentMethod) => { - setSelectedMethod(method); - setShowMethodSelect(false); - }; - const handleTopUpClick = async () => { if (onBeforeTopUp) { setIsPreparingTopUp(true); try { await onBeforeTopUp(); } catch { - // Silently ignore errors - still open the modal + // Silently ignore errors - still navigate } finally { setIsPreparingTopUp(false); } } - setShowMethodSelect(true); + const params = new URLSearchParams(); + params.set('amount', String(Math.ceil(missingRubles))); + params.set('returnTo', location.pathname); + navigate(`/balance/top-up?${params.toString()}`); }; if (compact) { return ( - <> -
-
- - - - - {message || t('balance.insufficientFunds')}:{' '} - - {displayAmount} {currencySymbol} - - -
- -
- - {showMethodSelect && ( - setShowMethodSelect(false)} - /> - )} - - {selectedMethod && ( - setSelectedMethod(null)} - /> - )} - - ); - } - - return ( - <>
-
-
- - - -
-
-
{t('balance.insufficientFunds')}
-
{message || t('balance.topUpToComplete')}
-
-
- {t('balance.missing')}:{' '} - - {displayAmount} {currencySymbol} - -
-
-
+
+ + + + + {message || t('balance.insufficientFunds')}:{' '} + + {displayAmount} {currencySymbol} + +
- - {showMethodSelect && ( - setShowMethodSelect(false)} - /> - )} - - {selectedMethod && ( - setSelectedMethod(null)} - /> - )} - - ); -} - -interface PaymentMethodModalProps { - paymentMethods: PaymentMethod[] | undefined; - onSelect: (method: PaymentMethod) => void; - onClose: () => void; -} - -function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) { - const { t } = useTranslation(); - const { formatAmount, currencySymbol } = useCurrency(); + ); + } return ( -
-
- -
- {/* Header */} -
- {t('balance.selectPaymentMethod')} - -
- - {/* Content */} -
- {!paymentMethods ? ( -
-
-
- ) : paymentMethods.length === 0 ? ( -
- {t('balance.noPaymentMethods')} -
- ) : ( - paymentMethods.map((method) => { - const methodKey = method.id.toLowerCase().replace(/-/g, '_'); - const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { - defaultValue: '', - }); - - return ( - - ); - }) - )} -
-
+ {t('balance.topUpBalance')} + + )} +
); } diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 2404ed7..63864ed 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -98,29 +98,29 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { const typeStyles = { success: { bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-success-500/30 to-transparent', - border: 'border-success-500/50', + accent: 'bg-gradient-to-r from-success-500/50 to-transparent', + border: 'border-success-500/70', icon: 'text-success-400', iconBg: 'bg-success-500/30', }, error: { bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-error-500/30 to-transparent', - border: 'border-error-500/50', + accent: 'bg-gradient-to-r from-error-500/50 to-transparent', + border: 'border-error-500/70', icon: 'text-error-400', iconBg: 'bg-error-500/30', }, warning: { bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-warning-500/30 to-transparent', - border: 'border-warning-500/50', + accent: 'bg-gradient-to-r from-warning-500/50 to-transparent', + border: 'border-warning-500/70', icon: 'text-warning-400', iconBg: 'bg-warning-500/30', }, info: { bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-accent-500/30 to-transparent', - border: 'border-accent-500/50', + accent: 'bg-gradient-to-r from-accent-500/50 to-transparent', + border: 'border-accent-500/70', icon: 'text-accent-400', iconBg: 'bg-accent-500/30', }, diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx index 68fc13d..9a0a9b1 100644 --- a/src/components/layout/AppShell/AppHeader.tsx +++ b/src/components/layout/AppShell/AppHeader.tsx @@ -145,7 +145,10 @@ export function AppHeader({ }; }, [mobileMenuOpen]); - const isActive = (path: string) => location.pathname === path; + const isActive = (path: string) => { + if (path === '/') return location.pathname === '/'; + return location.pathname.startsWith(path); + }; const isAdminActive = () => location.pathname.startsWith('/admin'); const navItems = [ diff --git a/src/main.tsx b/src/main.tsx index 30280ca..5d53a22 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,7 +14,6 @@ import { mountClosingBehavior, disableClosingConfirmation, mountBackButton, - mountMainButton, bindThemeParamsCssVars, bindViewportCssVars, requestFullscreen, @@ -66,12 +65,6 @@ if (!alreadyInitialized) { } catch { /* already mounted */ } - try { - mountMainButton(); - } catch { - /* already mounted */ - } - // Viewport — async, fullscreen зависит от смонтированного viewport mountViewport() .then(() => { diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index 069e825..c637b74 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -6,10 +6,9 @@ import { motion, AnimatePresence } from 'framer-motion'; import { useAuthStore } from '../store/auth'; import { balanceApi } from '../api/balance'; -import TopUpModal from '../components/TopUpModal'; import { useCurrency } from '../hooks/useCurrency'; import { useToast } from '../components/Toast'; -import type { PaymentMethod, PaginatedResponse, Transaction } from '../types'; +import type { PaginatedResponse, Transaction } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; @@ -92,7 +91,6 @@ export default function Balance() { } }, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]); - const [selectedMethod, setSelectedMethod] = useState(null); const [promocode, setPromocode] = useState(''); const [promocodeLoading, setPromocodeLoading] = useState(false); const [promocodeError, setPromocodeError] = useState(null); @@ -285,7 +283,7 @@ export default function Balance() { key={method.id} interactive={method.is_available} className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''} - onClick={() => method.is_available && setSelectedMethod(method)} + onClick={() => method.is_available && navigate(`/balance/top-up/${method.id}`)} >
{translatedName || method.name} @@ -423,11 +421,6 @@ export default function Balance() { - - {/* TopUp Modal */} - {selectedMethod && ( - setSelectedMethod(null)} /> - )} ); } diff --git a/src/components/TopUpModal.tsx b/src/pages/TopUpAmount.tsx similarity index 67% rename from src/components/TopUpModal.tsx rename to src/pages/TopUpAmount.tsx index ae3171b..32e5b90 100644 --- a/src/components/TopUpModal.tsx +++ b/src/pages/TopUpAmount.tsx @@ -1,33 +1,19 @@ import { useState, useRef, useEffect, useCallback } from 'react'; -import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; -import { useMutation } from '@tanstack/react-query'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; + import { balanceApi } from '../api/balance'; import { useCurrency } from '../hooks/useCurrency'; -import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; import { useCloseOnSuccessNotification } from '../store/successNotification'; -import { useBackButton, useMainButton, useHaptic, usePlatform } from '@/platform'; +import { useBackButton, useHaptic, usePlatform } from '@/platform'; +import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import type { PaymentMethod } from '../types'; -import BentoCard from './ui/BentoCard'; +import BentoCard from '../components/ui/BentoCard'; // Icons -const CloseIcon = () => ( - - - -); - -const WalletIcon = () => ( - - - -); - const StarIcon = () => ( @@ -86,26 +72,6 @@ const CheckIcon = () => ( ); -interface TopUpModalProps { - method: PaymentMethod; - onClose: () => void; - initialAmountRubles?: number; -} - -function useIsMobile() { - const [isMobile, setIsMobile] = useState(() => { - if (typeof window === 'undefined') return false; - return window.innerWidth < 640; - }); - useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 640); - window.addEventListener('resize', check); - return () => window.removeEventListener('resize', check); - }, []); - return isMobile; -} - -// Get method icon based on method type const getMethodIcon = (methodId: string) => { const id = methodId.toLowerCase(); if (id.includes('stars')) return ; @@ -113,19 +79,52 @@ const getMethodIcon = (methodId: string) => { return ; }; -export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { +export default function TopUpAmount() { const { t } = useTranslation(); + const navigate = useNavigate(); + const { methodId } = useParams<{ methodId: string }>(); + const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency(); - const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp(); const { openInvoice } = usePlatform(); const haptic = useHaptic(); const inputRef = useRef(null); - const isMobileScreen = useIsMobile(); - const safeBottom = isTelegramWebApp - ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) - : 0; + const returnTo = searchParams.get('returnTo'); + const initialAmountRubles = searchParams.get('amount') + ? parseFloat(searchParams.get('amount')!) + : undefined; + + // Get method from cached payment-methods query + const cachedMethods = queryClient.getQueryData(['payment-methods']); + const method = cachedMethods?.find((m) => m.id === methodId); + + const handleNavigateBack = useCallback(() => { + navigate(-1); + }, [navigate]); + + const handleSuccess = useCallback(() => { + navigate(returnTo || '/balance', { replace: true }); + }, [navigate, returnTo]); + + // Telegram back button + useBackButton(handleNavigateBack); + + // Keyboard: Escape to go back + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + handleNavigateBack(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleNavigateBack]); + + // Auto-redirect when success notification appears (e.g., balance topped up via WebSocket) + useCloseOnSuccessNotification(handleSuccess); const getInitialAmount = (): string => { if (!initialAmountRubles || initialAmountRubles <= 0) return ''; @@ -138,65 +137,24 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const [amount, setAmount] = useState(getInitialAmount); const [error, setError] = useState(null); const [selectedOption, setSelectedOption] = useState( - method.options && method.options.length > 0 ? method.options[0].id : null, + method?.options && method.options.length > 0 ? method.options[0].id : null, ); const [paymentUrl, setPaymentUrl] = useState(null); const [copied, setCopied] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false); - const handleClose = useCallback(() => { - onClose(); - }, [onClose]); - - // Auto-close when success notification appears (e.g., balance topped up via WebSocket) - useCloseOnSuccessNotification(handleClose); - - // Keyboard: Escape to close (PC) + // If method not found in cache, redirect to method selection useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - handleClose(); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [handleClose]); - - // Telegram back button - using platform hook - useBackButton(handleClose); - - // Scroll lock - useEffect(() => { - const scrollY = window.scrollY; - const preventScroll = (e: TouchEvent) => { - const target = e.target as HTMLElement; - if (target.closest('[data-modal-content]')) return; - e.preventDefault(); - }; - const preventWheel = (e: WheelEvent) => { - const target = e.target as HTMLElement; - if (target.closest('[data-modal-content]')) return; - e.preventDefault(); - }; - document.addEventListener('touchmove', preventScroll, { passive: false }); - document.addEventListener('wheel', preventWheel, { passive: false }); - document.body.style.overflow = 'hidden'; - return () => { - document.removeEventListener('touchmove', preventScroll); - document.removeEventListener('wheel', preventWheel); - document.body.style.overflow = ''; - window.scrollTo(0, scrollY); - }; - }, []); - - const hasOptions = method.options && method.options.length > 0; - const minRubles = method.min_amount_kopeks / 100; - const maxRubles = method.max_amount_kopeks / 100; - const methodKey = method.id.toLowerCase().replace(/-/g, '_'); - const isStarsMethod = methodKey.includes('stars'); - const methodName = - t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name; + if (cachedMethods && !method) { + const params = new URLSearchParams(); + const amount = searchParams.get('amount'); + const rt = searchParams.get('returnTo'); + if (amount) params.set('amount', amount); + if (rt) params.set('returnTo', rt); + const qs = params.toString(); + navigate(`/balance/top-up${qs ? `?${qs}` : ''}`, { replace: true }); + } + }, [cachedMethods, method, navigate, searchParams]); const starsPaymentMutation = useMutation({ mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), @@ -206,17 +164,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top return; } try { - // Use platform-agnostic invoice opening const status = await openInvoice(data.invoice_url); if (status === 'paid') { haptic.notification('success'); setError(null); - onClose(); + handleSuccess(); } else if (status === 'failed') { haptic.notification('error'); setError(t('wheel.starsPaymentFailed')); } - // 'pending' and 'cancelled' just close without action } catch (e) { setError(t('balance.errors.generic', { details: String(e) })); } @@ -241,13 +197,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top unknown, number >({ - mutationFn: (amountKopeks: number) => - balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), + mutationFn: (amountKopeks: number) => { + if (!method) throw new Error('Method not loaded'); + return balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined); + }, onSuccess: (data) => { const redirectUrl = data.payment_url || data.invoice_url; if (redirectUrl) { - // Always show the payment link for user to click manually - // This ensures it works on all platforms including iOS Safari setPaymentUrl(redirectUrl); } }, @@ -260,6 +216,32 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top }, }); + // Auto-focus input + useEffect(() => { + const timer = setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, 100); + return () => clearTimeout(timer); + }, []); + + if (!method) { + return ( +
+
+
+ ); + } + + const hasOptions = method.options && method.options.length > 0; + const minRubles = method.min_amount_kopeks / 100; + const maxRubles = method.max_amount_kopeks / 100; + const methodKey = method.id.toLowerCase().replace(/-/g, '_'); + const isStarsMethod = methodKey.includes('stars'); + const methodName = + t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name; + const handleSubmit = () => { setError(null); setPaymentUrl(null); @@ -302,24 +284,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top : convertAmount(rub).toFixed(currencyDecimals); const isPending = topUpMutation.isPending || starsPaymentMutation.isPending; - // Check if form is valid for MainButton - const amountNum = parseFloat(amount); - const amountRubles = !isNaN(amountNum) && amountNum > 0 ? convertToRub(amountNum) : 0; - const isFormValid = - !isPending && - !paymentUrl && - amountRubles >= minRubles && - amountRubles <= maxRubles && - (!hasOptions || !!selectedOption); - - // Telegram MainButton integration - shows "Top Up" action - useMainButton(isFormValid && !isInputFocused ? handleSubmit : null, { - text: t('balance.topUp'), - isLoading: isPending, - isActive: isFormValid, - visible: !paymentUrl && isMobileScreen, - }); - const handleCopyUrl = async () => { if (!paymentUrl) return; try { @@ -331,25 +295,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top } }; - // Auto-focus input - works on mobile in Telegram WebApp - useEffect(() => { - const timer = setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); - if (isMobileScreen) { - inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - } - }, 100); - return () => clearTimeout(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Content JSX - shared between mobile and desktop - const contentJSX = ( -
+ return ( + {/* Header icon and method */} -
+
-
+
{/* Payment options (if any) */} {hasOptions && method.options && ( -
+
{method.options.map((opt) => ( @@ -392,11 +346,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top ))}
-
+ )} {/* Amount input + Submit button - inline */} -
+
-
+
{/* Quick amount buttons */} {quickAmounts.length > 0 && ( -
+ {quickAmounts.map((a) => { const val = getQuickValue(a); const isSelected = amount === val; @@ -488,12 +442,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top ); })} -
+ )} {/* Error message */} {error && ( -
+ {error} -
+ )} {/* Payment link display - shown when URL is received */} {paymentUrl && ( -
+
{t('balance.paymentReady')} @@ -521,7 +481,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top

{t('balance.clickToOpenPayment')}

- {/* Main open button - NO preventDefault, let work natively for iOS Safari */} {t('balance.openPaymentPage')} - {/* Copy and link display */}

{paymentUrl}

@@ -550,87 +508,8 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top {copied ? : }
-
+ )} -
+
); - - // Render modal based on screen size - NO nested components! - const modalContent = isMobileScreen ? ( - <> - {/* Backdrop */} -
- {/* Bottom sheet */} -
e.stopPropagation()} - > - {/* Handle bar */} -
-
-
- - {/* Header */} -
-
- - {t('balance.topUp')} -
- -
- - {/* Divider */} -
- - {/* Content */} -
{contentJSX}
-
- - ) : ( -
-
e.stopPropagation()} - > - {/* Header */} -
-
-
- -
- {t('balance.topUp')} -
- -
- - {/* Content */} -
{contentJSX}
-
-
- ); - - if (typeof document !== 'undefined') { - return createPortal(modalContent, document.body); - } - return modalContent; } diff --git a/src/pages/TopUpMethodSelect.tsx b/src/pages/TopUpMethodSelect.tsx new file mode 100644 index 0000000..3afb02f --- /dev/null +++ b/src/pages/TopUpMethodSelect.tsx @@ -0,0 +1,97 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { motion } from 'framer-motion'; + +import { balanceApi } from '../api/balance'; +import { useCurrency } from '../hooks/useCurrency'; +import { useBackButton } from '@/platform'; +import { Card } from '@/components/data-display/Card'; +import { staggerContainer, staggerItem } from '@/components/motion/transitions'; + +export default function TopUpMethodSelect() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { formatAmount, currencySymbol } = useCurrency(); + + useBackButton(() => navigate('/balance')); + + const { data: paymentMethods, isLoading } = useQuery({ + queryKey: ['payment-methods'], + queryFn: balanceApi.getPaymentMethods, + }); + + const handleMethodClick = (methodId: string) => { + const params = new URLSearchParams(); + const amount = searchParams.get('amount'); + const returnTo = searchParams.get('returnTo'); + if (amount) params.set('amount', amount); + if (returnTo) params.set('returnTo', returnTo); + const qs = params.toString(); + navigate(`/balance/top-up/${methodId}${qs ? `?${qs}` : ''}`); + }; + + return ( + + +

+ {t('balance.selectPaymentMethod')} +

+
+ + + + {isLoading ? ( +
+
+
+ ) : !paymentMethods || paymentMethods.length === 0 ? ( +
+ {t('balance.noPaymentMethods')} +
+ ) : ( +
+ {paymentMethods.map((method) => { + const methodKey = method.id.toLowerCase().replace(/-/g, '_'); + const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { + defaultValue: '', + }); + const translatedDesc = t(`balance.paymentMethods.${methodKey}.description`, { + defaultValue: '', + }); + + return ( + method.is_available && handleMethodClick(method.id)} + > +
+ {translatedName || method.name} +
+ {(translatedDesc || method.description) && ( +
+ {translatedDesc || method.description} +
+ )} +
+ {formatAmount(method.min_amount_kopeks / 100, 0)} –{' '} + {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol} +
+
+ ); + })} +
+ )} + + + + ); +} diff --git a/src/platform/adapters/TelegramAdapter.ts b/src/platform/adapters/TelegramAdapter.ts index 07c19f2..1765bb0 100644 --- a/src/platform/adapters/TelegramAdapter.ts +++ b/src/platform/adapters/TelegramAdapter.ts @@ -5,9 +5,6 @@ import { onBackButtonClick, offBackButtonClick, isBackButtonVisible, - setMainButtonParams, - onMainButtonClick, - offMainButtonClick, hapticFeedbackImpactOccurred, hapticFeedbackNotificationOccurred, hapticFeedbackSelectionChanged, @@ -30,12 +27,10 @@ import type { PlatformContext, PlatformCapabilities, BackButtonController, - MainButtonController, HapticController, DialogController, ThemeController, CloudStorageController, - MainButtonConfig, PopupOptions, InvoiceStatus, HapticImpactStyle, @@ -47,7 +42,7 @@ function createCapabilities(): PlatformCapabilities { return { hasBackButton: inTelegram, - hasMainButton: inTelegram, + hasHapticFeedback: inTelegram, hasNativeDialogs: inTelegram, hasThemeSync: inTelegram, @@ -112,91 +107,6 @@ function createBackButtonController(): BackButtonController { }; } -function createMainButtonController(): MainButtonController { - const inTelegram = isInTelegramWebApp(); - let currentCallback: (() => void) | null = null; - - return { - get isVisible() { - return false; // SDK v3 doesn't expose this as a simple getter - }, - - show(config: MainButtonConfig) { - if (!inTelegram) return; - - if (currentCallback) { - try { - offMainButtonClick(currentCallback); - } catch { - // ignore - } - } - - currentCallback = config.onClick; - - try { - setMainButtonParams({ - text: config.text, - isVisible: true, - isEnabled: config.isActive !== false, - isLoaderVisible: config.isLoading ?? false, - backgroundColor: config.color as `#${string}` | undefined, - textColor: config.textColor as `#${string}` | undefined, - }); - onMainButtonClick(config.onClick); - } catch { - // Main button not mounted - } - }, - - hide() { - if (!inTelegram) return; - - if (currentCallback) { - try { - offMainButtonClick(currentCallback); - } catch { - // ignore - } - currentCallback = null; - } - - try { - setMainButtonParams({ isVisible: false, isLoaderVisible: false }); - } catch { - // Main button not mounted - } - }, - - showProgress(show: boolean) { - if (!inTelegram) return; - try { - setMainButtonParams({ isLoaderVisible: show }); - } catch { - // Main button not mounted - } - }, - - setText(text: string) { - if (!inTelegram) return; - try { - setMainButtonParams({ text }); - } catch { - // Main button not mounted - } - }, - - setActive(active: boolean) { - if (!inTelegram) return; - try { - setMainButtonParams({ isEnabled: active }); - } catch { - // Main button not mounted - } - }, - }; -} - function createHapticController(): HapticController { const inTelegram = isInTelegramWebApp(); @@ -382,7 +292,7 @@ export function createTelegramAdapter(): PlatformContext { platform: 'telegram', capabilities: createCapabilities(), backButton: createBackButtonController(), - mainButton: createMainButtonController(), + haptic: createHapticController(), dialog: createDialogController(), theme: createThemeController(), diff --git a/src/platform/adapters/WebAdapter.ts b/src/platform/adapters/WebAdapter.ts index 8f52b77..a75667a 100644 --- a/src/platform/adapters/WebAdapter.ts +++ b/src/platform/adapters/WebAdapter.ts @@ -2,12 +2,10 @@ import type { PlatformContext, PlatformCapabilities, BackButtonController, - MainButtonController, HapticController, DialogController, ThemeController, CloudStorageController, - MainButtonConfig, PopupOptions, InvoiceStatus, HapticImpactStyle, @@ -20,7 +18,7 @@ const STORAGE_PREFIX = 'bedolaga_'; function createCapabilities(): PlatformCapabilities { return { hasBackButton: false, // No native back button in web - hasMainButton: false, // No native main button in web + hasHapticFeedback: 'vibrate' in navigator, // Web Vibration API hasNativeDialogs: false, // Use custom dialogs hasThemeSync: false, // No header/bottom bar sync in web @@ -47,34 +45,6 @@ function createBackButtonController(): BackButtonController { }; } -function createMainButtonController(): MainButtonController { - // Web doesn't have a native main button - this is a no-op - // The UI will render its own submit buttons - return { - isVisible: false, - - show(_config: MainButtonConfig) { - // No-op in web - handled by UI components - }, - - hide() { - // No-op in web - }, - - showProgress(_show: boolean) { - // No-op in web - }, - - setText(_text: string) { - // No-op in web - }, - - setActive(_active: boolean) { - // No-op in web - }, - }; -} - function createHapticController(): HapticController { // Web Vibration API fallback (works on mobile browsers) const canVibrate = 'vibrate' in navigator; @@ -219,7 +189,7 @@ export function createWebAdapter(): PlatformContext { platform: 'web', capabilities: createCapabilities(), backButton: createBackButtonController(), - mainButton: createMainButtonController(), + haptic: createHapticController(), dialog: createDialogController(), theme: createThemeController(), diff --git a/src/platform/hooks/useMainButton.ts b/src/platform/hooks/useMainButton.ts deleted file mode 100644 index 6a6de95..0000000 --- a/src/platform/hooks/useMainButton.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useEffect, useRef, useCallback } from 'react'; -import { usePlatform } from '@/platform/hooks/usePlatform'; -import type { MainButtonConfig } from '@/platform/types'; - -interface UseMainButtonOptions extends Omit { - /** - * Whether the main button should be visible - * @default true - */ - visible?: boolean; -} - -/** - * Hook to manage the Telegram MainButton - * Automatically shows/hides based on component lifecycle - * - * @param onClick - Callback when main button is pressed - * @param options - Configuration options - * - * @example - * ```tsx - * function SubmitForm() { - * const mutation = useMutation(...); - * - * useMainButton(handleSubmit, { - * text: t('actions.submit'), - * isLoading: mutation.isPending, - * isActive: isFormValid, - * }); - * } - * ``` - */ -export function useMainButton( - onClick: (() => void) | null | undefined, - options: UseMainButtonOptions = { text: '' }, -): void { - const { mainButton, capabilities } = usePlatform(); - const { visible = true, text, isLoading, isActive, color, textColor } = options; - - // Use ref to prevent callback recreation issues - const callbackRef = useRef(onClick); - callbackRef.current = onClick; - - // Stable callback wrapper - const handleClick = useCallback(() => { - callbackRef.current?.(); - }, []); - - useEffect(() => { - // If no native main button support, do nothing - // UI components will render their own submit buttons - if (!capabilities.hasMainButton) { - return; - } - - // If callback is null/undefined or visible is false, hide button - if (!onClick || !visible || !text) { - mainButton.hide(); - return; - } - - // Show the main button with configuration - mainButton.show({ - text, - onClick: handleClick, - isLoading, - isActive, - color, - textColor, - }); - - // Cleanup: hide button when component unmounts - return () => { - mainButton.hide(); - }; - }, [ - mainButton, - capabilities.hasMainButton, - handleClick, - onClick, - visible, - text, - isLoading, - isActive, - color, - textColor, - ]); -} - -/** - * Hook for simple main button usage - * Shows button only when conditions are met - * - * @param text - Button text - * @param onClick - Click handler - * @param enabled - Whether button should be shown and active - * @param loading - Whether to show loading state - */ -export function useSimpleMainButton( - text: string, - onClick: () => void, - enabled: boolean = true, - loading: boolean = false, -): void { - useMainButton(enabled ? onClick : null, { - text, - isLoading: loading, - isActive: enabled && !loading, - visible: enabled, - }); -} diff --git a/src/platform/index.ts b/src/platform/index.ts index ff5d869..366e234 100644 --- a/src/platform/index.ts +++ b/src/platform/index.ts @@ -10,14 +10,12 @@ export type { PlatformType, PlatformContext as PlatformContextType, PlatformCapabilities, - MainButtonConfig, PopupOptions, PopupButton, InvoiceStatus, HapticImpactStyle, HapticNotificationType, BackButtonController, - MainButtonController, HapticController, DialogController, ThemeController, @@ -28,7 +26,6 @@ export type { // Hooks export { usePlatform, useIsTelegram, useCapability } from './hooks/usePlatform'; export { useBackButton, useConditionalBackButton } from './hooks/useBackButton'; -export { useMainButton, useSimpleMainButton } from './hooks/useMainButton'; export { useHaptic, useHapticClick, useHapticFeedback } from './hooks/useHaptic'; export { useNativeDialog, useDestructiveConfirm, PopupButtons } from './hooks/useNativeDialog'; export { useNotify } from './hooks/useNotify'; diff --git a/src/platform/types.ts b/src/platform/types.ts index 1f01b10..18597bc 100644 --- a/src/platform/types.ts +++ b/src/platform/types.ts @@ -7,15 +7,6 @@ export type HapticNotificationType = 'success' | 'warning' | 'error'; export type InvoiceStatus = 'paid' | 'cancelled' | 'failed' | 'pending'; -export interface MainButtonConfig { - text: string; - onClick: () => void; - isLoading?: boolean; - isActive?: boolean; - color?: string; - textColor?: string; -} - export interface PopupButton { id: string; type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive'; @@ -30,7 +21,7 @@ export interface PopupOptions { export interface PlatformCapabilities { hasBackButton: boolean; - hasMainButton: boolean; + hasHapticFeedback: boolean; hasNativeDialogs: boolean; hasThemeSync: boolean; @@ -46,15 +37,6 @@ export interface BackButtonController { isVisible: boolean; } -export interface MainButtonController { - show: (config: MainButtonConfig) => void; - hide: () => void; - showProgress: (show: boolean) => void; - setText: (text: string) => void; - setActive: (active: boolean) => void; - isVisible: boolean; -} - export interface HapticController { impact: (style?: HapticImpactStyle) => void; notification: (type: HapticNotificationType) => void; @@ -104,9 +86,6 @@ export interface PlatformContext { // Navigation backButton: BackButtonController; - // Main action button - mainButton: MainButtonController; - // Haptic feedback haptic: HapticController; From bb32cd8757b116728c0e7357fc40bcb842e7a476 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 14:51:12 +0300 Subject: [PATCH 2/8] fix: dim accent color for background blobs CSS wave-blobs now use accent-800 instead of accent-500 for a subdued background glow. Aurora WebGL shader receives a darkened accent (45% brightness) so bright accent colors like #e85002 no longer produce overly intense background blobs while button colors stay unchanged. --- src/components/layout/AppShell/Aurora.tsx | 17 ++++++++++++++++- src/styles/globals.css | 6 +++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx index 2cf9fec..31e9578 100644 --- a/src/components/layout/AppShell/Aurora.tsx +++ b/src/components/layout/AppShell/Aurora.tsx @@ -107,8 +107,23 @@ function hexToRgb(hex: string): [number, number, number] { return [r, g, b]; } +// Reduce lightness of a hex color for subdued background blobs +function dimAccent(hex: string, factor = 0.45): string { + hex = hex.replace('#', ''); + if (hex.length === 3) { + hex = hex + .split('') + .map((c) => c + c) + .join(''); + } + const r = Math.round(parseInt(hex.substring(0, 2), 16) * factor); + const g = Math.round(parseInt(hex.substring(2, 4), 16) * factor); + const b = Math.round(parseInt(hex.substring(4, 6), 16) * factor); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} + function generateColorStops(background: string, surface: string, accent: string): string[] { - return [background, surface, accent]; + return [background, surface, dimAccent(accent)]; } export function Aurora() { diff --git a/src/styles/globals.css b/src/styles/globals.css index d1e271c..f9e1881 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1436,7 +1436,7 @@ input[type='checkbox']:hover:not(:checked) { .wave-blob-1 { width: 400px; height: 400px; - background: radial-gradient(circle, rgba(var(--color-accent-500), 0.6) 0%, transparent 70%); + background: radial-gradient(circle, rgba(var(--color-accent-800), 0.6) 0%, transparent 70%); top: -15%; left: -10%; animation: wave1 20s ease-in-out infinite; /* Slower = less CPU */ @@ -1517,7 +1517,7 @@ input[type='checkbox']:hover:not(:checked) { } .light .wave-blob-1 { - background: radial-gradient(circle, rgba(var(--color-accent-500), 0.7) 0%, transparent 70%); + background: radial-gradient(circle, rgba(var(--color-accent-800), 0.7) 0%, transparent 70%); } /* Mobile: brighter and faster animations */ @@ -1530,7 +1530,7 @@ input[type='checkbox']:hover:not(:checked) { .wave-blob-1 { width: 300px; height: 300px; - background: radial-gradient(circle, rgba(var(--color-accent-500), 0.8) 0%, transparent 70%); + background: radial-gradient(circle, rgba(var(--color-accent-800), 0.8) 0%, transparent 70%); animation: wave1-mobile 12s ease-in-out infinite; } From 3a042473f027483a13a50bfe6dda3941546aaa17 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 15:40:30 +0300 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20unify=20ColorPicker=20=E2=80=94?= =?UTF-8?q?=20remove=20native=20input,=20fix=20scroll=20closing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove native , eyedropper button, colorInputRef, isTelegram check and isInTelegramWebApp import from ColorPicker - Remove scroll event listener so picker only closes on click/tap outside - Replace native color input in AdminWheel InlinePrizeForm with ColorPicker --- src/components/ColorPicker.tsx | 56 +--------------------------------- src/pages/AdminWheel.tsx | 26 ++++------------ 2 files changed, 7 insertions(+), 75 deletions(-) diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 080e96e..8a8fd17 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,6 +1,5 @@ -import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { isInTelegramWebApp } from '@/hooks/useTelegramSDK'; interface ColorPickerProps { value: string; @@ -111,9 +110,6 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C const buttonRef = useRef(null); const pickerRef = useRef(null); - const colorInputRef = useRef(null); - - const isTelegram = useMemo(() => isInTelegramWebApp(), []); // Sync with external value useEffect(() => { @@ -177,18 +173,15 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C } }; - const handleScroll = () => handleClose(); const handleResize = () => updatePosition(); document.addEventListener('mousedown', handleClickOutside); document.addEventListener('touchstart', handleClickOutside as EventListener); - window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', handleResize); return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('touchstart', handleClickOutside as EventListener); - window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('resize', handleResize); }; }, [isOpen, handleClose, updatePosition]); @@ -229,18 +222,6 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C [hsl, updateColorFromHsl], ); - // Handle native color input - const handleColorInputChange = useCallback( - (e: React.ChangeEvent) => { - const newColor = e.target.value; - setLocalValue(newColor); - const rgb = hexToRgb(newColor); - setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)); - onChange(newColor); - }, - [onChange], - ); - // Handle hex input const handleHexInputChange = useCallback( (e: React.ChangeEvent) => { @@ -409,41 +390,6 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C placeholder="#000000" maxLength={7} /> - - {/* Native color picker button (hidden in Telegram) */} - {!isTelegram && ( - <> - - - - )}
{/* Render picker in portal */} diff --git a/src/pages/AdminWheel.tsx b/src/pages/AdminWheel.tsx index 213e7a0..25bc23e 100644 --- a/src/pages/AdminWheel.tsx +++ b/src/pages/AdminWheel.tsx @@ -23,6 +23,7 @@ import { CSS } from '@dnd-kit/utilities'; import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel'; import { useDestructiveConfirm } from '@/platform'; import FortuneWheel from '../components/wheel/FortuneWheel'; +import { ColorPicker } from '@/components/ColorPicker'; import { useBackButton } from '../platform/hooks/useBackButton'; import { usePlatform } from '../platform/hooks/usePlatform'; @@ -1146,26 +1147,11 @@ function InlinePrizeForm({
{/* Color */} -
- -
- setFormData({ ...formData, color: e.target.value })} - className="h-10 w-12 cursor-pointer rounded" - /> - setFormData({ ...formData, color: e.target.value })} - className="input flex-1" - pattern="^#[0-9A-Fa-f]{6}$" - /> -
-
+ setFormData({ ...formData, color })} + />
{/* Active toggle */} From 59a251cb8c706b3029990e58fc6003ce620f80d3 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 16:00:05 +0300 Subject: [PATCH 4/8] fix: update Aurora colors reactively without recreating WebGL context Split the monolithic useEffect into two: one for WebGL init (depends only on isEnabled) and one for updating color uniforms (depends on theme colors). Previously the entire WebGL context was torn down and rebuilt on every color change, causing updates to not apply until page refresh or navigation. --- src/components/layout/AppShell/Aurora.tsx | 32 +++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx index 31e9578..415f7d5 100644 --- a/src/components/layout/AppShell/Aurora.tsx +++ b/src/components/layout/AppShell/Aurora.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { Renderer, Program, Mesh, Color, Triangle } from 'ogl'; import { brandingApi } from '@/api/branding'; +import { themeColorsApi } from '@/api/themeColors'; import { useTheme } from '@/hooks/useTheme'; -import { ThemeSettings, DEFAULT_THEME_COLORS } from '@/types/theme'; +import { DEFAULT_THEME_COLORS } from '@/types/theme'; const VERT = /* glsl */ `#version 300 es in vec2 position; @@ -132,8 +133,6 @@ export function Aurora() { const rendererRef = useRef(null); const programRef = useRef(null); - const queryClient = useQueryClient(); - // Fetch animation setting const { data: animationSetting } = useQuery({ queryKey: ['animation-enabled'], @@ -143,15 +142,19 @@ export function Aurora() { const isEnabled = animationSetting?.enabled ?? false; - // Get theme colors from cache (already fetched by ThemeColorsProvider) - const themeColors = - queryClient.getQueryData(['theme-colors']) || DEFAULT_THEME_COLORS; + // Subscribe reactively to theme-colors cache so Aurora re-renders on setQueryData + const { data: themeColors = DEFAULT_THEME_COLORS } = useQuery({ + queryKey: ['theme-colors'], + queryFn: themeColorsApi.getColors, + staleTime: 5 * 60 * 1000, + }); // Pick background and surface based on current theme const { isDark } = useTheme(); const background = isDark ? themeColors.darkBackground : themeColors.lightBackground; const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface; + // Initialize WebGL context once (only depends on isEnabled) useEffect(() => { if (!isEnabled || !containerRef.current) return; @@ -235,7 +238,20 @@ export function Aurora() { rendererRef.current = null; programRef.current = null; }; - }, [isEnabled, themeColors.accent, background, surface]); + }, [isEnabled]); // eslint-disable-line react-hooks/exhaustive-deps + + // Update color uniforms reactively without recreating WebGL context + useEffect(() => { + if (!programRef.current) return; + const colorStops = generateColorStops(background, surface, themeColors.accent); + const colorStopsArray = colorStops + .map((hex) => { + const c = new Color(hex); + return [c.r, c.g, c.b]; + }) + .flat(); + programRef.current.uniforms.uColorStops.value = colorStopsArray; + }, [themeColors.accent, background, surface]); if (!isEnabled) { return null; From 4499c9ad57dcdcd3126c8d4261bc9f32accd21d7 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 16:09:10 +0300 Subject: [PATCH 5/8] fix: prevent payment type reset after wheel spin The auto-select useEffect for payment type (days/stars) was running on every config refetch, resetting the user's manual selection back to stars. Now it only runs once on initial load using a ref flag. --- src/pages/Wheel.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index df745bc..5a70cc6 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -72,6 +72,7 @@ export default function Wheel() { ); const [isPayingStars, setIsPayingStars] = useState(false); const [historyExpanded, setHistoryExpanded] = useState(false); + const paymentTypeInitialized = useRef(false); // Check if we're in Telegram Mini App environment const isTelegramMiniApp = platform === 'telegram'; @@ -90,9 +91,10 @@ export default function Wheel() { queryFn: () => wheelApi.getHistory(1, 10), }); - // Auto-select payment type based on availability + // Auto-select payment type based on availability (only on initial load) useEffect(() => { - if (!config) return; + if (!config || paymentTypeInitialized.current) return; + paymentTypeInitialized.current = true; const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars; const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days; From 02640d1c38dde00d431058a399adcc85fd9bcaac Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 16:26:07 +0300 Subject: [PATCH 6/8] fix: unify wheel Stars payment across desktop and mobile - Use Telegram Stars invoice on all platforms instead of balance payment on desktop - Show star cost in toggle and subtitle everywhere - Show popup/toast when Stars are not enabled and user tries to spin - Remove platform-specific payment type auto-select logic - Add starsNotAvailable translation key (ru, en, zh, fa) --- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/Wheel.tsx | 52 ++++++++++++++------------------------------- 5 files changed, 20 insertions(+), 36 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index c2cebf0..fc1268a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -713,6 +713,7 @@ "starsPaymentSuccessCheckHistory": "Payment successful! Check history or Telegram for the result.", "starsPaymentFailed": "Payment failed. Please try again.", "starsPaymentRedirected": "Telegram will open for payment. Refresh the page after payment.", + "starsNotAvailable": "Star payments are currently unavailable. Contact support.", "youWon": "You won", "noPrize": "No luck this time...", "noHistory": "No spin history yet", diff --git a/src/locales/fa.json b/src/locales/fa.json index 83a82c4..5a92e26 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -599,6 +599,7 @@ "starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.", "starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.", "starsPaymentRedirected": "تلگرام برای پرداخت باز می‌شود. پس از پرداخت صفحه را بازخوانی کنید.", + "starsNotAvailable": "پرداخت با ستاره در حال حاضر در دسترس نیست. با پشتیبانی تماس بگیرید.", "noHistory": "هنوز تاریخچه‌ای نیست", "banner": { "title": "شانس خود را امتحان کنید!", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0e7fa9a..7c83bda 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -741,6 +741,7 @@ "starsPaymentSuccessCheckHistory": "Оплата прошла! Проверьте историю или Telegram для результата.", "starsPaymentFailed": "Ошибка оплаты. Попробуйте еще раз.", "starsPaymentRedirected": "Откроется Telegram для оплаты. После оплаты обновите страницу.", + "starsNotAvailable": "Оплата звёздами сейчас недоступна. Обратитесь в поддержку.", "youWon": "Вы выиграли", "noPrize": "В этот раз не повезло...", "noHistory": "История вращений пуста", diff --git a/src/locales/zh.json b/src/locales/zh.json index bc5c0a2..97cd77a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -603,6 +603,7 @@ "starsPaymentSuccess": "支付成功!抽奖结果已发送到Telegram。", "starsPaymentFailed": "支付失败。请重试。", "starsPaymentRedirected": "将打开Telegram进行支付。支付后请刷新页面。", + "starsNotAvailable": "星星支付暂不可用。请联系客服。", "noHistory": "暂无抽奖记录", "banner": { "title": "试试运气!", diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 5a70cc6..1d96092 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -5,8 +5,8 @@ import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel'; import FortuneWheel from '../components/wheel/FortuneWheel'; import WheelLegend from '../components/wheel/WheelLegend'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; -import { useCurrency } from '../hooks/useCurrency'; import { usePlatform, useHaptic } from '@/platform'; +import { useNotify } from '@/platform/hooks/useNotify'; import { Card } from '@/components/data-display/Card/Card'; import { Button } from '@/components/primitives/Button/Button'; import { motion, AnimatePresence } from 'framer-motion'; @@ -60,9 +60,9 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( export default function Wheel() { const { t } = useTranslation(); const queryClient = useQueryClient(); - const { formatAmount, currencySymbol } = useCurrency(); - const { platform, openInvoice, capabilities } = usePlatform(); + const { openInvoice, capabilities } = usePlatform(); const haptic = useHaptic(); + const notify = useNotify(); const [isSpinning, setIsSpinning] = useState(false); const [targetRotation, setTargetRotation] = useState(null); @@ -74,9 +74,6 @@ export default function Wheel() { const [historyExpanded, setHistoryExpanded] = useState(false); const paymentTypeInitialized = useRef(false); - // Check if we're in Telegram Mini App environment - const isTelegramMiniApp = platform === 'telegram'; - const { data: config, isLoading, @@ -98,25 +95,13 @@ export default function Wheel() { const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars; const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days; - const canPayBalance = starsEnabled && config.can_pay_stars; - const canPayDays = daysEnabled && config.can_pay_days; - if (isTelegramMiniApp) { - // In Mini App: prefer stars if available - if (starsEnabled) { - setPaymentType('telegram_stars'); - } else if (canPayDays) { - setPaymentType('subscription_days'); - } - } else { - // In Web: prefer balance (Stars converted to rubles), fallback to days - if (canPayBalance) { - setPaymentType('telegram_stars'); - } else if (canPayDays) { - setPaymentType('subscription_days'); - } + if (starsEnabled) { + setPaymentType('telegram_stars'); + } else if (daysEnabled) { + setPaymentType('subscription_days'); } - }, [config, isTelegramMiniApp]); + }, [config]); // Function to poll for new spin result after Stars payment const pollForSpinResult = useCallback( @@ -362,7 +347,11 @@ export default function Wheel() { }; const handleUnifiedSpin = () => { - if (isTelegramMiniApp && paymentType === 'telegram_stars') { + if (paymentType === 'telegram_stars') { + if (!config?.spin_cost_stars_enabled || !config?.spin_cost_stars) { + notify.warning(t('wheel.starsNotAvailable')); + return; + } handleDirectStarsPay(); } else { handleSpin(); @@ -456,11 +445,7 @@ export default function Wheel() { const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars; const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days; - const canPayBalance = starsEnabled && config.can_pay_stars; // For web: pay with internal balance - const canPayDays = daysEnabled && config.can_pay_days; - const bothMethodsAvailable = isTelegramMiniApp - ? starsEnabled && daysEnabled && canPayDays - : starsEnabled && daysEnabled && canPayBalance && canPayDays; + const bothMethodsAvailable = !!(starsEnabled && daysEnabled); const spinDisabled = !config.can_spin || @@ -472,10 +457,7 @@ export default function Wheel() { const getCostSubtitle = () => { if (bothMethodsAvailable) return null; // toggle handles it if (paymentType === 'telegram_stars' && starsEnabled) { - if (isTelegramMiniApp) { - return `${config.spin_cost_stars} ⭐`; - } - return `${formatAmount(config.required_balance_kopeks / 100)} ${currencySymbol} (${config.spin_cost_stars} ⭐)`; + return `${config.spin_cost_stars} ⭐`; } if (paymentType === 'subscription_days' && daysEnabled) { return t('wheel.days', { count: config.spin_cost_days ?? 0 }); @@ -529,9 +511,7 @@ export default function Wheel() { }`} > - {isTelegramMiniApp - ? `${config.spin_cost_stars} ⭐` - : `${formatAmount(config.required_balance_kopeks / 100)} ${currencySymbol}`} + {`${config.spin_cost_stars} ⭐`} - +
+ {starsEnabled && ( + + )} + {daysEnabled && ( + + )}
)} - {/* Single Spin Button */} - - - {/* Cost subtitle when no toggle */} - {costSubtitle && !bothMethodsAvailable && ( -

{costSubtitle}

+ {/* Stars confirmation panel */} + {showStarsConfirm && !isSpinning && !isPayingStars ? ( +
+

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

+
+ + +
+
+ ) : ( + /* Single Spin Button */ + )} {/* Cannot spin hint */}