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..ae7d8bf 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -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,78 +93,109 @@ 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 } @@ -145,20 +211,25 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { queryFn: () => subscriptionApi.getAppConfig(), }) - // Auto-detect platform on mount useEffect(() => { - const detected = detectPlatform() - setDetectedPlatform(detected) + setDetectedPlatform(detectPlatform()) + }, []) + + // Lock body scroll when modal is open + useEffect(() => { + const originalStyle = window.getComputedStyle(document.body).overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = originalStyle + } }, []) - // 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 +237,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 +249,11 @@ 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]) - // Copy subscription link const copySubscriptionLink = async () => { if (!appConfig?.subscriptionUrl) return try { @@ -194,7 +261,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 +272,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 +289,243 @@ 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 - centered on desktop, top on mobile + 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) { return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
-

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

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

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

- -
- {availablePlatforms.map((platform) => { - const IconComponent = platformIconComponents[platform] - return ( - - ) - })} + + {/* Header */} +
+
+
+ 📱 +
+
+

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

+

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

+ +
- {/* Footer - fixed with safe area */} -
- + {/* Platforms grid */} +
+
+ {availablePlatforms.map((platform) => { + const IconComponent = platformIconComponents[platform] + const isDetected = platform === detectedPlatform + return ( + + ) + })}
-
+ + {/* Copy link */} +
+ +
+
) } - // Step 2: Select app (if not selected yet) + // Step 2: Select app if (!selectedApp) { return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
-
- -

- {getPlatformName(selectedPlatform)} -

-
- +
+

{getPlatformName(selectedPlatform)}

+

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

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

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

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

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

+ {/* Apps list */} +
+ {platformApps.length === 0 ? ( +
+
+ 📭
- ) : ( -
- {platformApps.map((app) => ( - - ))} -
- )} -
+
+
+ +
+ + ))} +
+ )}
-
+ ) } - // Step 3: Show app instructions and connect button + // Step 3: App instructions return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
-
- -

- {selectedApp.name} -

-
- +
+

{selectedApp.name}

+

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

+
+ +
- {/* 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 +534,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/layout/Layout.tsx b/src/components/layout/Layout.tsx index 0c82aee..876f537 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -123,18 +123,6 @@ const WheelIcon = () => ( ) -const FullscreenIcon = () => ( - - - -) - -const ExitFullscreenIcon = () => ( - - - -) - export default function Layout({ children }: LayoutProps) { const { t } = useTranslation() const location = useLocation() @@ -142,7 +130,7 @@ 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() // Fetch enabled themes from API - same source of truth as AdminSettings const { data: enabledThemes } = useQuery({ @@ -372,20 +360,6 @@ 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 && (
+ + {/* 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() {