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 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/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index d779f81..d7ceeda 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -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 { useQuery } from '@tanstack/react-query'; -import { balanceApi } from '../api/balance'; import { useCurrency } from '../hooks/useCurrency'; -import { useCloseOnSuccessNotification } from '../store/successNotification'; -import TopUpModal from './TopUpModal'; -import type { PaymentMethod } from '../types'; interface InsufficientBalancePromptProps { /** Amount missing in kopeks */ @@ -28,205 +24,114 @@ export default function InsufficientBalancePrompt({ onBeforeTopUp, }: InsufficientBalancePromptProps) { const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); const { formatAmount, currencySymbol } = useCurrency(); - const [showMethodSelect, setShowMethodSelect] = useState(false); - const [selectedMethod, setSelectedMethod] = useState(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/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx index 2cf9fec..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; @@ -107,8 +108,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() { @@ -117,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'], @@ -128,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; @@ -220,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; diff --git a/src/locales/en.json b/src/locales/en.json index c2cebf0..b4eabe4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -713,6 +713,9 @@ "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.", + "confirmStarsPayment": "You will be redirected to Telegram to pay for one wheel spin.", + "payStars": "Pay {{count}} ⭐", "youWon": "You won", "noPrize": "No luck this time...", "noHistory": "No spin history yet", @@ -977,6 +980,7 @@ "promocodes": "Promocodes", "promoPrefix": "Promo code prefix" }, + "starsNotEnabledGlobally": "Star payments are not enabled in Payment Methods. Enable \"Telegram Stars\" in the Payment Methods section.", "prizes": { "addPrize": "Add Prize", "editPrize": "Edit Prize", diff --git a/src/locales/fa.json b/src/locales/fa.json index 83a82c4..4eb2d39 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -599,6 +599,9 @@ "starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.", "starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.", "starsPaymentRedirected": "تلگرام برای پرداخت باز می‌شود. پس از پرداخت صفحه را بازخوانی کنید.", + "starsNotAvailable": "پرداخت با ستاره در حال حاضر در دسترس نیست. با پشتیبانی تماس بگیرید.", + "confirmStarsPayment": "شما به تلگرام برای پرداخت یک چرخش منتقل خواهید شد.", + "payStars": "پرداخت {{count}} ⭐", "noHistory": "هنوز تاریخچه‌ای نیست", "banner": { "title": "شانس خود را امتحان کنید!", @@ -699,6 +702,7 @@ "limitsAndRtp": "محدودیت‌ها و RTP", "promocodes": "کدهای تخفیف" }, + "starsNotEnabledGlobally": "پرداخت با ستاره در روش‌های پرداخت فعال نیست. «Telegram Stars» را در بخش «روش‌های پرداخت» فعال کنید.", "prizes": { "addPrize": "افزودن جایزه", "editPrize": "ویرایش جایزه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0e7fa9a..4a48ae4 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -741,6 +741,9 @@ "starsPaymentSuccessCheckHistory": "Оплата прошла! Проверьте историю или Telegram для результата.", "starsPaymentFailed": "Ошибка оплаты. Попробуйте еще раз.", "starsPaymentRedirected": "Откроется Telegram для оплаты. После оплаты обновите страницу.", + "starsNotAvailable": "Оплата звёздами сейчас недоступна. Обратитесь в поддержку.", + "confirmStarsPayment": "Вы будете перенаправлены в Telegram для оплаты одного вращения колеса.", + "payStars": "Оплатить {{count}} ⭐", "youWon": "Вы выиграли", "noPrize": "В этот раз не повезло...", "noHistory": "История вращений пуста", @@ -1001,6 +1004,7 @@ "promocodes": "Промокоды", "promoPrefix": "Префикс промокодов" }, + "starsNotEnabledGlobally": "Оплата звёздами не включена в платёжных методах. Включите метод «Telegram Stars» в разделе «Платёжные методы».", "prizes": { "addPrize": "Добавить приз", "editPrize": "Редактировать приз", diff --git a/src/locales/zh.json b/src/locales/zh.json index bc5c0a2..adbae2a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -603,6 +603,9 @@ "starsPaymentSuccess": "支付成功!抽奖结果已发送到Telegram。", "starsPaymentFailed": "支付失败。请重试。", "starsPaymentRedirected": "将打开Telegram进行支付。支付后请刷新页面。", + "starsNotAvailable": "星星支付暂不可用。请联系客服。", + "confirmStarsPayment": "您将被重定向到Telegram进行一次转盘支付。", + "payStars": "支付 {{count}} ⭐", "noHistory": "暂无抽奖记录", "banner": { "title": "试试运气!", @@ -751,6 +754,7 @@ "limitsAndRtp": "限制和RTP", "promocodes": "促销码" }, + "starsNotEnabledGlobally": "星星支付未在支付方式中启用。请在「支付方式」中启用「Telegram Stars」。", "prizes": { "addPrize": "添加奖品", "editPrize": "编辑奖品", 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/AdminWheel.tsx b/src/pages/AdminWheel.tsx index 213e7a0..35bb2e5 100644 --- a/src/pages/AdminWheel.tsx +++ b/src/pages/AdminWheel.tsx @@ -21,8 +21,11 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel'; +import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import { useDestructiveConfirm } from '@/platform'; +import { useNotify } from '@/platform/hooks/useNotify'; import FortuneWheel from '../components/wheel/FortuneWheel'; +import { ColorPicker } from '@/components/ColorPicker'; import { useBackButton } from '../platform/hooks/useBackButton'; import { usePlatform } from '../platform/hooks/usePlatform'; @@ -290,6 +293,7 @@ export default function AdminWheel() { const queryClient = useQueryClient(); const confirmDelete = useDestructiveConfirm(); const { capabilities } = usePlatform(); + const notify = useNotify(); // Use native Telegram back button in Mini App useBackButton(() => navigate('/admin')); @@ -793,10 +797,21 @@ export default function AdminWheel() { {hasSettingsChanges && (
{/* 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 */} 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/pages/Wheel.tsx b/src/pages/Wheel.tsx index df745bc..6a2cca0 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); @@ -72,9 +72,8 @@ export default function Wheel() { ); const [isPayingStars, setIsPayingStars] = useState(false); const [historyExpanded, setHistoryExpanded] = useState(false); - - // Check if we're in Telegram Mini App environment - const isTelegramMiniApp = platform === 'telegram'; + const [showStarsConfirm, setShowStarsConfirm] = useState(false); + const paymentTypeInitialized = useRef(false); const { data: config, @@ -90,31 +89,20 @@ 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; - 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( @@ -182,6 +170,7 @@ export default function Wheel() { const pendingStarsResultRef = useRef(null); const isStarsSpinRef = useRef(false); const pollingAbortRef = useRef(null); + const preOpenedWindowRef = useRef(null); // Cleanup polling on unmount useEffect(() => { @@ -283,9 +272,12 @@ export default function Wheel() { setIsPayingStars(false); } } else { - // Fallback: open invoice URL in browser (unsupported platform) + // Fallback: redirect pre-opened window to invoice URL setIsPayingStars(false); - window.open(data.invoice_url, '_blank', 'noopener,noreferrer'); + if (preOpenedWindowRef.current) { + preOpenedWindowRef.current.location.href = data.invoice_url; + preOpenedWindowRef.current = null; + } setSpinResult({ success: true, prize_id: null, @@ -303,6 +295,10 @@ export default function Wheel() { }, onError: () => { setIsPayingStars(false); + if (preOpenedWindowRef.current) { + preOpenedWindowRef.current.close(); + preOpenedWindowRef.current = null; + } setSpinResult({ success: false, prize_id: null, @@ -320,7 +316,12 @@ export default function Wheel() { }); const handleDirectStarsPay = () => { + setShowStarsConfirm(false); setIsPayingStars(true); + // In browser: pre-open window synchronously (direct user gesture) to avoid popup blocker + if (!capabilities.hasInvoice) { + preOpenedWindowRef.current = window.open('about:blank', '_blank') || null; + } starsInvoiceMutation.mutate(); }; @@ -360,8 +361,12 @@ export default function Wheel() { }; const handleUnifiedSpin = () => { - if (isTelegramMiniApp && paymentType === 'telegram_stars') { - handleDirectStarsPay(); + if (paymentType === 'telegram_stars') { + if (!config?.spin_cost_stars_enabled || !config?.spin_cost_stars) { + notify.warning(t('wheel.starsNotAvailable')); + return; + } + setShowStarsConfirm(true); } else { handleSpin(); } @@ -454,11 +459,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 || @@ -466,23 +467,6 @@ export default function Wheel() { isPayingStars || (config.daily_limit > 0 && config.user_spins_today >= config.daily_limit); - // Build cost subtitle for the spin button - 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} ⭐)`; - } - if (paymentType === 'subscription_days' && daysEnabled) { - return t('wheel.days', { count: config.spin_cost_days ?? 0 }); - } - return null; - }; - - const costSubtitle = getCostSubtitle(); - return (
{/* Simple Header */} @@ -513,55 +497,77 @@ export default function Wheel() { {/* Spin Controls */}
- {/* Payment type toggle - only if both methods available */} - {bothMethodsAvailable && ( + {/* Payment type selector */} + {(starsEnabled || daysEnabled) && (
-
- - +
+ {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 */} 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; 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; }