diff --git a/index.html b/index.html index 8f9b149..e654320 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + diff --git a/src/App.tsx b/src/App.tsx index d827e18..4d87f50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,10 @@ import { lazy, Suspense } from 'react' import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './store/auth' +import { useBlockingStore } from './store/blocking' import Layout from './components/layout/Layout' import PageLoader from './components/common/PageLoader' +import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking' import { saveReturnUrl } from './utils/token' // Auth pages - load immediately (small) @@ -89,9 +91,25 @@ function LazyPage({ children }: { children: React.ReactNode }) { ) } +function BlockingOverlay() { + const { blockingType } = useBlockingStore() + + if (blockingType === 'maintenance') { + return + } + + if (blockingType === 'channel_subscription') { + return + } + + return null +} + function App() { return ( - + <> + + {/* Public routes */} } /> } /> @@ -316,6 +334,7 @@ function App() { {/* Catch all */} } /> + ) } diff --git a/src/api/client.ts b/src/api/client.ts index 808184c..e65dc3f 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,6 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token' +import { useBlockingStore } from '../store/blocking' const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' @@ -87,12 +88,57 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => return config }) -// Response interceptor - handle 401 as fallback +// Custom error types for special handling +export interface MaintenanceError { + code: 'maintenance' + message: string + reason?: string +} + +export interface ChannelSubscriptionError { + code: 'channel_subscription_required' + message: string + channel_link?: string +} + +export function isMaintenanceError(error: unknown): error is { response: { status: 503, data: { detail: MaintenanceError } } } { + if (!error || typeof error !== 'object') return false + const err = error as AxiosError<{ detail: MaintenanceError }> + return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance' +} + +export function isChannelSubscriptionError(error: unknown): error is { response: { status: 403, data: { detail: ChannelSubscriptionError } } } { + if (!error || typeof error !== 'object') return false + const err = error as AxiosError<{ detail: ChannelSubscriptionError }> + return err.response?.status === 403 && err.response?.data?.detail?.code === 'channel_subscription_required' +} + +// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription) apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + // Handle maintenance mode (503) + if (isMaintenanceError(error)) { + const detail = (error.response?.data as { detail: MaintenanceError }).detail + useBlockingStore.getState().setMaintenance({ + message: detail.message, + reason: detail.reason, + }) + return Promise.reject(error) + } + + // Handle channel subscription required (403) + if (isChannelSubscriptionError(error)) { + const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail + useBlockingStore.getState().setChannelSubscription({ + message: detail.message, + channel_link: detail.channel_link, + }) + return Promise.reject(error) + } + // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 4371ab0..cc723c1 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -156,14 +156,19 @@ export const subscriptionApi = { uuid: string name: string country_code: string | null + base_price_kopeks: number price_kopeks: number + price_per_month_kopeks: number price_rubles: number is_available: boolean is_connected: boolean - is_trial_eligible: boolean + has_discount: boolean + discount_percent: number }> connected_count: number has_subscription: boolean + days_left: number + discount_percent: number }> => { const response = await apiClient.get('/cabinet/subscription/countries') return response.data @@ -321,4 +326,24 @@ export const subscriptionApi = { const response = await apiClient.put('/cabinet/subscription/traffic', { gb }) return response.data }, + + // Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds) + refreshTraffic: async (): Promise<{ + success: boolean + cached: boolean + rate_limited?: boolean + retry_after_seconds?: number + source?: string + traffic_used_bytes: number + traffic_used_gb: number + traffic_limit_bytes: number + traffic_limit_gb: number + traffic_used_percent: number + is_unlimited: boolean + lifetime_used_bytes?: number + lifetime_used_gb?: number + }> => { + const response = await apiClient.post('/cabinet/subscription/refresh-traffic') + return response.data + }, } diff --git a/src/components/AnimatedBackground.tsx b/src/components/AnimatedBackground.tsx index fe54b1b..0557e2a 100644 --- a/src/components/AnimatedBackground.tsx +++ b/src/components/AnimatedBackground.tsx @@ -4,16 +4,11 @@ import { brandingApi } from '../api/branding' const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled' -// Detect low-performance device (mobile in Telegram WebApp) +// Detect if user prefers reduced motion const isLowPerformance = (): boolean => { - // Check if running in Telegram WebApp - const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp - // Check if mobile - const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) - // Check for reduced motion preference + // Only check for reduced motion preference - let animation run everywhere else const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches - - return prefersReducedMotion || (isTelegramWebApp && isMobile) + return prefersReducedMotion } // Get cached value from localStorage diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 6b90673..2f015ea 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react' +import { createPortal } from 'react-dom' interface ColorPickerProps { value: string @@ -8,7 +9,7 @@ interface ColorPickerProps { disabled?: boolean } -// Check if running in Telegram WebApp (native color picker causes crash) +// Check if running in Telegram WebApp const isTelegramWebApp = (): boolean => { return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp } @@ -30,106 +31,330 @@ const rgbToHex = (r: number, g: number, b: number): string => { return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('') } +// Convert RGB to HSL +const rgbToHsl = (r: number, g: number, b: number): { h: number; s: number; l: number } => { + r /= 255; g /= 255; b /= 255 + const max = Math.max(r, g, b), min = Math.min(r, g, b) + let h = 0, s = 0 + const l = (max + min) / 2 + if (max !== min) { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break + case g: h = ((b - r) / d + 2) / 6; break + case b: h = ((r - g) / d + 4) / 6; break + } + } + return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) } +} + +// Convert HSL to RGB +const hslToRgb = (h: number, s: number, l: number): { r: number; g: number; b: number } => { + h /= 360; s /= 100; l /= 100 + let r, g, b + if (s === 0) { + r = g = b = l + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1 + if (t > 1) t -= 1 + if (t < 1/6) return p + (q - p) * 6 * t + if (t < 1/2) return q + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6 + return p + } + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + r = hue2rgb(p, q, h + 1/3) + g = hue2rgb(p, q, h) + b = hue2rgb(p, q, h - 1/3) + } + return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) } +} + const PRESET_COLORS = [ - '#3b82f6', // Blue - '#ef4444', // Red - '#22c55e', // Green - '#f59e0b', // Amber - '#8b5cf6', // Violet - '#ec4899', // Pink - '#06b6d4', // Cyan - '#14b8a6', // Teal - '#84cc16', // Lime - '#f97316', // Orange - '#6366f1', // Indigo - '#a855f7', // Purple - '#ffffff', // White - '#64748b', // Slate - '#1e293b', // Dark - '#000000', // Black + '#3b82f6', '#ef4444', '#22c55e', '#f59e0b', + '#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6', + '#84cc16', '#f97316', '#6366f1', '#a855f7', ] export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) { const [isOpen, setIsOpen] = useState(false) const [localValue, setLocalValue] = useState(value) - const [rgb, setRgb] = useState(() => hexToRgb(value)) - const containerRef = useRef(null) + const [hsl, setHsl] = useState(() => { + const rgb = hexToRgb(value) + return rgbToHsl(rgb.r, rgb.g, rgb.b) + }) + const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number; openUp: boolean }>({ top: 0, left: 0, openUp: false }) + + const buttonRef = useRef(null) + const pickerRef = useRef(null) const colorInputRef = useRef(null) - // Memoize Telegram check to avoid recalculating const isTelegram = useMemo(() => isTelegramWebApp(), []) + // Sync with external value useEffect(() => { setLocalValue(value) - setRgb(hexToRgb(value)) + const rgb = hexToRgb(value) + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) }, [value]) - // Handle RGB slider change - const handleRgbChange = useCallback((channel: 'r' | 'g' | 'b', val: number) => { - const newRgb = { ...rgb, [channel]: val } - setRgb(newRgb) - const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b) - setLocalValue(hex) - onChange(hex) - }, [rgb, onChange]) + // Calculate picker position + const updatePosition = useCallback(() => { + if (!buttonRef.current) return - // Close on outside click - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false) - } + const rect = buttonRef.current.getBoundingClientRect() + const pickerHeight = 320 + const pickerWidth = 280 + const padding = 12 + + // Check if there's space below + const spaceBelow = window.innerHeight - rect.bottom + const spaceAbove = rect.top + const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow + + // Calculate left position (ensure it stays in viewport) + let left = rect.left + if (left + pickerWidth > window.innerWidth - padding) { + left = window.innerWidth - pickerWidth - padding } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) + if (left < padding) left = padding + + setPickerPosition({ + top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8, + left, + openUp + }) }, []) - const handleColorInputChange = (e: React.ChangeEvent) => { + // Open picker + const handleOpen = useCallback(() => { + if (disabled) return + updatePosition() + setIsOpen(true) + }, [disabled, updatePosition]) + + // Close picker + const handleClose = useCallback(() => { + setIsOpen(false) + }, []) + + // Handle click outside + useEffect(() => { + if (!isOpen) return + + const handleClickOutside = (e: MouseEvent) => { + if ( + pickerRef.current && !pickerRef.current.contains(e.target as Node) && + buttonRef.current && !buttonRef.current.contains(e.target as Node) + ) { + handleClose() + } + } + + 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]) + + // Update color from HSL + const updateColorFromHsl = useCallback((newHsl: { h: number; s: number; l: number }) => { + const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l) + const hex = rgbToHex(rgb.r, rgb.g, rgb.b) + setHsl(newHsl) + setLocalValue(hex) + onChange(hex) + }, [onChange]) + + // Handle hue change + const handleHueChange = useCallback((e: React.ChangeEvent) => { + updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) }) + }, [hsl, updateColorFromHsl]) + + // Handle saturation change + const handleSaturationChange = useCallback((e: React.ChangeEvent) => { + updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) }) + }, [hsl, updateColorFromHsl]) + + // Handle lightness change + const handleLightnessChange = useCallback((e: React.ChangeEvent) => { + updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) }) + }, [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]) - const handleHexInputChange = (e: React.ChangeEvent) => { + // Handle hex input + const handleHexInputChange = useCallback((e: React.ChangeEvent) => { let newValue = e.target.value - - // Add # if not present if (newValue && !newValue.startsWith('#')) { newValue = '#' + newValue } - - // Validate hex format if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) { setLocalValue(newValue) - - // Only trigger onChange for valid complete hex if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) { + const rgb = hexToRgb(newValue) + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) onChange(newValue) } } - } + }, [onChange]) - const handlePresetClick = (color: string) => { + // Handle preset click + const handlePresetClick = useCallback((color: string) => { setLocalValue(color) + const rgb = hexToRgb(color) + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) onChange(color) - setIsOpen(false) - } + handleClose() + }, [onChange, handleClose]) + + // Picker content + const pickerContent = isOpen ? ( +
e.stopPropagation()} + > + {/* Color preview header */} +
+ + {/* Controls */} +
+ {/* Hue slider */} +
+
+ Hue + {hsl.h}° +
+ +
+ + {/* Saturation slider */} +
+
+ Saturation + {hsl.s}% +
+ +
+ + {/* Lightness slider */} +
+
+ Lightness + {hsl.l}% +
+ +
+ + {/* Hex input */} +
+ HEX + +
+ + {/* Presets */} +
+ Presets +
+ {PRESET_COLORS.map((preset) => ( +
+
+
+
+ ) : null return ( -
+
{description &&

{description}

}
- {/* Color preview button - min 44px for touch accessibility */} + {/* Color preview button */} )}
- {/* Dropdown with presets and RGB sliders */} - {isOpen && ( -
- {/* RGB Sliders - shown in Telegram instead of native picker */} - {isTelegram && ( -
-
RGB
-
- {/* Red */} -
- R - handleRgbChange('r', parseInt(e.target.value))} - className="flex-1 h-2 rounded-full appearance-none cursor-pointer" - style={{ - background: `linear-gradient(to right, rgb(0,${rgb.g},${rgb.b}), rgb(255,${rgb.g},${rgb.b}))`, - }} - /> - {rgb.r} -
- {/* Green */} -
- G - handleRgbChange('g', parseInt(e.target.value))} - className="flex-1 h-2 rounded-full appearance-none cursor-pointer" - style={{ - background: `linear-gradient(to right, rgb(${rgb.r},0,${rgb.b}), rgb(${rgb.r},255,${rgb.b}))`, - }} - /> - {rgb.g} -
- {/* Blue */} -
- B - handleRgbChange('b', parseInt(e.target.value))} - className="flex-1 h-2 rounded-full appearance-none cursor-pointer" - style={{ - background: `linear-gradient(to right, rgb(${rgb.r},${rgb.g},0), rgb(${rgb.r},${rgb.g},255))`, - }} - /> - {rgb.b} -
-
-
- )} - -
Preset colors
-
- {PRESET_COLORS.map((preset) => ( -
-
- )} + {/* Render picker in portal */} + {typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
) } diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index ae7d8bf..3b5ccb4 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -1,97 +1,51 @@ -import { useState, useMemo, useEffect } from 'react' +import { useState, useMemo, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { subscriptionApi } from '../api/subscription' +import { useTelegramWebApp } from '../hooks/useTelegramWebApp' import type { AppInfo, AppConfig, LocalizedText } from '../types' interface ConnectionModalProps { onClose: () => void } -// Platform SVG Icons -const IosIcon = () => ( - - - -) - -const AndroidIcon = () => ( - - - -) - -const WindowsIcon = () => ( - - - -) - -const MacosIcon = () => ( - - - -) - -const LinuxIcon = () => ( - - - -) - -const TvIcon = () => ( - - - - -) - +// Icons const CloseIcon = () => ( - + ) -const BackIcon = () => ( - - - -) - const CopyIcon = () => ( - + ) const CheckIcon = () => ( - + ) const LinkIcon = () => ( - + ) const ChevronIcon = () => ( - - + + ) -// Platform icon components map -const platformIconComponents: Record = { - ios: IosIcon, - android: AndroidIcon, - macos: MacosIcon, - windows: WindowsIcon, - linux: LinuxIcon, - androidTV: TvIcon, - appleTV: TvIcon, -} +const BackIcon = () => ( + + + +) // App icons const HappIcon = () => ( @@ -108,20 +62,6 @@ const HappIcon = () => ( const ClashMetaIcon = () => ( - - - - - - -) - -const ClashVergeIcon = () => ( - - - - - ) @@ -133,95 +73,136 @@ const ShadowrocketIcon = () => ( const StreisandIcon = () => ( - + ) -// App icon mapping by name (case-insensitive) -const getAppIcon = (appName: string, isFeatured: boolean): React.ReactNode => { +const getAppIcon = (appName: string): 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 ? '⭐' : '📦' + if (name.includes('happ')) return + if (name.includes('shadowrocket') || name.includes('rocket')) return + if (name.includes('streisand')) return + if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return + return 📦 } -// 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:'] function isValidExternalUrl(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() - if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { - return false - } + if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://') } function isValidDeepLink(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() - if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { - return false - } + if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false return lowerUrl.includes('://') } function detectPlatform(): string | null { - if (typeof window === 'undefined' || !navigator?.userAgent) { - return null - } + if (typeof window === 'undefined' || !navigator?.userAgent) return null const ua = navigator.userAgent.toLowerCase() if (/iphone|ipad|ipod/.test(ua)) return 'ios' - if (/android/.test(ua)) { - if (/tv|television|smart-tv|smarttv/.test(ua)) return 'androidTV' - return 'android' - } + if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android' if (/macintosh|mac os x/.test(ua)) return 'macos' if (/windows/.test(ua)) return 'windows' if (/linux/.test(ua)) return 'linux' return null } +function useIsMobile() { + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === 'undefined') return false + return window.innerWidth < 768 + }) + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768) + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + return isMobile +} + 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 { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp() + const isMobileScreen = useIsMobile() + const isMobile = isMobileScreen + + const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 + const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0 const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], queryFn: () => subscriptionApi.getAppConfig(), }) + // Detect platform ONCE on mount (stable reference) + const detectedPlatform = useMemo(() => detectPlatform(), []) + + // Set initial app based on detected platform - AFTER appConfig loads useEffect(() => { - setDetectedPlatform(detectPlatform()) + if (!appConfig?.platforms || selectedApp) return + + // Priority: detected platform > first available platform + let platform = detectedPlatform + if (!platform || !appConfig.platforms[platform]?.length) { + platform = platformOrder.find(p => appConfig.platforms[p]?.length > 0) || null + } + + if (!platform || !appConfig.platforms[platform]?.length) return + + const apps = appConfig.platforms[platform] + // Select featured app or first app for the detected platform + const app = apps.find(a => a.isFeatured) || apps[0] + if (app) setSelectedApp(app) + }, [appConfig, detectedPlatform, selectedApp]) + + const handleClose = useCallback(() => { + onClose() + }, [onClose]) + + const handleBack = useCallback(() => { + setShowAppSelector(false) }, []) - // Lock body scroll when modal is open + // Keyboard: Escape to close (PC) useEffect(() => { - const originalStyle = window.getComputedStyle(document.body).overflow - document.body.style.overflow = 'hidden' - return () => { - document.body.style.overflow = originalStyle + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + if (showAppSelector) handleBack() + else handleClose() + } } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleClose, handleBack, showAppSelector]) + + // Telegram back button (Android) + useEffect(() => { + if (!webApp?.BackButton) return + const handler = showAppSelector ? handleBack : handleClose + webApp.BackButton.show() + webApp.BackButton.onClick(handler) + return () => { + webApp.BackButton.offClick(handler) + webApp.BackButton.hide() + } + }, [webApp, handleClose, handleBack, showAppSelector]) + + // Scroll lock + useEffect(() => { + document.body.style.overflow = 'hidden' + return () => { document.body.style.overflow = '' } }, []) const getLocalizedText = (text: LocalizedText | undefined): string => { @@ -230,30 +211,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '' } - const getPlatformName = (platformKey: string): string => { - if (!appConfig?.platformNames?.[platformKey]) { - return platformKey - } - return getLocalizedText(appConfig.platformNames[platformKey]) - } - const availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return [] - const available = platformOrder.filter( - (key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0 - ) + const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0) + // Put detected platform first if (detectedPlatform && available.includes(detectedPlatform)) { - const filtered = available.filter(p => p !== detectedPlatform) - return [detectedPlatform, ...filtered] + return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)] } return available }, [appConfig, detectedPlatform]) - const platformApps = useMemo(() => { - if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return [] - return appConfig.platforms[selectedPlatform] - }, [selectedPlatform, appConfig]) - const copySubscriptionLink = async () => { if (!appConfig?.subscriptionUrl) return try { @@ -273,339 +240,263 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { } const handleConnect = (app: AppInfo) => { - if (!app.deepLink || !isValidDeepLink(app.deepLink)) { - console.warn('Invalid or missing deep link:', app.deepLink) - return - } + if (!app.deepLink || !isValidDeepLink(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}` - const isCustomScheme = !/^https?:\/\//i.test(app.deepLink) const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp - if (isCustomScheme && tg?.openLink) { + if (tg?.openLink) { try { tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) return - } catch (e) { - console.warn('tg.openLink failed:', e) - } + } catch { /* fallback */ } } window.location.href = redirectUrl } - // Modal wrapper - centered on desktop, top on mobile - const ModalWrapper = ({ children }: { children: React.ReactNode }) => ( -
-
e.stopPropagation()} - > - {children} -
-
- ) - - // Loading state - if (isLoading) { - return ( - -
-
+ // Wrapper component + const Wrapper = ({ children }: { children: React.ReactNode }) => { + if (isMobile) { + // Mobile fullscreen + const content = ( +
+ {children}
- + ) + if (typeof document !== 'undefined') return createPortal(content, document.body) + return content + } + + // Desktop centered + return ( +
+
e.stopPropagation()} + > + {children} +
+
) } - // Error state + // Loading + if (isLoading) { + return ( + +
+
+
+ + ) + } + + // Error if (error || !appConfig) { return ( - -
-
- 😕 -
-

{t('common.error')}

- + +
+

{t('common.error')}

+
- +
) } // No subscription if (!appConfig.hasSubscription) { return ( - -
-
- 📱 -
-

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

-

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

- + +
+

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

+

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

+
- +
) } - // Step 1: Select platform - if (!selectedPlatform) { + // App selector + if (showAppSelector) { + const platformNames: Record = { + ios: 'iOS', android: 'Android', windows: 'Windows', + macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV' + } + return ( - + {/* Header */} -
-
-
- 📱 -
-
-

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

-

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

-
-
- -
- - {/* Platforms grid */} -
-
- {availablePlatforms.map((platform) => { - const IconComponent = platformIconComponents[platform] - const isDetected = platform === detectedPlatform - return ( - - ) - })} -
-
- - {/* Copy link */} -
- -
-
- ) - } - - // Step 2: Select app - if (!selectedApp) { - return ( - - {/* Header */} -
-
- -
-

{getPlatformName(selectedPlatform)}

-

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

-
-
- -
- - {/* Apps list */} -
- {platformApps.length === 0 ? ( -
-
- 📭 -
-

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

-
- ) : ( -
- {platformApps.map((app) => ( - - ))} -
- )} -
-
- ) - } - - // Step 3: App instructions - return ( - - {/* Header */} -
-
- -
-

{selectedApp.name}

-

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

-
+

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

- + ))} +
+
+ ) + })} +
+ + ) + } + + // Main view + return ( + + {/* Header */} +
+
+

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

+ +
+ + {/* App selector button */} +
- {/* Instructions */} -
+ {/* Steps */} +
{/* 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) => ( - - - {getLocalizedText(btn.buttonText)} - - ))} -
- )} -
+ {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) => ( + + {getLocalizedText(btn.buttonText)} + + ))} +
+ )}
)} {/* Step 2: Add subscription */} - {selectedApp.addSubscriptionStep && ( -
-
-
2
-
-

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

-

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

-
- {selectedApp.deepLink && ( - - )} - -
-
+ {selectedApp?.addSubscriptionStep && ( +
+
+ 2 + {t('subscription.connection.addSubscription')} +
+

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

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

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

-

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

-
+ {selectedApp?.connectAndUseStep && ( +
+
+ 3 + {t('subscription.connection.connectVpn')}
+

{getLocalizedText(selectedApp.connectAndUseStep.description)}

)}
- - {/* Footer */} -
- -
- + ) } diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 056c19c..8346a9b 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -1,8 +1,10 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { useMutation } from '@tanstack/react-query' 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 type { PaymentMethod } from '../types' @@ -17,7 +19,6 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) } } if (webApp?.openLink) { - // try_browser: true - открывает диалог для перехода во внешний браузер (важно для мобильных) try { webApp.openLink(url, { try_instant_view: false, try_browser: true }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) } } if (reservedWindow && !reservedWindow.closed) { @@ -29,16 +30,78 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { window.location.href = url } +// Icons +const CloseIcon = () => ( + + + +) + +const WalletIcon = () => ( + + + +) + +const StarIcon = () => ( + + + +) + +const CardIcon = () => ( + + + +) + +const CryptoIcon = () => ( + + + +) + +const SparklesIcon = () => ( + + + +) + 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 + if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return + return +} + export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { const { t } = useTranslation() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() + const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp() const inputRef = useRef(null) + const isMobileScreen = useIsMobile() + + const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 const getInitialAmount = (): string => { if (!initialAmountRubles || initialAmountRubles <= 0) return '' @@ -54,6 +117,58 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top method.options && method.options.length > 0 ? method.options[0].id : null ) const popupRef = useRef(null) + const [isInputFocused, setIsInputFocused] = useState(false) + + const handleClose = useCallback(() => { + onClose() + }, [onClose]) + + // Keyboard: Escape to close (PC) + 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 (Android) + useEffect(() => { + if (!webApp?.BackButton) return + webApp.BackButton.show() + webApp.BackButton.onClick(handleClose) + return () => { + webApp.BackButton.offClick(handleClose) + webApp.BackButton.hide() + } + }, [webApp, 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 @@ -132,105 +247,250 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top : convertAmount(rub).toFixed(currencyDecimals) const isPending = topUpMutation.isPending || starsPaymentMutation.isPending - return ( -
-
+ // Auto-focus input - works on mobile in Telegram WebApp + useEffect(() => { + // Small delay to ensure DOM is ready + const timer = setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus() + // For iOS Safari - scroll input into view to trigger keyboard + if (isMobileScreen) { + inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + }, 100) + return () => clearTimeout(timer) + }, []) + + // Calculate display amount for preview + const displayAmount = amount && parseFloat(amount) > 0 ? parseFloat(amount) : 0 + + // Content JSX - shared between mobile and desktop + const contentJSX = ( +
+ {/* Header icon and method */} +
+
+
+ {getMethodIcon(method.id)} +
+
+
+

{methodName}

+

+ {formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)} {currencySymbol} +

+
+
+ + {/* Payment options (if any) */} + {hasOptions && method.options && ( +
+ +
+ {method.options.map((opt) => ( + + ))} +
+
+ )} + + {/* Amount input - modern design */} +
+ +
+ setAmount(e.target.value)} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }} + placeholder="0" + className="w-full h-16 px-5 pr-16 text-2xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none" + style={{ fontSize: '24px' }} + autoComplete="off" + autoFocus + /> + + {currencySymbol} + +
+
+ + {/* Quick amount buttons */} + {quickAmounts.length > 0 && ( +
+ {quickAmounts.map((a) => { + const val = getQuickValue(a) + const isSelected = amount === val + return ( + + ) + })} +
+ )} + + {/* Error message */} + {error && ( +
+ + + + {error} +
+ )} + + {/* Submit button */} + +
+ ) + + // Render modal based on screen size - NO nested components! + const modalContent = isMobileScreen ? ( + <> + {/* Backdrop */} +
+ {/* Bottom sheet */} +
e.stopPropagation()} + > + {/* Handle bar */} +
+
+
-
{/* Header */} -
- {methodName} -
-
- {/* Payment options */} - {hasOptions && method.options && ( -
- {method.options.map((opt) => ( - - ))} -
- )} + {/* Divider */} +
- {/* Amount input */} -
- setAmount(e.target.value)} - placeholder={`${formatAmount(minRubles, 0)} – ${formatAmount(maxRubles, 0)}`} - className="w-full h-12 px-4 pr-12 text-lg font-semibold bg-dark-800 border border-dark-700 rounded-xl text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500" - autoComplete="off" - /> - - {currencySymbol} - + {/* Content */} +
+ {contentJSX} +
+
+ + ) : ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+ {t('balance.topUp')}
- - {/* Quick amounts */} - {quickAmounts.length > 0 && ( -
- {quickAmounts.map((a) => { - const val = getQuickValue(a) - return ( - - ) - })} -
- )} - - {/* Error */} - {error && ( -
{error}
- )} - - {/* Submit */}
+ + {/* Content */} +
+ {contentJSX} +
) + + if (typeof document !== 'undefined') { + return createPortal(modalContent, document.body) + } + return modalContent } diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx new file mode 100644 index 0000000..98788f4 --- /dev/null +++ b/src/components/blocking/ChannelSubscriptionScreen.tsx @@ -0,0 +1,155 @@ +import { useState, useEffect, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useBlockingStore } from '../../store/blocking' +import { apiClient } from '../../api/client' + +const CHECK_COOLDOWN_SECONDS = 5 + +export default function ChannelSubscriptionScreen() { + const { t } = useTranslation() + const { channelInfo, clearBlocking } = useBlockingStore() + const [isChecking, setIsChecking] = useState(false) + const [cooldown, setCooldown] = useState(0) + const [error, setError] = useState(null) + + // Cooldown timer + useEffect(() => { + if (cooldown <= 0) return + + const timer = setInterval(() => { + setCooldown((prev) => { + if (prev <= 1) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, [cooldown]) + + const openChannel = useCallback(() => { + if (channelInfo?.channel_link) { + window.open(channelInfo.channel_link, '_blank') + } + }, [channelInfo?.channel_link]) + + const checkSubscription = useCallback(async () => { + if (isChecking || cooldown > 0) return + + setIsChecking(true) + setError(null) + + try { + // Make any authenticated request - if channel check passes, it will succeed + await apiClient.get('/cabinet/auth/me') + // If we get here, subscription is valid - reload page + clearBlocking() + window.location.reload() + } catch (err: unknown) { + // Check if it's still a channel subscription error + const error = err as { response?: { status?: number; data?: { detail?: { code?: string } } } } + if (error.response?.status === 403 && error.response?.data?.detail?.code === 'channel_subscription_required') { + setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал')) + } else { + // Other error - might be network issue + setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.')) + } + } finally { + setIsChecking(false) + setCooldown(CHECK_COOLDOWN_SECONDS) + } + }, [isChecking, cooldown, clearBlocking, t]) + + return ( +
+
+ {/* Icon */} +
+
+ + + +
+
+ + {/* Title */} +

+ {t('blocking.channel.title', 'Подписка на канал')} +

+ + {/* Message */} +

+ {channelInfo?.message || t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')} +

+ + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + + {/* Buttons */} +
+ {/* Open channel button */} + + + {/* Check subscription button */} + +
+ + {/* Hint */} +

+ {t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')} +

+
+
+ ) +} diff --git a/src/components/blocking/MaintenanceScreen.tsx b/src/components/blocking/MaintenanceScreen.tsx new file mode 100644 index 0000000..a7a052b --- /dev/null +++ b/src/components/blocking/MaintenanceScreen.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next' +import { useBlockingStore } from '../../store/blocking' + +export default function MaintenanceScreen() { + const { t } = useTranslation() + const { maintenanceInfo } = useBlockingStore() + + return ( +
+
+ {/* Icon */} +
+
+ + + +
+
+ + {/* Title */} +

+ {t('blocking.maintenance.title', 'Технические работы')} +

+ + {/* Message */} +

+ {maintenanceInfo?.message || t('blocking.maintenance.defaultMessage', 'Сервис временно недоступен. Проводятся технические работы.')} +

+ + {/* Reason */} + {maintenanceInfo?.reason && ( +
+

+ {t('blocking.maintenance.reason', 'Причина')}: +

+

+ {maintenanceInfo.reason} +

+
+ )} + + {/* Decorative dots */} +
+
+
+
+
+ +

+ {t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')} +

+
+
+ ) +} diff --git a/src/components/blocking/index.ts b/src/components/blocking/index.ts new file mode 100644 index 0000000..c04dc4e --- /dev/null +++ b/src/components/blocking/index.ts @@ -0,0 +1,2 @@ +export { default as MaintenanceScreen } from './MaintenanceScreen' +export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen' diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 876f537..4333c07 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' @@ -132,6 +133,12 @@ export default function Layout({ children }: LayoutProps) { const [userPhotoUrl, setUserPhotoUrl] = useState(null) 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({ queryKey: ['enabled-themes'], @@ -155,17 +162,50 @@ 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) + // Note: We avoid using body position:fixed with top:-scrollY as it causes issues + // in Telegram Mini App where the menu disappears when opened from scrolled position 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 body = document.body + const html = document.documentElement + + // Save original styles + const originalStyles = { + bodyOverflow: body.style.overflow, + htmlOverflow: html.style.overflow, } + + // Lock scroll - simple approach without body position manipulation + body.style.overflow = 'hidden' + html.style.overflow = 'hidden' + + // Prevent touchmove on body (critical for mobile, especially Telegram Mini App) + 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 }) + + // Also prevent wheel scroll on desktop + const preventWheel = (e: WheelEvent) => { + const target = e.target as HTMLElement + if (target.closest('.mobile-menu-content')) return + e.preventDefault() + } + document.addEventListener('wheel', preventWheel, { passive: false }) + return () => { - document.body.style.overflow = '' + // Restore original styles + body.style.overflow = originalStyles.bodyOverflow + html.style.overflow = originalStyles.htmlOverflow + + // Remove listeners + document.removeEventListener('touchmove', preventScroll) + document.removeEventListener('wheel', preventWheel) } }, [mobileMenuOpen]) @@ -287,18 +327,42 @@ 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 */} @@ -363,7 +427,10 @@ export default function Layout({ children }: LayoutProps) { {/* 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)}>
@@ -413,7 +484,10 @@ export default function Layout({ children }: LayoutProps) { {/* Mobile menu button */}
- {/* Mobile menu - fixed overlay */} + {/* Spacer for fixed header - matches header height */} + {isFullscreen ? ( +
+ ) : ( +
+ )} + + {/* Mobile menu - fixed overlay below header */} {mobileMenuOpen && ( -
+
{/* Backdrop */}
{/* Menu content */} -
+
{/* User info */}
@@ -546,6 +637,7 @@ export default function Layout({ children }: LayoutProps) { setMobileMenuOpen(false)} className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'} > 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 31f5d01..4fc5b03 100644 --- a/src/hooks/useTelegramWebApp.ts +++ b/src/hooks/useTelegramWebApp.ts @@ -25,13 +25,14 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => { * Provides fullscreen mode, safe area insets, and other WebApp features */ export function useTelegramWebApp() { - const [isFullscreen, setIsFullscreen] = useState(false) - const [isTelegramWebApp, setIsTelegramWebApp] = useState(false) - const [safeAreaInset, setSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 }) - const [contentSafeAreaInset, setContentSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 }) - + // Initialize synchronously to avoid flash/flicker on first render const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined + const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false) + const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp) + const [safeAreaInset, setSafeAreaInset] = useState(() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + const [contentSafeAreaInset, setContentSafeAreaInset] = useState(() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + useEffect(() => { if (!webApp) { setIsTelegramWebApp(false) diff --git a/src/locales/en.json b/src/locales/en.json index 3edb504..718150a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -33,7 +33,8 @@ "contests": "Contests", "polls": "Polls", "info": "Info", - "wheel": "Fortune Wheel" + "wheel": "Fortune Wheel", + "menu": "Menu" }, "notifications": { "ticketNotifications": "Ticket Notifications", @@ -185,7 +186,8 @@ "copyLink": "Copy subscription link", "copied": "Link copied!", "openLink": "Open link", - "instructions": "Instructions" + "instructions": "Instructions", + "changeApp": "Change app" }, "myDevices": "My Devices", "noDevices": "No connected devices", @@ -251,6 +253,8 @@ "currentBalance": "Current Balance", "topUp": "Top Up", "topUpBalance": "Top Up Balance", + "enterAmount": "Enter amount", + "paymentMethod": "Payment Method", "paymentMethods": "Payment Methods", "transactionHistory": "Transaction History", "noTransactions": "No transactions", @@ -1313,5 +1317,24 @@ "hours": "h", "minutes": "m" } + }, + "blocking": { + "maintenance": { + "title": "Maintenance", + "defaultMessage": "Service is temporarily unavailable. Maintenance in progress.", + "reason": "Reason", + "waitMessage": "Please wait..." + }, + "channel": { + "title": "Channel Subscription", + "defaultMessage": "Please subscribe to our channel to continue", + "openChannel": "Open Channel", + "checkSubscription": "Check Subscription", + "checking": "Checking...", + "waitSeconds": "Wait {{seconds}} sec.", + "hint": "Click check button after subscribing", + "notSubscribed": "You haven't subscribed to the channel yet", + "checkError": "Check failed. Please try again later." + } } } diff --git a/src/locales/fa.json b/src/locales/fa.json index c0f1918..a13371a 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -183,6 +183,8 @@ "currentBalance": "موجودی فعلی", "topUp": "شارژ", "topUpBalance": "شارژ موجودی", + "enterAmount": "مقدار را وارد کنید", + "paymentMethod": "روش پرداخت", "paymentMethods": { "yookassa": { "name": "YooKassa", @@ -794,5 +796,24 @@ "hours": "ساعت", "minutes": "دقیقه" } + }, + "blocking": { + "maintenance": { + "title": "تعمیرات", + "defaultMessage": "سرویس موقتاً در دسترس نیست. تعمیرات در حال انجام است.", + "reason": "دلیل", + "waitMessage": "لطفاً صبر کنید..." + }, + "channel": { + "title": "اشتراک کانال", + "defaultMessage": "لطفاً برای ادامه در کانال ما عضو شوید", + "openChannel": "باز کردن کانال", + "checkSubscription": "بررسی اشتراک", + "checking": "در حال بررسی...", + "waitSeconds": "{{seconds}} ثانیه صبر کنید", + "hint": "پس از عضویت دکمه بررسی را بزنید", + "notSubscribed": "شما هنوز در کانال عضو نشده‌اید", + "checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید." + } } } \ No newline at end of file diff --git a/src/locales/ru.json b/src/locales/ru.json index 01563dc..310a0ae 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -33,7 +33,8 @@ "contests": "Конкурсы", "polls": "Опросы", "info": "Информация", - "wheel": "Колесо удачи" + "wheel": "Колесо удачи", + "menu": "Меню" }, "notifications": { "ticketNotifications": "Уведомления о тикетах", @@ -185,7 +186,8 @@ "copyLink": "Скопировать ссылку", "copied": "Ссылка скопирована!", "openLink": "Открыть ссылку", - "instructions": "Инструкция" + "instructions": "Инструкция", + "changeApp": "Сменить приложение" }, "myDevices": "Мои устройства", "noDevices": "Нет подключенных устройств", @@ -251,6 +253,8 @@ "currentBalance": "Текущий баланс", "topUp": "Пополнить", "topUpBalance": "Пополнение баланса", + "enterAmount": "Введите сумму", + "paymentMethod": "Способ оплаты", "paymentMethods": "Способы оплаты", "transactionHistory": "История операций", "noTransactions": "Нет операций", @@ -1471,5 +1475,24 @@ "hours": "ч", "minutes": "м" } + }, + "blocking": { + "maintenance": { + "title": "Технические работы", + "defaultMessage": "Сервис временно недоступен. Проводятся технические работы.", + "reason": "Причина", + "waitMessage": "Пожалуйста, подождите..." + }, + "channel": { + "title": "Подписка на канал", + "defaultMessage": "Для продолжения работы подпишитесь на наш канал", + "openChannel": "Открыть канал", + "checkSubscription": "Проверить подписку", + "checking": "Проверяем...", + "waitSeconds": "Подождите {{seconds}} сек.", + "hint": "После подписки нажмите кнопку проверки", + "notSubscribed": "Вы ещё не подписались на канал", + "checkError": "Ошибка проверки. Попробуйте позже." + } } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 8ce491a..f02e127 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -31,7 +31,8 @@ "contests": "活动", "polls": "问卷", "info": "信息", - "wheel": "幸运转盘" + "wheel": "幸运转盘", + "menu": "菜单" }, "notifications": { "ticketNotifications": "工单通知", @@ -183,6 +184,8 @@ "currentBalance": "当前余额", "topUp": "充值", "topUpBalance": "充值余额", + "enterAmount": "输入金额", + "paymentMethod": "支付方式", "paymentMethods": { "yookassa": { "name": "YooKassa", @@ -794,5 +797,24 @@ "hours": "时", "minutes": "分" } + }, + "blocking": { + "maintenance": { + "title": "系统维护", + "defaultMessage": "服务暂时不可用。正在进行系统维护。", + "reason": "原因", + "waitMessage": "请稍候..." + }, + "channel": { + "title": "频道订阅", + "defaultMessage": "请订阅我们的频道以继续", + "openChannel": "打开频道", + "checkSubscription": "检查订阅", + "checking": "检查中...", + "waitSeconds": "请等待 {{seconds}} 秒", + "hint": "订阅后点击检查按钮", + "notSubscribed": "您还没有订阅该频道", + "checkError": "检查失败。请稍后重试。" + } } } \ No newline at end of file diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 46208c8..2c20823 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -32,6 +32,12 @@ const ChevronRightIcon = () => ( ) +const RefreshIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( + + + +) + // Check if device might be low-performance (Telegram WebApp on mobile) const isLowPerfDevice = (() => { const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp @@ -123,6 +129,47 @@ export default function Dashboard() { }, }) + // Traffic refresh state and mutation + const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0) + const [trafficData, setTrafficData] = useState<{ + traffic_used_gb: number + traffic_used_percent: number + is_unlimited: boolean + } | null>(null) + + const refreshTrafficMutation = useMutation({ + mutationFn: subscriptionApi.refreshTraffic, + onSuccess: (data) => { + setTrafficData({ + traffic_used_gb: data.traffic_used_gb, + traffic_used_percent: data.traffic_used_percent, + is_unlimited: data.is_unlimited, + }) + if (data.rate_limited && data.retry_after_seconds) { + setTrafficRefreshCooldown(data.retry_after_seconds) + } else { + setTrafficRefreshCooldown(60) + } + // Also update subscription query cache + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + }, + onError: (error: { response?: { status?: number; headers?: { get?: (key: string) => string } } }) => { + if (error.response?.status === 429) { + const retryAfter = error.response.headers?.get?.('Retry-After') + setTrafficRefreshCooldown(retryAfter ? parseInt(retryAfter, 10) : 60) + } + }, + }) + + // Cooldown timer + useEffect(() => { + if (trafficRefreshCooldown <= 0) return + const timer = setInterval(() => { + setTrafficRefreshCooldown((prev) => Math.max(0, prev - 1)) + }, 1000) + return () => clearInterval(timer) + }, [trafficRefreshCooldown]) + const hasNoSubscription = !subscription && !subLoading && subError // Show onboarding for new users after data loads @@ -223,9 +270,19 @@ export default function Dashboard() {
-
{t('subscription.traffic')}
+
+ {t('subscription.traffic')} + +
- {subscription.traffic_used_gb.toFixed(1)} / {subscription.traffic_limit_gb || '∞'} GB + {(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} / {subscription.traffic_limit_gb || '∞'} GB
@@ -248,12 +305,14 @@ export default function Dashboard() {
{t('subscription.trafficUsed')} - {subscription.traffic_used_percent.toFixed(1)}% + + {(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(1)}% +
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 2af8144..11e9ef8 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -97,6 +97,8 @@ export default function Subscription() { const [devicesToAdd, setDevicesToAdd] = useState(1) const [showTrafficTopup, setShowTrafficTopup] = useState(false) const [selectedTrafficPackage, setSelectedTrafficPackage] = useState(null) + const [showServerManagement, setShowServerManagement] = useState(false) + const [selectedServersToUpdate, setSelectedServersToUpdate] = useState([]) const { data: subscription, isLoading } = useQuery({ queryKey: ['subscription'], @@ -299,6 +301,31 @@ export default function Subscription() { }, }) + // Countries/servers query + const { data: countriesData, isLoading: countriesLoading } = useQuery({ + queryKey: ['countries'], + queryFn: subscriptionApi.getCountries, + enabled: showServerManagement && !!subscription && !subscription.is_trial, + }) + + // Initialize selected servers when data loads + useEffect(() => { + if (countriesData && showServerManagement) { + const connected = countriesData.countries.filter(c => c.is_connected).map(c => c.uuid) + setSelectedServersToUpdate(connected) + } + }, [countriesData, showServerManagement]) + + // Countries update mutation + const updateCountriesMutation = useMutation({ + mutationFn: (countries: string[]) => subscriptionApi.updateCountries(countries), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + queryClient.invalidateQueries({ queryKey: ['countries'] }) + setShowServerManagement(false) + }, + }) + // Auto-scroll to switch tariff modal when it appears useEffect(() => { if (switchTariffId && switchModalRef.current) { @@ -912,6 +939,212 @@ export default function Subscription() { )}
)} + + {/* Server Management - only in classic mode */} + {!isTariffsMode && ( +
+ {!showServerManagement ? ( + + ) : ( +
+
+

Управление серверами

+ +
+ + {countriesLoading ? ( +
+
+
+ ) : countriesData && countriesData.countries.length > 0 ? ( +
+
+ ✅ — подключено • ➕ — будет добавлено (платно) • ➖ — будет отключено +
+ + {countriesData.discount_percent > 0 && ( +
+ 🎁 Ваша скидка на серверы: -{countriesData.discount_percent}% +
+ )} + +
+ {countriesData.countries.map((country) => { + const isCurrentlyConnected = country.is_connected + const isSelected = selectedServersToUpdate.includes(country.uuid) + const willBeAdded = !isCurrentlyConnected && isSelected + const willBeRemoved = isCurrentlyConnected && !isSelected + + return ( + + ) + })} +
+ + {(() => { + const currentConnected = countriesData.countries.filter(c => c.is_connected).map(c => c.uuid) + const added = selectedServersToUpdate.filter(u => !currentConnected.includes(u)) + const removed = currentConnected.filter(u => !selectedServersToUpdate.includes(u)) + const hasChanges = added.length > 0 || removed.length > 0 + + // Calculate cost for added servers + const addedServers = countriesData.countries.filter(c => added.includes(c.uuid)) + const totalCost = addedServers.reduce((sum, s) => sum + s.price_kopeks, 0) + const hasEnoughBalance = !purchaseOptions || totalCost <= purchaseOptions.balance_kopeks + const missingAmount = purchaseOptions ? totalCost - purchaseOptions.balance_kopeks : 0 + + return hasChanges ? ( +
+ {added.length > 0 && ( +
+ Добавить:{' '} + + {addedServers.map(s => s.name).join(', ')} + +
+ )} + {removed.length > 0 && ( +
+ Отключить:{' '} + + {countriesData.countries.filter(c => removed.includes(c.uuid)).map(s => s.name).join(', ')} + +
+ )} + {totalCost > 0 && ( +
+
К оплате (пропорционально оставшимся дням):
+
{formatPrice(totalCost)}
+
+ )} + + {totalCost > 0 && !hasEnoughBalance && missingAmount > 0 && ( + + )} + + +
+ ) : ( +
+ Выберите серверы для подключения или отключения +
+ ) + })()} + + {updateCountriesMutation.isError && ( +
+ {getErrorMessage(updateCountriesMutation.error)} +
+ )} +
+ ) : ( +
+ Нет доступных серверов для управления +
+ )} +
+ )} +
+ )}
)} diff --git a/src/store/blocking.ts b/src/store/blocking.ts new file mode 100644 index 0000000..10378bd --- /dev/null +++ b/src/store/blocking.ts @@ -0,0 +1,47 @@ +import { create } from 'zustand' + +export type BlockingType = 'maintenance' | 'channel_subscription' | null + +interface MaintenanceInfo { + message: string + reason?: string +} + +interface ChannelSubscriptionInfo { + message: string + channel_link?: string +} + +interface BlockingState { + blockingType: BlockingType + maintenanceInfo: MaintenanceInfo | null + channelInfo: ChannelSubscriptionInfo | null + + setMaintenance: (info: MaintenanceInfo) => void + setChannelSubscription: (info: ChannelSubscriptionInfo) => void + clearBlocking: () => void +} + +export const useBlockingStore = create((set) => ({ + blockingType: null, + maintenanceInfo: null, + channelInfo: null, + + setMaintenance: (info) => set({ + blockingType: 'maintenance', + maintenanceInfo: info, + channelInfo: null, + }), + + setChannelSubscription: (info) => set({ + blockingType: 'channel_subscription', + channelInfo: info, + maintenanceInfo: null, + }), + + clearBlocking: () => set({ + blockingType: null, + maintenanceInfo: null, + channelInfo: null, + }), +})) diff --git a/src/styles/globals.css b/src/styles/globals.css index 408b8e1..cc5325e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -203,6 +203,10 @@ background-color: rgba(254, 249, 240, 0.5) !important; /* champagne-100/50 */ } + .light .bg-dark-900\/95 { + background-color: rgba(254, 249, 240, 0.95) !important; /* champagne-100/95 - mobile menu sticky header */ + } + .light .bg-dark-950 { background-color: #F7E7CE !important; /* champagne-200 */ } @@ -958,31 +962,83 @@ input[type="range"] { -webkit-appearance: none; appearance: none; - height: 8px; - border-radius: 4px; + height: 12px; + border-radius: 6px; outline: none; + touch-action: pan-x; +} + +/* Desktop: smaller track */ +@media (min-width: 640px) { + input[type="range"] { + height: 8px; + border-radius: 4px; + } } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - width: 18px; - height: 18px; + width: 24px; + height: 24px; border-radius: 50%; background: white; cursor: pointer; - border: 2px solid rgba(0, 0, 0, 0.2); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + border: 2px solid rgba(0, 0, 0, 0.15); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.1); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4), 0 0 0 2px rgba(255, 255, 255, 0.2); +} + +input[type="range"]::-webkit-slider-thumb:active { + transform: scale(0.95); +} + +/* Desktop: smaller thumb */ +@media (min-width: 640px) { + input[type="range"]::-webkit-slider-thumb { + width: 18px; + height: 18px; + } } input[type="range"]::-moz-range-thumb { - width: 18px; - height: 18px; + width: 24px; + height: 24px; border-radius: 50%; background: white; cursor: pointer; - border: 2px solid rgba(0, 0, 0, 0.2); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + border: 2px solid rgba(0, 0, 0, 0.15); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +input[type="range"]::-moz-range-thumb:hover { + transform: scale(1.1); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4), 0 0 0 2px rgba(255, 255, 255, 0.2); +} + +/* Desktop: smaller thumb */ +@media (min-width: 640px) { + input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + } +} + +/* Hide number input spinners for cleaner look */ +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="number"] { + -moz-appearance: textfield; } /* Selection color */ @@ -1246,6 +1302,42 @@ input[type="range"]::-moz-range-thumb { background: radial-gradient(circle, rgba(var(--color-accent-500), 0.7) 0%, transparent 70%); } +/* Mobile: brighter and faster animations */ +@media (max-width: 768px) { + .wave-blob { + opacity: 0.7; + filter: blur(50px); + } + + .wave-blob-1 { + width: 300px; + height: 300px; + background: radial-gradient(circle, rgba(var(--color-accent-500), 0.8) 0%, transparent 70%); + animation: wave1-mobile 12s ease-in-out infinite; + } + + .wave-blob-2 { + width: 280px; + height: 280px; + background: radial-gradient(circle, rgba(59, 130, 246, 0.8) 0%, transparent 70%); + animation: wave2-mobile 15s ease-in-out infinite; + } + + @keyframes wave1-mobile { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } + 25% { transform: translate3d(15%, 20%, 0) scale(1.1); } + 50% { transform: translate3d(5%, 35%, 0) scale(1.15); } + 75% { transform: translate3d(20%, 10%, 0) scale(1.05); } + } + + @keyframes wave2-mobile { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } + 25% { transform: translate3d(-20%, -15%, 0) scale(1.15); } + 50% { transform: translate3d(-10%, -30%, 0) scale(1.1); } + 75% { transform: translate3d(-25%, -5%, 0) scale(1.05); } + } +} + /* Disable animations for reduced motion preference */ @media (prefers-reduced-motion: reduce) { .wave-blob {