From 576893f5c6b67c19bee0cd562cd0430a88350619 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 14:36:16 +0300 Subject: [PATCH] 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;