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
This commit is contained in:
c0mrade
2026-02-04 14:36:16 +03:00
parent 6e98a19d6b
commit 576893f5c6
13 changed files with 333 additions and 766 deletions

View File

@@ -26,6 +26,8 @@ const Contests = lazy(() => import('./pages/Contests'));
const Polls = lazy(() => import('./pages/Polls')); const Polls = lazy(() => import('./pages/Polls'));
const Info = lazy(() => import('./pages/Info')); const Info = lazy(() => import('./pages/Info'));
const Wheel = lazy(() => import('./pages/Wheel')); 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) // Admin pages - lazy load (only for admins)
const AdminPanel = lazy(() => import('./pages/AdminPanel')); const AdminPanel = lazy(() => import('./pages/AdminPanel'));
@@ -170,6 +172,26 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/balance/top-up"
element={
<ProtectedRoute>
<LazyPage>
<TopUpMethodSelect />
</LazyPage>
</ProtectedRoute>
}
/>
<Route
path="/balance/top-up/:methodId"
element={
<ProtectedRoute>
<LazyPage>
<TopUpAmount />
</LazyPage>
</ProtectedRoute>
}
/>
<Route <Route
path="/referral" path="/referral"
element={ element={

View File

@@ -1,11 +1,7 @@
import { useState, useCallback } from 'react'; import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { useCloseOnSuccessNotification } from '../store/successNotification';
import TopUpModal from './TopUpModal';
import type { PaymentMethod } from '../types';
interface InsufficientBalancePromptProps { interface InsufficientBalancePromptProps {
/** Amount missing in kopeks */ /** Amount missing in kopeks */
@@ -28,205 +24,114 @@ export default function InsufficientBalancePrompt({
onBeforeTopUp, onBeforeTopUp,
}: InsufficientBalancePromptProps) { }: InsufficientBalancePromptProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { formatAmount, currencySymbol } = useCurrency(); const { formatAmount, currencySymbol } = useCurrency();
const [showMethodSelect, setShowMethodSelect] = useState(false);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
const [isPreparingTopUp, setIsPreparingTopUp] = useState(false); 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 missingRubles = missingAmountKopeks / 100;
const displayAmount = formatAmount(missingRubles); const displayAmount = formatAmount(missingRubles);
const handleMethodSelect = (method: PaymentMethod) => {
setSelectedMethod(method);
setShowMethodSelect(false);
};
const handleTopUpClick = async () => { const handleTopUpClick = async () => {
if (onBeforeTopUp) { if (onBeforeTopUp) {
setIsPreparingTopUp(true); setIsPreparingTopUp(true);
try { try {
await onBeforeTopUp(); await onBeforeTopUp();
} catch { } catch {
// Silently ignore errors - still open the modal // Silently ignore errors - still navigate
} finally { } finally {
setIsPreparingTopUp(false); 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) { if (compact) {
return ( return (
<>
<div
className={`flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3 ${className}`}
>
<div className="flex items-center gap-2 text-sm text-error-400">
<svg
className="h-4 w-4 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<span>
{message || t('balance.insufficientFunds')}:{' '}
<span className="font-semibold">
{displayAmount} {currencySymbol}
</span>
</span>
</div>
<button
onClick={handleTopUpClick}
disabled={isPreparingTopUp}
className="btn-primary whitespace-nowrap px-3 py-1.5 text-xs"
>
{isPreparingTopUp ? (
<span className="h-3 w-3 animate-spin rounded-full border border-white/30 border-t-white" />
) : (
t('balance.topUp')
)}
</button>
</div>
{showMethodSelect && (
<PaymentMethodModal
paymentMethods={paymentMethods}
onSelect={handleMethodSelect}
onClose={() => setShowMethodSelect(false)}
/>
)}
{selectedMethod && (
<TopUpModal
method={selectedMethod}
initialAmountRubles={Math.ceil(missingRubles)}
onClose={() => setSelectedMethod(null)}
/>
)}
</>
);
}
return (
<>
<div <div
className={`rounded-xl border border-error-500/30 bg-gradient-to-br from-error-500/10 to-warning-500/5 p-4 ${className}`} className={`flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3 ${className}`}
> >
<div className="flex items-start gap-3"> <div className="flex items-center gap-2 text-sm text-error-400">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20"> <svg
<svg className="h-4 w-4 flex-shrink-0"
className="h-5 w-5 text-error-400" fill="none"
fill="none" viewBox="0 0 24 24"
viewBox="0 0 24 24" stroke="currentColor"
stroke="currentColor" strokeWidth={2}
strokeWidth={2} >
> <path
<path strokeLinecap="round"
strokeLinecap="round" strokeLinejoin="round"
strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
/> </svg>
</svg> <span>
</div> {message || t('balance.insufficientFunds')}:{' '}
<div className="min-w-0 flex-1"> <span className="font-semibold">
<div className="mb-1 font-medium text-error-400">{t('balance.insufficientFunds')}</div> {displayAmount} {currencySymbol}
<div className="text-sm text-dark-300">{message || t('balance.topUpToComplete')}</div> </span>
<div className="mt-3 flex items-center gap-3"> </span>
<div className="text-lg font-bold text-dark-100">
{t('balance.missing')}:{' '}
<span className="text-error-400">
{displayAmount} {currencySymbol}
</span>
</div>
</div>
</div>
</div> </div>
<button <button
onClick={handleTopUpClick} onClick={handleTopUpClick}
disabled={isPreparingTopUp} disabled={isPreparingTopUp}
className="btn-primary mt-4 flex w-full items-center justify-center gap-2 py-2.5" className="btn-primary whitespace-nowrap px-3 py-1.5 text-xs"
> >
{isPreparingTopUp ? ( {isPreparingTopUp ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <span className="h-3 w-3 animate-spin rounded-full border border-white/30 border-t-white" />
) : ( ) : (
<> t('balance.topUp')
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{t('balance.topUpBalance')}
</>
)} )}
</button> </button>
</div> </div>
);
{showMethodSelect && ( }
<PaymentMethodModal
paymentMethods={paymentMethods}
onSelect={handleMethodSelect}
onClose={() => setShowMethodSelect(false)}
/>
)}
{selectedMethod && (
<TopUpModal
method={selectedMethod}
initialAmountRubles={Math.ceil(missingRubles)}
onClose={() => 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 ( return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 px-4 pb-28 pt-14 backdrop-blur-sm sm:pb-0 sm:pt-0"> <div
<div className="absolute inset-0" onClick={onClose} /> className={`rounded-xl border border-error-500/30 bg-gradient-to-br from-error-500/10 to-warning-500/5 p-4 ${className}`}
>
<div className="relative flex max-h-full w-full max-w-sm flex-col overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900/95 shadow-2xl backdrop-blur-xl"> <div className="flex items-start gap-3">
{/* Header */} <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20">
<div className="flex items-center justify-between bg-dark-800/50 px-4 py-3"> <svg
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span> className="h-5 w-5 text-error-400"
<button fill="none"
onClick={onClose} viewBox="0 0 24 24"
className="rounded-lg p-1.5 text-dark-400 hover:bg-dark-700" stroke="currentColor"
aria-label="Close" strokeWidth={2}
> >
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 font-medium text-error-400">{t('balance.insufficientFunds')}</div>
<div className="text-sm text-dark-300">{message || t('balance.topUpToComplete')}</div>
<div className="mt-3 flex items-center gap-3">
<div className="text-lg font-bold text-dark-100">
{t('balance.missing')}:{' '}
<span className="text-error-400">
{displayAmount} {currencySymbol}
</span>
</div>
</div>
</div>
</div>
<button
onClick={handleTopUpClick}
disabled={isPreparingTopUp}
className="btn-primary mt-4 flex w-full items-center justify-center gap-2 py-2.5"
>
{isPreparingTopUp ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<>
<svg <svg
className="h-5 w-5" className="h-5 w-5"
fill="none" fill="none"
@@ -234,82 +139,12 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
</button> {t('balance.topUpBalance')}
</div> </>
)}
{/* Content */} </button>
<div className="flex-1 space-y-2 overflow-y-auto p-3">
{!paymentMethods ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : paymentMethods.length === 0 ? (
<div className="py-6 text-center text-sm text-dark-400">
{t('balance.noPaymentMethods')}
</div>
) : (
paymentMethods.map((method) => {
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
defaultValue: '',
});
return (
<button
key={method.id}
disabled={!method.is_available}
onClick={() => method.is_available && onSelect(method)}
className={`flex w-full items-center gap-3 rounded-xl p-3 text-left ${
method.is_available
? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600'
: 'bg-dark-800/50 opacity-50'
}`}
>
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-accent-500/20">
<svg
className="h-4 w-4 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-dark-100">
{translatedName || method.name}
</div>
<div className="text-xs text-dark-500">
{formatAmount(method.min_amount_kopeks / 100, 0)} {' '}
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
</div>
</div>
<svg
className="h-4 w-4 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
);
})
)}
</div>
</div>
</div> </div>
); );
} }

View File

@@ -98,29 +98,29 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const typeStyles = { const typeStyles = {
success: { success: {
bg: 'bg-dark-800', bg: 'bg-dark-800',
accent: 'bg-gradient-to-r from-success-500/30 to-transparent', accent: 'bg-gradient-to-r from-success-500/50 to-transparent',
border: 'border-success-500/50', border: 'border-success-500/70',
icon: 'text-success-400', icon: 'text-success-400',
iconBg: 'bg-success-500/30', iconBg: 'bg-success-500/30',
}, },
error: { error: {
bg: 'bg-dark-800', bg: 'bg-dark-800',
accent: 'bg-gradient-to-r from-error-500/30 to-transparent', accent: 'bg-gradient-to-r from-error-500/50 to-transparent',
border: 'border-error-500/50', border: 'border-error-500/70',
icon: 'text-error-400', icon: 'text-error-400',
iconBg: 'bg-error-500/30', iconBg: 'bg-error-500/30',
}, },
warning: { warning: {
bg: 'bg-dark-800', bg: 'bg-dark-800',
accent: 'bg-gradient-to-r from-warning-500/30 to-transparent', accent: 'bg-gradient-to-r from-warning-500/50 to-transparent',
border: 'border-warning-500/50', border: 'border-warning-500/70',
icon: 'text-warning-400', icon: 'text-warning-400',
iconBg: 'bg-warning-500/30', iconBg: 'bg-warning-500/30',
}, },
info: { info: {
bg: 'bg-dark-800', bg: 'bg-dark-800',
accent: 'bg-gradient-to-r from-accent-500/30 to-transparent', accent: 'bg-gradient-to-r from-accent-500/50 to-transparent',
border: 'border-accent-500/50', border: 'border-accent-500/70',
icon: 'text-accent-400', icon: 'text-accent-400',
iconBg: 'bg-accent-500/30', iconBg: 'bg-accent-500/30',
}, },

View File

@@ -145,7 +145,10 @@ export function AppHeader({
}; };
}, [mobileMenuOpen]); }, [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 isAdminActive = () => location.pathname.startsWith('/admin');
const navItems = [ const navItems = [

View File

@@ -14,7 +14,6 @@ import {
mountClosingBehavior, mountClosingBehavior,
disableClosingConfirmation, disableClosingConfirmation,
mountBackButton, mountBackButton,
mountMainButton,
bindThemeParamsCssVars, bindThemeParamsCssVars,
bindViewportCssVars, bindViewportCssVars,
requestFullscreen, requestFullscreen,
@@ -66,12 +65,6 @@ if (!alreadyInitialized) {
} catch { } catch {
/* already mounted */ /* already mounted */
} }
try {
mountMainButton();
} catch {
/* already mounted */
}
// Viewport — async, fullscreen зависит от смонтированного viewport // Viewport — async, fullscreen зависит от смонтированного viewport
mountViewport() mountViewport()
.then(() => { .then(() => {

View File

@@ -6,10 +6,9 @@ import { motion, AnimatePresence } from 'framer-motion';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { balanceApi } from '../api/balance'; import { balanceApi } from '../api/balance';
import TopUpModal from '../components/TopUpModal';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { useToast } from '../components/Toast'; 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 { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
@@ -92,7 +91,6 @@ export default function Balance() {
} }
}, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]); }, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
const [promocode, setPromocode] = useState(''); const [promocode, setPromocode] = useState('');
const [promocodeLoading, setPromocodeLoading] = useState(false); const [promocodeLoading, setPromocodeLoading] = useState(false);
const [promocodeError, setPromocodeError] = useState<string | null>(null); const [promocodeError, setPromocodeError] = useState<string | null>(null);
@@ -285,7 +283,7 @@ export default function Balance() {
key={method.id} key={method.id}
interactive={method.is_available} interactive={method.is_available}
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''} 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}`)}
> >
<div className="font-semibold text-dark-100"> <div className="font-semibold text-dark-100">
{translatedName || method.name} {translatedName || method.name}
@@ -423,11 +421,6 @@ export default function Balance() {
</AnimatePresence> </AnimatePresence>
</Card> </Card>
</motion.div> </motion.div>
{/* TopUp Modal */}
{selectedMethod && (
<TopUpModal method={selectedMethod} onClose={() => setSelectedMethod(null)} />
)}
</motion.div> </motion.div>
); );
} }

View File

@@ -1,33 +1,19 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; 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 { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
import { useCloseOnSuccessNotification } from '../store/successNotification'; 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 type { PaymentMethod } from '../types';
import BentoCard from './ui/BentoCard'; import BentoCard from '../components/ui/BentoCard';
// Icons // Icons
const CloseIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const WalletIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
/>
</svg>
);
const StarIcon = () => ( const StarIcon = () => (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
@@ -86,26 +72,6 @@ const CheckIcon = () => (
</svg> </svg>
); );
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 getMethodIcon = (methodId: string) => {
const id = methodId.toLowerCase(); const id = methodId.toLowerCase();
if (id.includes('stars')) return <StarIcon />; if (id.includes('stars')) return <StarIcon />;
@@ -113,19 +79,52 @@ const getMethodIcon = (methodId: string) => {
return <CardIcon />; return <CardIcon />;
}; };
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { export default function TopUpAmount() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const { methodId } = useParams<{ methodId: string }>();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
useCurrency(); useCurrency();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
const { openInvoice } = usePlatform(); const { openInvoice } = usePlatform();
const haptic = useHaptic(); const haptic = useHaptic();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const isMobileScreen = useIsMobile();
const safeBottom = isTelegramWebApp const returnTo = searchParams.get('returnTo');
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) const initialAmountRubles = searchParams.get('amount')
: 0; ? parseFloat(searchParams.get('amount')!)
: undefined;
// Get method from cached payment-methods query
const cachedMethods = queryClient.getQueryData<PaymentMethod[]>(['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 => { const getInitialAmount = (): string => {
if (!initialAmountRubles || initialAmountRubles <= 0) return ''; if (!initialAmountRubles || initialAmountRubles <= 0) return '';
@@ -138,65 +137,24 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
const [amount, setAmount] = useState(getInitialAmount); const [amount, setAmount] = useState(getInitialAmount);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedOption, setSelectedOption] = useState<string | null>( const [selectedOption, setSelectedOption] = useState<string | null>(
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<string | null>(null); const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
const handleClose = useCallback(() => { // If method not found in cache, redirect to method selection
onClose();
}, [onClose]);
// Auto-close when success notification appears (e.g., balance topped up via WebSocket)
useCloseOnSuccessNotification(handleClose);
// Keyboard: Escape to close (PC)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { if (cachedMethods && !method) {
if (e.key === 'Escape') { const params = new URLSearchParams();
e.preventDefault(); const amount = searchParams.get('amount');
handleClose(); const rt = searchParams.get('returnTo');
} if (amount) params.set('amount', amount);
}; if (rt) params.set('returnTo', rt);
document.addEventListener('keydown', handleKeyDown); const qs = params.toString();
return () => document.removeEventListener('keydown', handleKeyDown); navigate(`/balance/top-up${qs ? `?${qs}` : ''}`, { replace: true });
}, [handleClose]); }
}, [cachedMethods, method, navigate, searchParams]);
// 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;
const starsPaymentMutation = useMutation({ const starsPaymentMutation = useMutation({
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
@@ -206,17 +164,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
return; return;
} }
try { try {
// Use platform-agnostic invoice opening
const status = await openInvoice(data.invoice_url); const status = await openInvoice(data.invoice_url);
if (status === 'paid') { if (status === 'paid') {
haptic.notification('success'); haptic.notification('success');
setError(null); setError(null);
onClose(); handleSuccess();
} else if (status === 'failed') { } else if (status === 'failed') {
haptic.notification('error'); haptic.notification('error');
setError(t('wheel.starsPaymentFailed')); setError(t('wheel.starsPaymentFailed'));
} }
// 'pending' and 'cancelled' just close without action
} catch (e) { } catch (e) {
setError(t('balance.errors.generic', { details: String(e) })); setError(t('balance.errors.generic', { details: String(e) }));
} }
@@ -241,13 +197,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
unknown, unknown,
number number
>({ >({
mutationFn: (amountKopeks: number) => mutationFn: (amountKopeks: number) => {
balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), if (!method) throw new Error('Method not loaded');
return balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined);
},
onSuccess: (data) => { onSuccess: (data) => {
const redirectUrl = data.payment_url || data.invoice_url; const redirectUrl = data.payment_url || data.invoice_url;
if (redirectUrl) { if (redirectUrl) {
// Always show the payment link for user to click manually
// This ensures it works on all platforms including iOS Safari
setPaymentUrl(redirectUrl); 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 (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
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 = () => { const handleSubmit = () => {
setError(null); setError(null);
setPaymentUrl(null); setPaymentUrl(null);
@@ -302,24 +284,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
: convertAmount(rub).toFixed(currencyDecimals); : convertAmount(rub).toFixed(currencyDecimals);
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending; 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 () => { const handleCopyUrl = async () => {
if (!paymentUrl) return; if (!paymentUrl) return;
try { try {
@@ -331,25 +295,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
} }
}; };
// Auto-focus input - works on mobile in Telegram WebApp return (
useEffect(() => { <motion.div
const timer = setTimeout(() => { className="mx-auto max-w-lg space-y-5"
if (inputRef.current) { variants={staggerContainer}
inputRef.current.focus(); initial="initial"
if (isMobileScreen) { animate="animate"
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 = (
<div className="space-y-5">
{/* Header icon and method */} {/* Header icon and method */}
<div className="flex items-center gap-4 pb-1"> <motion.div variants={staggerItem} className="flex items-center gap-4 pb-1">
<div <div
className={`flex h-14 w-14 items-center justify-center rounded-2xl ${ className={`flex h-14 w-14 items-center justify-center rounded-2xl ${
isStarsMethod isStarsMethod
@@ -365,11 +319,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
{formatAmount(minRubles, 0)} {formatAmount(maxRubles, 0)} {currencySymbol} {formatAmount(minRubles, 0)} {formatAmount(maxRubles, 0)} {currencySymbol}
</p> </p>
</div> </div>
</div> </motion.div>
{/* Payment options (if any) */} {/* Payment options (if any) */}
{hasOptions && method.options && ( {hasOptions && method.options && (
<div className="space-y-2"> <motion.div variants={staggerItem} className="space-y-2">
<label className="text-sm font-medium text-dark-400">{t('balance.paymentMethod')}</label> <label className="text-sm font-medium text-dark-400">{t('balance.paymentMethod')}</label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{method.options.map((opt) => ( {method.options.map((opt) => (
@@ -392,11 +346,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
</button> </button>
))} ))}
</div> </div>
</div> </motion.div>
)} )}
{/* Amount input + Submit button - inline */} {/* Amount input + Submit button - inline */}
<div className="space-y-2"> <motion.div variants={staggerItem} className="space-y-2">
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label> <label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
<div className="flex gap-2"> <div className="flex gap-2">
<div <div
@@ -452,11 +406,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
)} )}
</button> </button>
</div> </div>
</div> </motion.div>
{/* Quick amount buttons */} {/* Quick amount buttons */}
{quickAmounts.length > 0 && ( {quickAmounts.length > 0 && (
<div className="grid grid-cols-4 gap-2"> <motion.div variants={staggerItem} className="grid grid-cols-4 gap-2">
{quickAmounts.map((a) => { {quickAmounts.map((a) => {
const val = getQuickValue(a); const val = getQuickValue(a);
const isSelected = amount === val; const isSelected = amount === val;
@@ -488,12 +442,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
</BentoCard> </BentoCard>
); );
})} })}
</div> </motion.div>
)} )}
{/* Error message */} {/* Error message */}
{error && ( {error && (
<div className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3"> <motion.div
variants={staggerItem}
className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3"
>
<svg <svg
className="h-5 w-5 shrink-0 text-error-400" className="h-5 w-5 shrink-0 text-error-400"
fill="none" fill="none"
@@ -508,12 +465,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
/> />
</svg> </svg>
<span className="text-sm text-error-400">{error}</span> <span className="text-sm text-error-400">{error}</span>
</div> </motion.div>
)} )}
{/* Payment link display - shown when URL is received */} {/* Payment link display - shown when URL is received */}
{paymentUrl && ( {paymentUrl && (
<div className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4"> <motion.div
variants={staggerItem}
className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4"
>
<div className="flex items-center gap-2 text-success-400"> <div className="flex items-center gap-2 text-success-400">
<CheckIcon /> <CheckIcon />
<span className="font-semibold">{t('balance.paymentReady')}</span> <span className="font-semibold">{t('balance.paymentReady')}</span>
@@ -521,7 +481,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
<p className="text-sm text-dark-400">{t('balance.clickToOpenPayment')}</p> <p className="text-sm text-dark-400">{t('balance.clickToOpenPayment')}</p>
{/* Main open button - NO preventDefault, let <a> work natively for iOS Safari */}
<a <a
href={paymentUrl} href={paymentUrl}
target="_blank" target="_blank"
@@ -532,7 +491,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
<span>{t('balance.openPaymentPage')}</span> <span>{t('balance.openPaymentPage')}</span>
</a> </a>
{/* Copy and link display */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2"> <div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2">
<p className="truncate text-xs text-dark-500">{paymentUrl}</p> <p className="truncate text-xs text-dark-500">{paymentUrl}</p>
@@ -550,87 +508,8 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
{copied ? <CheckIcon /> : <CopyIcon />} {copied ? <CheckIcon /> : <CopyIcon />}
</button> </button>
</div> </div>
</div> </motion.div>
)} )}
</div> </motion.div>
); );
// Render modal based on screen size - NO nested components!
const modalContent = isMobileScreen ? (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[9998] bg-black/70" onClick={handleClose} />
{/* Bottom sheet */}
<div
data-modal-content
className="fixed inset-x-0 bottom-0 z-[9999] flex max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-dark-900"
style={{
paddingBottom: safeBottom
? `${safeBottom + 20}px`
: 'max(20px, env(safe-area-inset-bottom))',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Handle bar */}
<div className="flex justify-center pb-1 pt-3">
<div className="h-1 w-10 rounded-full bg-dark-600" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 py-2">
<div className="flex items-center gap-2">
<WalletIcon />
<span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
</div>
<button
onClick={handleClose}
className="-mr-2 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800"
>
<CloseIcon />
</button>
</div>
{/* Divider */}
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent" />
{/* Content */}
<div className="overflow-y-auto px-5 py-5">{contentJSX}</div>
</div>
</>
) : (
<div
className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[10vh]"
onClick={handleClose}
>
<div
data-modal-content
className="w-full max-w-md overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-dark-700/50 bg-gradient-to-r from-dark-800/80 to-dark-800/40 px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent-500/10 text-accent-400">
<WalletIcon />
</div>
<span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
</div>
<button
onClick={handleClose}
className="-mr-1 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-700"
>
<CloseIcon />
</button>
</div>
{/* Content */}
<div className="p-6">{contentJSX}</div>
</div>
</div>
);
if (typeof document !== 'undefined') {
return createPortal(modalContent, document.body);
}
return modalContent;
} }

View File

@@ -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 (
<motion.div
className="space-y-6"
variants={staggerContainer}
initial="initial"
animate="animate"
>
<motion.div variants={staggerItem}>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{t('balance.selectPaymentMethod')}
</h1>
</motion.div>
<motion.div variants={staggerItem}>
<Card>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : !paymentMethods || paymentMethods.length === 0 ? (
<div className="py-6 text-center text-sm text-dark-400">
{t('balance.noPaymentMethods')}
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{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 (
<Card
key={method.id}
interactive={method.is_available}
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''}
onClick={() => method.is_available && handleMethodClick(method.id)}
>
<div className="font-semibold text-dark-100">
{translatedName || method.name}
</div>
{(translatedDesc || method.description) && (
<div className="mt-1 text-sm text-dark-500">
{translatedDesc || method.description}
</div>
)}
<div className="mt-3 text-xs text-dark-600">
{formatAmount(method.min_amount_kopeks / 100, 0)} {' '}
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
</div>
</Card>
);
})}
</div>
)}
</Card>
</motion.div>
</motion.div>
);
}

View File

@@ -5,9 +5,6 @@ import {
onBackButtonClick, onBackButtonClick,
offBackButtonClick, offBackButtonClick,
isBackButtonVisible, isBackButtonVisible,
setMainButtonParams,
onMainButtonClick,
offMainButtonClick,
hapticFeedbackImpactOccurred, hapticFeedbackImpactOccurred,
hapticFeedbackNotificationOccurred, hapticFeedbackNotificationOccurred,
hapticFeedbackSelectionChanged, hapticFeedbackSelectionChanged,
@@ -30,12 +27,10 @@ import type {
PlatformContext, PlatformContext,
PlatformCapabilities, PlatformCapabilities,
BackButtonController, BackButtonController,
MainButtonController,
HapticController, HapticController,
DialogController, DialogController,
ThemeController, ThemeController,
CloudStorageController, CloudStorageController,
MainButtonConfig,
PopupOptions, PopupOptions,
InvoiceStatus, InvoiceStatus,
HapticImpactStyle, HapticImpactStyle,
@@ -47,7 +42,7 @@ function createCapabilities(): PlatformCapabilities {
return { return {
hasBackButton: inTelegram, hasBackButton: inTelegram,
hasMainButton: inTelegram,
hasHapticFeedback: inTelegram, hasHapticFeedback: inTelegram,
hasNativeDialogs: inTelegram, hasNativeDialogs: inTelegram,
hasThemeSync: 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 { function createHapticController(): HapticController {
const inTelegram = isInTelegramWebApp(); const inTelegram = isInTelegramWebApp();
@@ -382,7 +292,7 @@ export function createTelegramAdapter(): PlatformContext {
platform: 'telegram', platform: 'telegram',
capabilities: createCapabilities(), capabilities: createCapabilities(),
backButton: createBackButtonController(), backButton: createBackButtonController(),
mainButton: createMainButtonController(),
haptic: createHapticController(), haptic: createHapticController(),
dialog: createDialogController(), dialog: createDialogController(),
theme: createThemeController(), theme: createThemeController(),

View File

@@ -2,12 +2,10 @@ import type {
PlatformContext, PlatformContext,
PlatformCapabilities, PlatformCapabilities,
BackButtonController, BackButtonController,
MainButtonController,
HapticController, HapticController,
DialogController, DialogController,
ThemeController, ThemeController,
CloudStorageController, CloudStorageController,
MainButtonConfig,
PopupOptions, PopupOptions,
InvoiceStatus, InvoiceStatus,
HapticImpactStyle, HapticImpactStyle,
@@ -20,7 +18,7 @@ const STORAGE_PREFIX = 'bedolaga_';
function createCapabilities(): PlatformCapabilities { function createCapabilities(): PlatformCapabilities {
return { return {
hasBackButton: false, // No native back button in web hasBackButton: false, // No native back button in web
hasMainButton: false, // No native main button in web
hasHapticFeedback: 'vibrate' in navigator, // Web Vibration API hasHapticFeedback: 'vibrate' in navigator, // Web Vibration API
hasNativeDialogs: false, // Use custom dialogs hasNativeDialogs: false, // Use custom dialogs
hasThemeSync: false, // No header/bottom bar sync in web 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 { function createHapticController(): HapticController {
// Web Vibration API fallback (works on mobile browsers) // Web Vibration API fallback (works on mobile browsers)
const canVibrate = 'vibrate' in navigator; const canVibrate = 'vibrate' in navigator;
@@ -219,7 +189,7 @@ export function createWebAdapter(): PlatformContext {
platform: 'web', platform: 'web',
capabilities: createCapabilities(), capabilities: createCapabilities(),
backButton: createBackButtonController(), backButton: createBackButtonController(),
mainButton: createMainButtonController(),
haptic: createHapticController(), haptic: createHapticController(),
dialog: createDialogController(), dialog: createDialogController(),
theme: createThemeController(), theme: createThemeController(),

View File

@@ -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<MainButtonConfig, 'onClick'> {
/**
* 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,
});
}

View File

@@ -10,14 +10,12 @@ export type {
PlatformType, PlatformType,
PlatformContext as PlatformContextType, PlatformContext as PlatformContextType,
PlatformCapabilities, PlatformCapabilities,
MainButtonConfig,
PopupOptions, PopupOptions,
PopupButton, PopupButton,
InvoiceStatus, InvoiceStatus,
HapticImpactStyle, HapticImpactStyle,
HapticNotificationType, HapticNotificationType,
BackButtonController, BackButtonController,
MainButtonController,
HapticController, HapticController,
DialogController, DialogController,
ThemeController, ThemeController,
@@ -28,7 +26,6 @@ export type {
// Hooks // Hooks
export { usePlatform, useIsTelegram, useCapability } from './hooks/usePlatform'; export { usePlatform, useIsTelegram, useCapability } from './hooks/usePlatform';
export { useBackButton, useConditionalBackButton } from './hooks/useBackButton'; export { useBackButton, useConditionalBackButton } from './hooks/useBackButton';
export { useMainButton, useSimpleMainButton } from './hooks/useMainButton';
export { useHaptic, useHapticClick, useHapticFeedback } from './hooks/useHaptic'; export { useHaptic, useHapticClick, useHapticFeedback } from './hooks/useHaptic';
export { useNativeDialog, useDestructiveConfirm, PopupButtons } from './hooks/useNativeDialog'; export { useNativeDialog, useDestructiveConfirm, PopupButtons } from './hooks/useNativeDialog';
export { useNotify } from './hooks/useNotify'; export { useNotify } from './hooks/useNotify';

View File

@@ -7,15 +7,6 @@ export type HapticNotificationType = 'success' | 'warning' | 'error';
export type InvoiceStatus = 'paid' | 'cancelled' | 'failed' | 'pending'; 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 { export interface PopupButton {
id: string; id: string;
type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive'; type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive';
@@ -30,7 +21,7 @@ export interface PopupOptions {
export interface PlatformCapabilities { export interface PlatformCapabilities {
hasBackButton: boolean; hasBackButton: boolean;
hasMainButton: boolean;
hasHapticFeedback: boolean; hasHapticFeedback: boolean;
hasNativeDialogs: boolean; hasNativeDialogs: boolean;
hasThemeSync: boolean; hasThemeSync: boolean;
@@ -46,15 +37,6 @@ export interface BackButtonController {
isVisible: boolean; 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 { export interface HapticController {
impact: (style?: HapticImpactStyle) => void; impact: (style?: HapticImpactStyle) => void;
notification: (type: HapticNotificationType) => void; notification: (type: HapticNotificationType) => void;
@@ -104,9 +86,6 @@ export interface PlatformContext {
// Navigation // Navigation
backButton: BackButtonController; backButton: BackButtonController;
// Main action button
mainButton: MainButtonController;
// Haptic feedback // Haptic feedback
haptic: HapticController; haptic: HapticController;