diff --git a/src/api/branding.ts b/src/api/branding.ts index 8d21793..9bfe29e 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -11,6 +11,10 @@ export interface AnimationEnabled { enabled: boolean } +export interface FullscreenEnabled { + enabled: boolean +} + const BRANDING_CACHE_KEY = 'cabinet_branding' const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded' @@ -121,4 +125,21 @@ export const brandingApi = { const response = await apiClient.patch('/cabinet/branding/animation', { enabled }) return response.data }, + + // Get fullscreen enabled (public, no auth required) + getFullscreenEnabled: async (): Promise => { + try { + const response = await apiClient.get('/cabinet/branding/fullscreen') + return response.data + } catch { + // If endpoint doesn't exist, default to disabled + return { enabled: false } + } + }, + + // Update fullscreen enabled (admin only) + updateFullscreenEnabled: async (enabled: boolean): Promise => { + const response = await apiClient.patch('/cabinet/branding/fullscreen', { enabled }) + return response.data + }, } diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 473022d..c9826da 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react' +import { useState, useMemo, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { subscriptionApi } from '../api/subscription' @@ -10,40 +10,75 @@ interface ConnectionModalProps { // Platform SVG Icons const IosIcon = () => ( - + ) const AndroidIcon = () => ( - + ) const WindowsIcon = () => ( - + ) const MacosIcon = () => ( - - + + ) const LinuxIcon = () => ( - + ) const TvIcon = () => ( - + - + +) + +const CloseIcon = () => ( + + + +) + +const BackIcon = () => ( + + + +) + +const CopyIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const LinkIcon = () => ( + + + +) + +const ChevronIcon = () => ( + + ) @@ -58,107 +93,185 @@ const platformIconComponents: Record = { appleTV: TvIcon, } +// App icons +const HappIcon = () => ( + + + + + + + + +) + +const ClashMetaIcon = () => ( + + + + + + + + +) + +const ClashVergeIcon = () => ( + + + + + + +) + +const ShadowrocketIcon = () => ( + + + +) + +const StreisandIcon = () => ( + + + +) + +// App icon mapping by name (case-insensitive) +const getAppIcon = (appName: string, isFeatured: boolean): React.ReactNode => { + const name = appName.toLowerCase() + if (name.includes('happ')) { + return + } + if (name.includes('shadowrocket') || name.includes('rocket')) { + return + } + if (name.includes('streisand')) { + return + } + if (name.includes('verge')) { + return + } + if (name.includes('clash') || name.includes('meta')) { + return + } + // Default icons + return isFeatured ? '⭐' : '📦' +} + // Platform order for display const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] // Dangerous schemes that should never be allowed const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] -/** - * Validate URL to prevent XSS via javascript: and other dangerous schemes - * Only allows http, https, and known app store URLs - */ function isValidExternalUrl(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() - - // Block dangerous schemes - const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { return false } - - // Allow only http/https URLs return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://') } -/** - * Validate deep link URL - blocks dangerous schemes only - * Allows any custom app scheme (clash://, hiddify://, etc.) - */ function isValidDeepLink(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() - - // Block dangerous schemes if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { return false } - - // Allow any URL that has a scheme (contains ://) return lowerUrl.includes('://') } -// Detect user's platform from user agent function detectPlatform(): string | null { if (typeof window === 'undefined' || !navigator?.userAgent) { return null } - const ua = navigator.userAgent.toLowerCase() - - // Check for mobile devices first - if (/iphone|ipad|ipod/.test(ua)) { - return 'ios' - } + if (/iphone|ipad|ipod/.test(ua)) return 'ios' if (/android/.test(ua)) { - // Check if it's Android TV - if (/tv|television|smart-tv|smarttv/.test(ua)) { - return 'androidTV' - } + if (/tv|television|smart-tv|smarttv/.test(ua)) return 'androidTV' return 'android' } - - // Desktop platforms - if (/macintosh|mac os x/.test(ua)) { - return 'macos' - } - if (/windows/.test(ua)) { - return 'windows' - } - if (/linux/.test(ua)) { - return 'linux' - } - + if (/macintosh|mac os x/.test(ua)) return 'macos' + if (/windows/.test(ua)) return 'windows' + if (/linux/.test(ua)) return 'linux' return null } export default function ConnectionModal({ onClose }: ConnectionModalProps) { const { t, i18n } = useTranslation() - const [selectedPlatform, setSelectedPlatform] = useState(null) const [selectedApp, setSelectedApp] = useState(null) const [copied, setCopied] = useState(false) const [detectedPlatform, setDetectedPlatform] = useState(null) + const [showAppSelector, setShowAppSelector] = useState(false) + const modalContentRef = useRef(null) const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], queryFn: () => subscriptionApi.getAppConfig(), }) - // Auto-detect platform on mount useEffect(() => { - const detected = detectPlatform() - setDetectedPlatform(detected) + setDetectedPlatform(detectPlatform()) + }, []) + + // Auto-select platform and app when data is loaded + useEffect(() => { + if (!appConfig?.platforms || selectedApp) return + + const platform = detectedPlatform || platformOrder.find(p => appConfig.platforms[p]?.length > 0) + if (!platform || !appConfig.platforms[platform]?.length) return + + const apps = appConfig.platforms[platform] + // Prefer featured app, otherwise first app + const app = apps.find(a => a.isFeatured) || apps[0] + + if (app) { + setSelectedApp(app) + } + }, [appConfig, detectedPlatform, selectedApp]) + + // Scroll modal content to top when switching views + useEffect(() => { + modalContentRef.current?.scrollTo({ top: 0, behavior: 'instant' }) + }, [showAppSelector]) + + // Scroll lock when modal is open + useEffect(() => { + const scrollY = window.scrollY + + // Prevent all touch/wheel scroll on backdrop + 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) + } }, []) - // Helper to get localized text const getLocalizedText = (text: LocalizedText | undefined): string => { if (!text) return '' const lang = i18n.language || 'en' return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '' } - // Get platform name const getPlatformName = (platformKey: string): string => { if (!appConfig?.platformNames?.[platformKey]) { return platformKey @@ -166,13 +279,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return getLocalizedText(appConfig.platformNames[platformKey]) } - // Get available platforms sorted (detected platform first) const availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return [] const available = platformOrder.filter( (key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0 ) - // Move detected platform to the front if (detectedPlatform && available.includes(detectedPlatform)) { const filtered = available.filter(p => p !== detectedPlatform) return [detectedPlatform, ...filtered] @@ -180,13 +291,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return available }, [appConfig, detectedPlatform]) - // Get apps for selected platform - const platformApps = useMemo(() => { - if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return [] - return appConfig.platforms[selectedPlatform] - }, [selectedPlatform, appConfig]) + // Get all apps for selector (must be before any conditional returns) + const allAppsForSelector = useMemo(() => { + if (!appConfig?.platforms) return [] + const result: { platform: string; apps: AppInfo[] }[] = [] + for (const platform of availablePlatforms) { + const apps = appConfig.platforms[platform] + if (apps?.length) { + result.push({ platform, apps }) + } + } + return result + }, [appConfig, availablePlatforms]) - // Copy subscription link const copySubscriptionLink = async () => { if (!appConfig?.subscriptionUrl) return try { @@ -194,7 +311,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { setCopied(true) setTimeout(() => setCopied(false), 2000) } catch { - // Fallback for older browsers const textarea = document.createElement('textarea') textarea.value = appConfig.subscriptionUrl document.body.appendChild(textarea) @@ -206,24 +322,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { } } - // Handle deep link click - use miniapp redirect page like in miniapp index.html const handleConnect = (app: AppInfo) => { - // Validate deep link to prevent XSS if (!app.deepLink || !isValidDeepLink(app.deepLink)) { console.warn('Invalid or missing deep link:', app.deepLink) return } - const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en' const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}` - - // Check if it's a custom URL scheme (not http/https) const isCustomScheme = !/^https?:\/\//i.test(app.deepLink) - const tg = (window as any).Telegram?.WebApp - + const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp if (isCustomScheme && tg?.openLink) { - // For custom URL schemes - open redirect page in external browser via Telegram - // try_browser: true - показывает диалог для перехода во внешний браузер (важно для мобильных) try { tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) return @@ -231,260 +339,207 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { console.warn('tg.openLink failed:', e) } } - - // Fallback - direct navigation window.location.href = redirectUrl } - // Modal wrapper classes - bottom sheet on mobile, centered on desktop - const modalOverlayClass = "fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center sm:justify-center" - const modalCardClass = "w-full sm:max-w-lg sm:mx-4 bg-dark-850 sm:rounded-2xl rounded-t-2xl rounded-b-none border-t border-x sm:border border-dark-700/50 shadow-2xl overflow-hidden" - const modalContentClass = "p-4 sm:p-6 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6" - // Allow the sheet to almost fill the viewport height on phones - const modalScrollableClass = "h-[calc(100vh-1rem)] sm:h-auto sm:max-h-[85vh]" + // Modal wrapper - top aligned with auto-focus + const ModalWrapper = ({ children }: { children: React.ReactNode }) => ( +
+
e.stopPropagation()} + > + {children} +
+
+ ) + // Loading state if (isLoading) { return ( -
-
e.stopPropagation()}> -
-
-
-
-
+ +
+
-
+
) } + // Error state if (error || !appConfig) { return ( -
-
e.stopPropagation()}> -
-
-

{t('common.error')}

- -
+ +
+
+ 😕
+

{t('common.error')}

+
-
+ ) } + // No subscription if (!appConfig.hasSubscription) { return ( -
-
e.stopPropagation()}> -
-
-

- {t('subscription.connection.title')} -

- -
-
-

{t('subscription.connection.noSubscription')}

-
+ +
+
+ 📱
+

{t('subscription.connection.title')}

+

{t('subscription.connection.noSubscription')}

+
-
+ ) } - // Step 1: Select platform - if (!selectedPlatform) { + // App selector view + if (showAppSelector || !selectedApp) { return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
-

- {t('subscription.connection.title')} -

- + + {/* Header */} +
+
+ {selectedApp && ( + + )} +
+

{t('subscription.connection.selectApp')}

+

{t('subscription.connection.selectDevice')}

+
+ +
- {/* Scrollable content */} -
-

{t('subscription.connection.selectDevice')}

- -
- {availablePlatforms.map((platform) => { - const IconComponent = platformIconComponents[platform] - return ( -
-

{getPlatformName(platform)}

- - ) - })} -
-
- - {/* Footer - fixed with safe area */} -
- -
-
-
- ) - } - - // Step 2: Select app (if not selected yet) - if (!selectedApp) { - return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
-
- -

- {getPlatformName(selectedPlatform)} -

-
- -
- - {/* Scrollable content */} -
-

{t('subscription.connection.selectApp')}

- - {platformApps.length === 0 ? ( -
-

{t('subscription.connection.noApps')}

-
- ) : ( -
- {platformApps.map((app) => ( - - ))} + + + ))} +
- )} -
+ ) + })}
-
+ + {/* Copy link */} +
+ +
+ ) } - // Step 3: Show app instructions and connect button + // App instructions (main view) return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
-
- -

- {selectedApp.name} -

+ + {/* Header with app info and change button */} +
+ -
+
+

{selectedApp.name}

+

{t('subscription.connection.changeApp') || 'Сменить'} →

+
+ + +
- {/* Scrollable content */} -
-
- {/* Step 1: Install */} - {selectedApp.installationStep && ( -
-

- {t('subscription.connection.installApp')} -

-

+ {/* Instructions */} +

+ {/* Step 1: Install */} + {selectedApp.installationStep && ( +
+
+
1
+
+

{t('subscription.connection.installApp')}

+

{getLocalizedText(selectedApp.installationStep.description)}

{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( -
+
{selectedApp.installationStep.buttons .filter((btn) => isValidExternalUrl(btn.buttonLink)) .map((btn, idx) => ( @@ -493,115 +548,78 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { href={btn.buttonLink} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700 text-dark-200 text-sm hover:bg-dark-600 transition-all" + className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-700 text-dark-200 text-xs hover:bg-dark-600 transition-all" > - - - + {getLocalizedText(btn.buttonText)} ))}
)}
- )} +
+
+ )} - {/* Additional before add subscription */} - {selectedApp.additionalBeforeAddSubscriptionStep && ( -
- {selectedApp.additionalBeforeAddSubscriptionStep.title && ( -

- {getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.title)} -

- )} -

- {getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.description)} -

-
- )} - - {/* Step 2: Add subscription */} - {selectedApp.addSubscriptionStep && ( -
-

- {t('subscription.connection.addSubscription')} -

-

+ {/* Step 2: Add subscription */} + {selectedApp.addSubscriptionStep && ( +

+
+
2
+
+

{t('subscription.connection.addSubscription')}

+

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

- - {/* Connect button */} - {selectedApp.deepLink && ( - - )} - - {/* Copy link fallback */} - )} - + +
- )} +
+
+ )} - {/* Additional after add subscription */} - {selectedApp.additionalAfterAddSubscriptionStep && ( -
- {selectedApp.additionalAfterAddSubscriptionStep.title && ( -

- {getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.title)} -

- )} + {/* Step 3: Connect */} + {selectedApp.connectAndUseStep && ( +
+
+
3
+
+

{t('subscription.connection.connectVpn')}

- {getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.description)} -

-
- )} - - {/* Step 3: Connect */} - {selectedApp.connectAndUseStep && ( -
-

- {t('subscription.connection.connectVpn')} -

-

{getLocalizedText(selectedApp.connectAndUseStep.description)}

- )} +
-
- - {/* Footer - fixed with safe area */} -
- -
+ )}
-
+ + {/* Footer */} +
+ +
+ ) } diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 056c19c..bbcacdc 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useMutation } from '@tanstack/react-query' import { balanceApi } from '../api/balance' @@ -55,6 +55,35 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top ) const popupRef = useRef(null) + // Scroll lock when modal is open + useEffect(() => { + const scrollY = window.scrollY + + // Prevent all touch/wheel scroll on backdrop + 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 @@ -132,11 +161,28 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top : convertAmount(rub).toFixed(currencyDecimals) const isPending = topUpMutation.isPending || starsPaymentMutation.isPending - return ( -
-
+ // Auto-focus input on mount + useEffect(() => { + const timer = setTimeout(() => { + inputRef.current?.focus() + }, 100) + return () => clearTimeout(timer) + }, []) -
+ return ( +
+
e.stopPropagation()} + > {/* Header */}
{methodName} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 0c82aee..98d1e69 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -15,6 +15,7 @@ import { themeColorsApi } from '../../api/themeColors' import { promoApi } from '../../api/promo' import { useTheme } from '../../hooks/useTheme' import { useTelegramWebApp } from '../../hooks/useTelegramWebApp' +import { usePullToRefresh } from '../../hooks/usePullToRefresh' // Fallback branding from environment variables const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet' @@ -123,18 +124,6 @@ const WheelIcon = () => ( ) -const FullscreenIcon = () => ( - - - -) - -const ExitFullscreenIcon = () => ( - - - -) - export default function Layout({ children }: LayoutProps) { const { t } = useTranslation() const location = useLocation() @@ -142,7 +131,13 @@ export default function Layout({ children }: LayoutProps) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const { toggleTheme, isDark } = useTheme() const [userPhotoUrl, setUserPhotoUrl] = useState(null) - const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() + const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() + + // Pull to refresh (disabled when mobile menu is open) + const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({ + disabled: mobileMenuOpen, + threshold: 80, + }) // Fetch enabled themes from API - same source of truth as AdminSettings const { data: enabledThemes } = useQuery({ @@ -167,17 +162,58 @@ export default function Layout({ children }: LayoutProps) { } }, []) - // Lock body scroll and scroll to top when mobile menu is open + // Lock body scroll when mobile menu is open (cross-platform) useEffect(() => { - if (mobileMenuOpen) { - // Scroll to top so header is visible - window.scrollTo({ top: 0, behavior: 'instant' }) - document.body.style.overflow = 'hidden' - } else { - document.body.style.overflow = '' + if (!mobileMenuOpen) return + + const scrollY = window.scrollY + const body = document.body + const html = document.documentElement + + // Save original styles + const originalStyles = { + bodyOverflow: body.style.overflow, + bodyPosition: body.style.position, + bodyTop: body.style.top, + bodyWidth: body.style.width, + bodyHeight: body.style.height, + htmlOverflow: html.style.overflow, + htmlHeight: html.style.height, } + + // Lock scroll - works on iOS, Android, and desktop + body.style.overflow = 'hidden' + body.style.position = 'fixed' + body.style.top = `-${scrollY}px` + body.style.width = '100%' + body.style.height = '100%' + html.style.overflow = 'hidden' + html.style.height = '100%' + + // Prevent touchmove on body (extra protection for mobile) + const preventScroll = (e: TouchEvent) => { + const target = e.target as HTMLElement + // Allow scroll inside menu content + if (target.closest('.mobile-menu-content')) return + e.preventDefault() + } + document.addEventListener('touchmove', preventScroll, { passive: false }) + return () => { - document.body.style.overflow = '' + // Restore original styles + body.style.overflow = originalStyles.bodyOverflow + body.style.position = originalStyles.bodyPosition + body.style.top = originalStyles.bodyTop + body.style.width = originalStyles.bodyWidth + body.style.height = originalStyles.bodyHeight + html.style.overflow = originalStyles.htmlOverflow + html.style.height = originalStyles.htmlHeight + + // Remove touchmove listener + document.removeEventListener('touchmove', preventScroll) + + // Restore scroll position + window.scrollTo(0, scrollY) } }, [mobileMenuOpen]) @@ -299,6 +335,30 @@ export default function Layout({ children }: LayoutProps) { {/* Animated Background */} + {/* Pull to refresh indicator */} + {(isPulling || isRefreshing) && ( +
+
+ + + +
+
+ )} + {/* Header */}
-
+
mobileMenuOpen && setMobileMenuOpen(false)}>
{/* Logo */} - + setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
{/* Always show letter as fallback */} @@ -372,24 +432,13 @@ export default function Layout({ children }: LayoutProps) { {/* Right side */}
- {/* Fullscreen toggle - only show in Telegram WebApp */} - {isTelegramWebApp && isFullscreenSupported && ( - - )} - {/* Theme toggle button - only show if both themes are enabled */} {canToggle && ( )} - - +
setMobileMenuOpen(false)}> + +
+
setMobileMenuOpen(false)}> + +
{/* Hide language switcher on mobile when promo is active */} -
+
setMobileMenuOpen(false)}>
@@ -439,7 +492,10 @@ export default function Layout({ children }: LayoutProps) { {/* Mobile menu button */}
- {/* Mobile menu - fixed overlay */} + {/* Mobile menu - fixed overlay below header */} {mobileMenuOpen && ( -
+
{/* Backdrop */}
{/* Menu content */} -
+
{/* User info */}
diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts new file mode 100644 index 0000000..b97b83e --- /dev/null +++ b/src/hooks/usePullToRefresh.ts @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState, useCallback } from 'react' + +interface UsePullToRefreshOptions { + onRefresh?: () => void | Promise + threshold?: number // How far to pull before triggering refresh (px) + disabled?: boolean +} + +export function usePullToRefresh({ + onRefresh, + threshold = 80, + disabled = false, +}: UsePullToRefreshOptions = {}) { + const [isPulling, setIsPulling] = useState(false) + const [pullDistance, setPullDistance] = useState(0) + const [isRefreshing, setIsRefreshing] = useState(false) + + const startY = useRef(0) + const currentY = useRef(0) + + const handleRefresh = useCallback(async () => { + if (onRefresh) { + setIsRefreshing(true) + try { + await onRefresh() + } finally { + setIsRefreshing(false) + } + } else { + // Default: reload the page + window.location.reload() + } + }, [onRefresh]) + + useEffect(() => { + if (disabled) return + + const handleTouchStart = (e: TouchEvent) => { + // Only trigger if at top of page + if (window.scrollY > 5) return + + startY.current = e.touches[0].clientY + currentY.current = e.touches[0].clientY + } + + const handleTouchMove = (e: TouchEvent) => { + if (startY.current === 0) return + if (window.scrollY > 5) { + // User scrolled down, reset + startY.current = 0 + setPullDistance(0) + setIsPulling(false) + return + } + + currentY.current = e.touches[0].clientY + const diff = currentY.current - startY.current + + // Only track downward pulls + if (diff > 0) { + // Apply resistance - pull distance is less than actual finger movement + const resistance = 0.4 + const distance = Math.min(diff * resistance, threshold * 1.5) + setPullDistance(distance) + setIsPulling(true) + + // Prevent default scroll if we're pulling + if (distance > 10) { + e.preventDefault() + } + } + } + + const handleTouchEnd = () => { + if (pullDistance >= threshold && !isRefreshing) { + handleRefresh() + } + + startY.current = 0 + setPullDistance(0) + setIsPulling(false) + } + + document.addEventListener('touchstart', handleTouchStart, { passive: true }) + document.addEventListener('touchmove', handleTouchMove, { passive: false }) + document.addEventListener('touchend', handleTouchEnd, { passive: true }) + + return () => { + document.removeEventListener('touchstart', handleTouchStart) + document.removeEventListener('touchmove', handleTouchMove) + document.removeEventListener('touchend', handleTouchEnd) + } + }, [disabled, threshold, pullDistance, isRefreshing, handleRefresh]) + + return { + isPulling, + pullDistance, + isRefreshing, + progress: Math.min(pullDistance / threshold, 1), + } +} diff --git a/src/hooks/useTelegramWebApp.ts b/src/hooks/useTelegramWebApp.ts index e8402b4..31f5d01 100644 --- a/src/hooks/useTelegramWebApp.ts +++ b/src/hooks/useTelegramWebApp.ts @@ -1,5 +1,25 @@ import { useEffect, useState, useCallback } from 'react' +const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled' + +// Get cached fullscreen setting +export const getCachedFullscreenEnabled = (): boolean => { + try { + return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true' + } catch { + return false + } +} + +// Set cached fullscreen setting +export const setCachedFullscreenEnabled = (enabled: boolean) => { + try { + localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled)) + } catch { + // localStorage not available + } +} + /** * Hook for Telegram WebApp API integration * Provides fullscreen mode, safe area insets, and other WebApp features @@ -123,5 +143,15 @@ export function initTelegramWebApp() { if (webApp.disableVerticalSwipes) { webApp.disableVerticalSwipes() } + + // Auto-enter fullscreen if enabled in settings (use cached value for instant response) + const fullscreenEnabled = getCachedFullscreenEnabled() + if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) { + try { + webApp.requestFullscreen() + } catch (e) { + console.warn('Auto-fullscreen failed:', e) + } + } } } diff --git a/src/locales/en.json b/src/locales/en.json index 7feec1f..92e4e72 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -184,7 +184,9 @@ "yourDevice": "Your device", "copyLink": "Copy subscription link", "copied": "Link copied!", - "openLink": "Open link" + "openLink": "Open link", + "instructions": "Instructions", + "changeApp": "Change app" }, "myDevices": "My Devices", "noDevices": "No connected devices", diff --git a/src/locales/ru.json b/src/locales/ru.json index 2791432..4987412 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -184,7 +184,9 @@ "yourDevice": "Ваше устройство", "copyLink": "Скопировать ссылку", "copied": "Ссылка скопирована!", - "openLink": "Открыть ссылку" + "openLink": "Открыть ссылку", + "instructions": "Инструкция", + "changeApp": "Сменить приложение" }, "myDevices": "Мои устройства", "noDevices": "Нет подключенных устройств", diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 6080066..69f1711 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -5,6 +5,7 @@ import { Link } from 'react-router-dom' import { adminSettingsApi, SettingDefinition, SettingCategorySummary } from '../api/adminSettings' import { brandingApi, setCachedBranding } from '../api/branding' import { setCachedAnimationEnabled } from '../components/AnimatedBackground' +import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp' import { themeColorsApi } from '../api/themeColors' import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme' import { ColorPicker } from '../components/ColorPicker' @@ -880,6 +881,21 @@ export default function AdminSettings() { }, }) + // Fullscreen toggle query and mutation + const { data: fullscreenSettings } = useQuery({ + queryKey: ['fullscreen-enabled'], + queryFn: brandingApi.getFullscreenEnabled, + }) + + const updateFullscreenMutation = useMutation({ + mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled), + onSuccess: (data) => { + // Update local cache immediately for instant effect + setCachedFullscreenEnabled(data.enabled) + queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] }) + }, + }) + const updateNameMutation = useMutation({ mutationFn: (name: string) => brandingApi.updateName(name), onSuccess: (data) => { @@ -1229,6 +1245,29 @@ export default function AdminSettings() {
+ + {/* Fullscreen Toggle */} +
+
+
+

Авто-Fullscreen

+

+ Автоматически открывать на полный экран в Telegram +

+
+ +
+
{/* Theme Colors Card */} diff --git a/src/pages/Referral.tsx b/src/pages/Referral.tsx index 4526fd2..af3c681 100644 --- a/src/pages/Referral.tsx +++ b/src/pages/Referral.tsx @@ -68,6 +68,12 @@ export default function Referral() { queryFn: referralApi.getReferralInfo, }) + // Build referral link using frontend env variable for correct bot username + const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME + const referralLink = info?.referral_code && botUsername + ? `https://t.me/${botUsername}?start=${info.referral_code}` + : info?.referral_link || '' + const { data: terms } = useQuery({ queryKey: ['referral-terms'], queryFn: referralApi.getReferralTerms, @@ -90,15 +96,15 @@ export default function Referral() { }) const copyLink = () => { - if (info?.referral_link) { - navigator.clipboard.writeText(info.referral_link) + if (referralLink) { + navigator.clipboard.writeText(referralLink) setCopied(true) setTimeout(() => setCopied(false), 2000) } } const shareLink = () => { - if (!info?.referral_link) return + if (!referralLink) return const shareText = t('referral.shareMessage', { percent: info?.commission_percent || 0, botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet', @@ -109,7 +115,7 @@ export default function Referral() { .share({ title: t('referral.title'), text: shareText, - url: info.referral_link, + url: referralLink, }) .catch(() => { // ignore cancellation errors @@ -118,7 +124,7 @@ export default function Referral() { } const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent( - info.referral_link + referralLink )}&text=${encodeURIComponent(shareText)}` window.open(telegramUrl, '_blank', 'noopener,noreferrer') } @@ -176,16 +182,16 @@ export default function Referral() {