diff --git a/.gitignore b/.gitignore index 52b5bce..d3b91dc 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ coverage/ tmp/ temp/ miniapp/ + +# AI & Agents context +.ai/ 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/public/miniapp/index.html b/public/miniapp/index.html deleted file mode 100644 index 1025629..0000000 --- a/public/miniapp/index.html +++ /dev/null @@ -1,26889 +0,0 @@ - - - - - - - Pedzeo VPN - - - - - - - - -
- -
- -
- - - - - - - -
- -
- - - - -
-
-
-
- - -
-
-
-
-
-
-
-
- -
- - - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - - - - -
- - - - - - - - -
- - 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/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/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/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 473022d..58662e6 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -1,192 +1,185 @@ import { useState, useMemo, useEffect } 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 CloseIcon = () => ( + + ) -const AndroidIcon = () => ( - - +const CopyIcon = () => ( + + ) -const WindowsIcon = () => ( - - +const CheckIcon = () => ( + + ) -const MacosIcon = () => ( - - +const LinkIcon = () => ( + + ) -const LinuxIcon = () => ( - - +const ChevronIcon = () => ( + + ) -const TvIcon = () => ( - - - - +const BackIcon = () => ( + + ) -// Platform icon components map -const platformIconComponents: Record = { - ios: IosIcon, - android: AndroidIcon, - macos: MacosIcon, - windows: WindowsIcon, - linux: LinuxIcon, - androidTV: TvIcon, - appleTV: TvIcon, +const HappIcon = () => ( + + + + + + + + +) + +const ClashMetaIcon = () => ( + + + +) + +const ShadowrocketIcon = () => ( + + + +) + +const StreisandIcon = () => ( + + + +) + +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('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:'] -/** - * 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 + if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false 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 ://) + if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false return lowerUrl.includes('://') } -// Detect user's platform from user agent 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() - - // Check for mobile devices first - 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' - } - 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 (/iphone|ipad|ipod/.test(ua)) return 'ios' + 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() { + // Initialize synchronously to avoid flash between desktop/mobile layouts + 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 } = useTelegramWebApp() + const isMobileScreen = useIsMobile() + // Use mobile layout only on small screens, even in Telegram Desktop + const isMobile = isMobileScreen + + const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 + // In fullscreen mode, add +45px for Telegram native controls (close/menu buttons in corners) + const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0 const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], queryFn: () => subscriptionApi.getAppConfig(), }) - // Auto-detect platform on mount useEffect(() => { - const detected = detectPlatform() - setDetectedPlatform(detected) + setDetectedPlatform(detectPlatform()) + }, []) + + 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] + const app = apps.find(a => a.isFeatured) || apps[0] + if (app) setSelectedApp(app) + }, [appConfig, detectedPlatform, selectedApp]) + + useEffect(() => { + document.body.style.overflow = 'hidden' + return () => { document.body.style.overflow = '' } }, []) - // 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 - } - 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 + const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0) 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]) - // 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 +187,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,402 +198,289 @@ 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 - } - + 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}` - - // 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 - - if (isCustomScheme && tg?.openLink) { - // For custom URL schemes - open redirect page in external browser via Telegram - // try_browser: true - показывает диалог для перехода во внешний браузер (важно для мобильных) + const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp + 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 */ } } - - // 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]" + // Desktop modal wrapper - compact centered modal with max height + const DesktopWrapper = ({ children }: { children: React.ReactNode }) => ( +
+
e.stopPropagation()} + > + {/* Desktop close button */} + + {children} +
+
+ ) + // Mobile fullscreen wrapper - like React Native Modal with animationType="slide" + // Use portal to render directly in body, avoiding transform/filter issues with fixed positioning + const MobileWrapper = ({ children }: { children: React.ReactNode }) => { + const content = ( + <> + {/* Backdrop */} +
+ {/* Modal - fullscreen overlay */} +
+ {/* Close button */} + + {children} +
+ + ) + + if (typeof document !== 'undefined') { + return createPortal(content, document.body) + } + return content + } + + const Wrapper = isMobile ? MobileWrapper : DesktopWrapper + + // Loading if (isLoading) { return ( -
-
e.stopPropagation()}> -
-
-
-
-
+ +
+
-
+
) } + // Error 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 + if (showAppSelector) { + const platformNames: Record = { + ios: 'iOS', + android: 'Android', + windows: 'Windows', + macos: 'macOS', + linux: 'Linux', + androidTV: 'Android TV', + appleTV: 'Apple TV' + } + return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
-

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

- -
+ + {/* Header */} +
+ +

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

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

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

+ {/* Apps grouped by platform */} +
+ {availablePlatforms.map(platform => { + const apps = appConfig.platforms[platform] + if (!apps?.length) return null + const isCurrentPlatform = platform === detectedPlatform -
- {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)} -

-
- + // Main view + return ( + +
+

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

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

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

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

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

-
- ) : ( -
- {platformApps.map((app) => ( - + + {getLocalizedText(btn.buttonText)} + ))}
)}
-
-
- ) - } + )} - // Step 3: Show app instructions and connect button - return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
-
- -

- {selectedApp.name} -

-
- -
+ {/* Step 2 */} + {selectedApp?.addSubscriptionStep && ( +
+
+
2
+

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

+
+

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

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

- {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)} - - ))} -
- )} -
- )} - - {/* 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')} -

-

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

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

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

- )} -

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

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

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

-

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

-
- )} + )} + +
-
+ )} - {/* Footer - fixed with safe area */} -
- -
+ {/* Step 3 */} + {selectedApp?.connectAndUseStep && ( +
+
+
3
+

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

+
+

{getLocalizedText(selectedApp.connectAndUseStep.description)}

+
+ )}
-
+ + ) } diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index f921a43..9ed2ed5 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -145,10 +145,10 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod const { formatAmount, currencySymbol } = useCurrency() return ( -
+
-
+
{/* Header */}
{t('balance.selectPaymentMethod')} diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 698a586..47ad820 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -41,13 +41,13 @@ export default function LanguageSwitcher() {
+ ) +} + +function HSLSlider({ + label, + value, + onChange, + max, + gradient, + suffix = '', +}: { + label: string + value: number + onChange: (value: number) => void + max: number + gradient: string + suffix?: string +}) { + return ( +
+
+ + + {value} + {suffix} + +
+ onChange(parseInt(e.target.value))} + className="w-full h-2.5 rounded-full appearance-none cursor-pointer" + style={{ background: gradient }} + /> +
+ ) +} + +function CompactColorInput({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (color: string) => void +}) { + const [localValue, setLocalValue] = useState(value) + const [isEditing, setIsEditing] = useState(false) + + useEffect(() => { + setLocalValue(value) + }, [value]) + + const handleChange = (newValue: string) => { + let formatted = newValue.toUpperCase() + if (!formatted.startsWith('#')) { + formatted = '#' + formatted + } + setLocalValue(formatted) + if (isValidHex(formatted)) { + onChange(formatted) + } + } + + const handleBlur = () => { + setIsEditing(false) + if (!isValidHex(localValue)) { + setLocalValue(value) + } + } + + return ( +
+ + )} +
+
+ ) +} + +function CollapsibleSection({ + title, + icon, + isOpen, + onToggle, + children, + badge, +}: { + title: string + icon: React.ReactNode + isOpen: boolean + onToggle: () => void + children: React.ReactNode + badge?: string +}) { + return ( +
+ + +
+
+
{children}
+
+
+
+ ) +} + +export function ThemeBentoPicker({ + currentColors, + onColorsChange, + onSave, + isSaving, +}: ThemeBentoPickerProps) { + const { t } = useTranslation() + + const [hsl, setHsl] = useState(() => hexToHsl(currentColors.accent)) + const [hexInput, setHexInput] = useState(currentColors.accent) + const [hasChanges, setHasChanges] = useState(false) + + const [isPresetsOpen, setIsPresetsOpen] = useState(false) + const [isAccentOpen, setIsAccentOpen] = useState(false) + const [isDarkOpen, setIsDarkOpen] = useState(false) + const [isLightOpen, setIsLightOpen] = useState(false) + const [isStatusOpen, setIsStatusOpen] = useState(false) + + const selectedPresetId = useMemo(() => { + const match = COLOR_PRESETS.find( + (p) => + p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() && + p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() && + p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase() + ) + return match?.id ?? null + }, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]) + + useEffect(() => { + setHsl(hexToHsl(currentColors.accent)) + setHexInput(currentColors.accent) + }, [currentColors.accent]) + + const updateColor = useCallback( + (key: keyof ThemeColors, value: string) => { + const newColors = { ...currentColors, [key]: value } + onColorsChange(newColors) + applyThemeColors(newColors) + setHasChanges(true) + }, + [currentColors, onColorsChange] + ) + + const updateAccentFromHsl = useCallback( + (newHsl: HSLColor) => { + setHsl(newHsl) + const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l) + setHexInput(newHex) + updateColor('accent', newHex) + }, + [updateColor] + ) + + const handleHexInputChange = (value: string) => { + setHexInput(value) + if (isValidHex(value)) { + const newHsl = hexToHsl(value) + setHsl(newHsl) + updateColor('accent', value) + } + } + + const handlePresetSelect = (preset: ColorPreset) => { + onColorsChange(preset.colors) + applyThemeColors(preset.colors) + setHasChanges(true) + } + + const hueGradient = useMemo(() => { + return `linear-gradient(to right, + hsl(0, ${hsl.s}%, ${hsl.l}%), + hsl(60, ${hsl.s}%, ${hsl.l}%), + hsl(120, ${hsl.s}%, ${hsl.l}%), + hsl(180, ${hsl.s}%, ${hsl.l}%), + hsl(240, ${hsl.s}%, ${hsl.l}%), + hsl(300, ${hsl.s}%, ${hsl.l}%), + hsl(360, ${hsl.s}%, ${hsl.l}%) + )` + }, [hsl.s, hsl.l]) + + const saturationGradient = useMemo(() => { + return `linear-gradient(to right, + hsl(${hsl.h}, 0%, ${hsl.l}%), + hsl(${hsl.h}, 100%, ${hsl.l}%) + )` + }, [hsl.h, hsl.l]) + + const lightnessGradient = useMemo(() => { + return `linear-gradient(to right, + hsl(${hsl.h}, ${hsl.s}%, 0%), + hsl(${hsl.h}, ${hsl.s}%, 50%), + hsl(${hsl.h}, ${hsl.s}%, 100%) + )` + }, [hsl.h, hsl.s]) + + return ( +
+ } + badge={`${COLOR_PRESETS.length}`} + isOpen={isPresetsOpen} + onToggle={() => setIsPresetsOpen(!isPresetsOpen)} + > +
+ {COLOR_PRESETS.map((preset, index) => ( +
+ handlePresetSelect(preset)} + /> +
+ ))} +
+
+ +
+

+ {t('admin.theme.customizeColors', 'Customize Colors')} +

+ + } + badge={hexInput.toUpperCase()} + isOpen={isAccentOpen} + onToggle={() => setIsAccentOpen(!isAccentOpen)} + > +
+
+
+
+ {hexInput.toUpperCase()} +
+
+ + updateAccentFromHsl({ ...hsl, h })} + max={360} + gradient={hueGradient} + suffix="°" + /> + + updateAccentFromHsl({ ...hsl, s })} + max={100} + gradient={saturationGradient} + suffix="%" + /> + + updateAccentFromHsl({ ...hsl, l })} + max={100} + gradient={lightnessGradient} + suffix="%" + /> + +
+ + handleHexInputChange(e.target.value)} + placeholder="#3b82f6" + maxLength={7} + className="input w-full text-sm font-mono uppercase" + /> +
+
+ + + } + isOpen={isDarkOpen} + onToggle={() => setIsDarkOpen(!isDarkOpen)} + > +
+ updateColor('darkBackground', c)} + /> + updateColor('darkSurface', c)} + /> + updateColor('darkText', c)} + /> + updateColor('darkTextSecondary', c)} + /> +
+
+ + } + isOpen={isLightOpen} + onToggle={() => setIsLightOpen(!isLightOpen)} + > +
+ updateColor('lightBackground', c)} + /> + updateColor('lightSurface', c)} + /> + updateColor('lightText', c)} + /> + updateColor('lightTextSecondary', c)} + /> +
+
+ + } + isOpen={isStatusOpen} + onToggle={() => setIsStatusOpen(!isStatusOpen)} + > +
+ updateColor('success', c)} + /> + updateColor('warning', c)} + /> + updateColor('error', c)} + /> +
+
+
+ +
+

+ {t('theme.preview', 'Preview')} +

+
+ + + {t('theme.success', 'Success')} + {t('theme.warning', 'Warning')} + {t('theme.error', 'Error')} +
+
+ + {hasChanges && ( +
+ +
+ )} +
+ ) +} diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index 57f9f61..f270530 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -198,7 +198,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi {/* Bell button */}
-
- {/* Payment options */} - {hasOptions && method.options && ( -
- {method.options.map((opt) => ( - - ))} -
- )} - - {/* 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} - -
- - {/* Quick amounts */} +
{quickAmounts.length > 0 && ( -
+
{quickAmounts.map((a) => { const val = getQuickValue(a) + const isSelected = amount === val + return ( - + + {formatAmount(a, 0)} + + + {currencySymbol} + + ) })}
)} - {/* Error */} - {error && ( -
{error}
+
+
+ + + {formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)} {currencySymbol} + +
+ +
+
+
+ setAmount(e.target.value)} + placeholder="0" + className="w-full h-14 pl-5 pr-12 text-2xl font-bold bg-transparent text-dark-50 placeholder:text-dark-600 focus:outline-none" + autoComplete="off" + /> +
+ + {currencySymbol} + +
+
+
+
+ + {hasOptions && method.options && ( +
+ +
+ {method.options.map((opt) => { + const isSelected = selectedOption === opt.id + return ( + + ) + })} +
+
+ )} + + {error && ( +
+
+ + + +
+

{error}

+
)} - {/* Submit */}
) + + 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 0c82aee..0b7fdd6 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,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]) @@ -299,21 +327,45 @@ 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 */} - + {logoLetter} {/* Logo image with smooth fade-in */} @@ -371,28 +423,18 @@ 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 +485,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 */}
@@ -572,6 +638,7 @@ export default function Layout({ children }: LayoutProps) { setMobileMenuOpen(false)} className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'} > diff --git a/src/components/ui/BentoCard.tsx b/src/components/ui/BentoCard.tsx new file mode 100644 index 0000000..3f9d544 --- /dev/null +++ b/src/components/ui/BentoCard.tsx @@ -0,0 +1,115 @@ +import { Link } from 'react-router-dom' +import { forwardRef } from 'react' + +export type BentoSize = 'sm' | 'md' | 'lg' | 'xl' + +interface BentoCardBaseProps { + size?: BentoSize + children: React.ReactNode + className?: string + hover?: boolean + glow?: boolean +} + +interface BentoCardDivProps extends BentoCardBaseProps { + as?: 'div' + onClick?: () => void +} + +interface BentoCardLinkProps extends BentoCardBaseProps { + as: 'link' + to: string + state?: unknown +} + +interface BentoCardButtonProps extends BentoCardBaseProps { + as: 'button' + onClick?: () => void + disabled?: boolean + type?: 'button' | 'submit' +} + +export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps + +const sizeClasses: Record = { + sm: '', + md: 'col-span-2', + lg: 'row-span-2', + xl: 'col-span-2 row-span-2', +} + +const baseClasses = ` + bento-card + rounded-[var(--bento-radius)] + p-[var(--bento-padding)] + bg-dark-900/70 + border border-dark-700/40 + transition-all duration-300 ease-smooth +` + +const hoverClasses = ` + cursor-pointer + hover:bg-dark-800/60 + hover:border-dark-600/50 + hover:shadow-lg + hover:scale-[1.01] + active:scale-[0.99] +` + +const glowClasses = ` + hover:shadow-glow + hover:border-accent-500/30 +` + +export const BentoCard = forwardRef((props, ref) => { + const { + size = 'sm', + children, + className = '', + hover = false, + glow = false, + } = props + + const classes = [ + baseClasses, + sizeClasses[size], + hover && hoverClasses, + glow && glowClasses, + className, + ].filter(Boolean).join(' ') + + if (props.as === 'link') { + const { to, state } = props as BentoCardLinkProps + return ( + + {children} + + ) + } + + if (props.as === 'button') { + const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps + return ( + + ) + } + + const { onClick } = props as BentoCardDivProps + return ( +
+ {children} +
+ ) +}) + +BentoCard.displayName = 'BentoCard' + +export default BentoCard diff --git a/src/components/ui/BentoSkeleton.tsx b/src/components/ui/BentoSkeleton.tsx new file mode 100644 index 0000000..d7e47a0 --- /dev/null +++ b/src/components/ui/BentoSkeleton.tsx @@ -0,0 +1,28 @@ +interface BentoSkeletonProps { + className?: string + count?: number +} + +export default function BentoSkeleton({ className = '', count = 1 }: BentoSkeletonProps) { + const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}` + + if (count > 1) { + return ( + <> + {Array.from({ length: count }).map((_, i) => ( +
+ ))} + + ) + } + + return ( +
+ ) +} diff --git a/src/data/colorPresets.ts b/src/data/colorPresets.ts new file mode 100644 index 0000000..0cee321 --- /dev/null +++ b/src/data/colorPresets.ts @@ -0,0 +1,282 @@ +import { ThemeColors } from '../types/theme' + +export interface ColorPreset { + id: string + name: string + nameRu: string + description: string + descriptionRu: string + colors: ThemeColors + preview: { + background: string + accent: string + text: string + } +} + +export const COLOR_PRESETS: ColorPreset[] = [ + { + id: 'electric-blue', + name: 'Electric Blue', + nameRu: 'Электрик', + description: 'Classic tech blue, reliable and clean', + descriptionRu: 'Классический технологичный синий', + colors: { + accent: '#3b82f6', + darkBackground: '#0a0f1a', + darkSurface: '#0f172a', + darkText: '#f1f5f9', + darkTextSecondary: '#94a3b8', + lightBackground: '#f1f5f9', + lightSurface: '#ffffff', + lightText: '#0f172a', + lightTextSecondary: '#475569', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0a0f1a', + accent: '#3b82f6', + text: '#f1f5f9', + }, + }, + { + id: 'toxic-neon', + name: 'Toxic Neon', + nameRu: 'Токсичный неон', + description: 'Cyberpunk vibes, high energy', + descriptionRu: 'Киберпанк атмосфера, высокая энергия', + colors: { + accent: '#22c55e', + darkBackground: '#030712', + darkSurface: '#0a0f14', + darkText: '#e2e8f0', + darkTextSecondary: '#64748b', + lightBackground: '#f0fdf4', + lightSurface: '#ffffff', + lightText: '#14532d', + lightTextSecondary: '#166534', + success: '#22c55e', + warning: '#eab308', + error: '#ef4444', + }, + preview: { + background: '#030712', + accent: '#22c55e', + text: '#e2e8f0', + }, + }, + { + id: 'royal-purple', + name: 'Royal Purple', + nameRu: 'Королевский пурпур', + description: 'Premium, sophisticated, Stripe-like', + descriptionRu: 'Премиальный, утончённый, как Stripe', + colors: { + accent: '#8b5cf6', + darkBackground: '#0c0a14', + darkSurface: '#13111c', + darkText: '#f1f0f5', + darkTextSecondary: '#a1a1aa', + lightBackground: '#faf5ff', + lightSurface: '#ffffff', + lightText: '#3b0764', + lightTextSecondary: '#581c87', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0c0a14', + accent: '#8b5cf6', + text: '#f1f0f5', + }, + }, + { + id: 'sunset-orange', + name: 'Sunset Orange', + nameRu: 'Закатный оранж', + description: 'Warm, energetic, action-oriented', + descriptionRu: 'Тёплый, энергичный, призыв к действию', + colors: { + accent: '#f97316', + darkBackground: '#0f0906', + darkSurface: '#1a120d', + darkText: '#fef3e2', + darkTextSecondary: '#a3a3a3', + lightBackground: '#fff7ed', + lightSurface: '#ffffff', + lightText: '#7c2d12', + lightTextSecondary: '#9a3412', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0f0906', + accent: '#f97316', + text: '#fef3e2', + }, + }, + { + id: 'ocean-teal', + name: 'Ocean Teal', + nameRu: 'Океанский бирюзовый', + description: 'Calm, trustworthy, health-tech', + descriptionRu: 'Спокойный, надёжный, медтех', + colors: { + accent: '#14b8a6', + darkBackground: '#042f2e', + darkSurface: '#0d3d3b', + darkText: '#f0fdfa', + darkTextSecondary: '#5eead4', + lightBackground: '#f0fdfa', + lightSurface: '#ffffff', + lightText: '#134e4a', + lightTextSecondary: '#115e59', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#042f2e', + accent: '#14b8a6', + text: '#f0fdfa', + }, + }, + { + id: 'champagne-gold', + name: 'Champagne Gold', + nameRu: 'Шампанское золото', + description: 'Luxury, premium, elegant', + descriptionRu: 'Роскошный, премиальный, элегантный', + colors: { + accent: '#b8860b', + darkBackground: '#0a0f1a', + darkSurface: '#0f172a', + darkText: '#f1f5f9', + darkTextSecondary: '#94a3b8', + lightBackground: '#fefce8', + lightSurface: '#ffffff', + lightText: '#713f12', + lightTextSecondary: '#92400e', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0a0f1a', + accent: '#b8860b', + text: '#f1f5f9', + }, + }, + { + id: 'quantum-teal', + name: 'Quantum Teal', + nameRu: 'Квантовый бирюзовый', + description: 'Bio-synthetic, futuristic fintech', + descriptionRu: 'Био-синтетический, футуристичный финтех', + colors: { + accent: '#0d9488', + darkBackground: '#0f172a', + darkSurface: '#1e293b', + darkText: '#f1f5f9', + darkTextSecondary: '#94a3b8', + lightBackground: '#f0fdfa', + lightSurface: '#ffffff', + lightText: '#134e4a', + lightTextSecondary: '#0f766e', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0f172a', + accent: '#0d9488', + text: '#f1f5f9', + }, + }, + { + id: 'cosmic-violet', + name: 'Cosmic Violet', + nameRu: 'Космический фиолет', + description: 'Digital lavender, wellness vibes', + descriptionRu: 'Цифровая лаванда, атмосфера спокойствия', + colors: { + accent: '#7c3aed', + darkBackground: '#0b0d10', + darkSurface: '#18181b', + darkText: '#f4f4f5', + darkTextSecondary: '#a1a1aa', + lightBackground: '#faf5ff', + lightSurface: '#ffffff', + lightText: '#3b0764', + lightTextSecondary: '#5b21b6', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0b0d10', + accent: '#7c3aed', + text: '#f4f4f5', + }, + }, + { + id: 'solar-coral', + name: 'Solar Coral', + nameRu: 'Солнечный коралл', + description: 'Hyper-coral, high energy social', + descriptionRu: 'Гипер-коралловый, энергия соцсетей', + colors: { + accent: '#ea580c', + darkBackground: '#18181b', + darkSurface: '#27272a', + darkText: '#fafafa', + darkTextSecondary: '#a1a1aa', + lightBackground: '#fff7ed', + lightSurface: '#ffffff', + lightText: '#7c2d12', + lightTextSecondary: '#9a3412', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#18181b', + accent: '#ea580c', + text: '#fafafa', + }, + }, + { + id: 'frost-blue', + name: 'Frost Blue', + nameRu: 'Морозный синий', + description: 'Liquid chrome, enterprise trust', + descriptionRu: 'Жидкий хром, корпоративное доверие', + colors: { + accent: '#0284c7', + darkBackground: '#0b0d10', + darkSurface: '#1e293b', + darkText: '#f1f5f9', + darkTextSecondary: '#94a3b8', + lightBackground: '#f0f9ff', + lightSurface: '#ffffff', + lightText: '#0c4a6e', + lightTextSecondary: '#075985', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0b0d10', + accent: '#0284c7', + text: '#f1f5f9', + }, + }, +] + +export function getPresetById(id: string): ColorPreset | undefined { + return COLOR_PRESETS.find((preset) => preset.id === id) +} 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..4fc5b03 100644 --- a/src/hooks/useTelegramWebApp.ts +++ b/src/hooks/useTelegramWebApp.ts @@ -1,17 +1,38 @@ 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 */ 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) @@ -123,5 +144,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..a2501e5 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", @@ -184,7 +185,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", @@ -1312,5 +1315,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..2c14f63 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -794,5 +794,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 2791432..847c0b2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -33,7 +33,8 @@ "contests": "Конкурсы", "polls": "Опросы", "info": "Информация", - "wheel": "Колесо удачи" + "wheel": "Колесо удачи", + "menu": "Меню" }, "notifications": { "ticketNotifications": "Уведомления о тикетах", @@ -184,7 +185,9 @@ "yourDevice": "Ваше устройство", "copyLink": "Скопировать ссылку", "copied": "Ссылка скопирована!", - "openLink": "Открыть ссылку" + "openLink": "Открыть ссылку", + "instructions": "Инструкция", + "changeApp": "Сменить приложение" }, "myDevices": "Мои устройства", "noDevices": "Нет подключенных устройств", @@ -1470,5 +1473,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..afdaf2c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -31,7 +31,8 @@ "contests": "活动", "polls": "问卷", "info": "信息", - "wheel": "幸运转盘" + "wheel": "幸运转盘", + "menu": "菜单" }, "notifications": { "ticketNotifications": "工单通知", @@ -794,5 +795,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/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 6080066..cbe668b 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -5,9 +5,10 @@ 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' +import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES, ThemeColors } from '../types/theme' +import { ThemeBentoPicker } from '../components/ThemeBentoPicker' import { applyThemeColors } from '../hooks/useThemeColors' import { updateEnabledThemesCache } from '../hooks/useTheme' @@ -857,6 +858,7 @@ export default function AdminSettings() { const [searchQuery, setSearchQuery] = useState('') const [editingName, setEditingName] = useState(false) const [newName, setNewName] = useState('') + const [localColors, setLocalColors] = useState(null) const fileInputRef = useRef(null) // Branding query and mutations @@ -880,6 +882,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 +1246,29 @@ export default function AdminSettings() {
+ + {/* Fullscreen Toggle */} +
+
+
+

Авто-Fullscreen

+

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

+
+ +
+
{/* Theme Colors Card */} @@ -1316,113 +1356,18 @@ export default function AdminSettings() { Включите нужные темы для пользователей. Минимум одна тема должна быть активна.

- {/* Accent Color */} - updateColorsMutation.mutate({ accent: color })} - disabled={updateColorsMutation.isPending} + + setLocalColors(colors)} + onSave={() => { + if (localColors) { + updateColorsMutation.mutate(localColors) + setLocalColors(null) + } + }} + isSaving={updateColorsMutation.isPending} /> - - {/* Dark Theme Section */} -
-

{t('theme.darkTheme')}

-
- updateColorsMutation.mutate({ darkBackground: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkSurface: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkText: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkTextSecondary: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Light Theme Section */} -
-

{t('theme.lightTheme')}

-
- updateColorsMutation.mutate({ lightBackground: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightSurface: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightText: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightTextSecondary: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Status Colors */} -
-

{t('theme.statusColors')}

-
- updateColorsMutation.mutate({ success: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ warning: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ error: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Preview */} -
-

{t('theme.preview')}

-
- - - {t('theme.success')} - {t('theme.warning')} - {t('theme.error')} -
-
diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index 4f3b5ee..6e0c10e 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -32,6 +32,7 @@ export default function Balance() { const [promocodeSuccess, setPromocodeSuccess] = useState<{ message: string; amount: number } | null>(null) const [transactionsPage, setTransactionsPage] = useState(1) + const [isHistoryOpen, setIsHistoryOpen] = useState(false) const { data: transactions, isLoading } = useQuery>({ queryKey: ['transactions', transactionsPage], @@ -122,7 +123,7 @@ export default function Balance() {

{t('balance.title')}

{/* Balance Card */} -
+
{t('balance.currentBalance')}
{formatAmount(balanceData?.balance_rubles || 0)} @@ -131,7 +132,7 @@ export default function Balance() {
{/* Promo Code Section */} -
+

{t('balance.promocode.title')}

0 && ( -
+

{t('balance.topUpBalance')}

{paymentMethods.map((method) => { @@ -181,10 +182,10 @@ export default function Balance() { key={method.id} disabled={!method.is_available} onClick={() => method.is_available && setSelectedMethod(method)} - className={`p-4 rounded-xl border text-left transition-all ${ + className={`bento-card-hover p-4 text-left transition-all ${ method.is_available - ? 'border-dark-700/50 hover:border-accent-500/50 bg-dark-800/30 cursor-pointer' - : 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed' + ? 'cursor-pointer' + : 'opacity-50 cursor-not-allowed' }`} >
{translatedName || method.name}
@@ -201,88 +202,104 @@ export default function Balance() {
)} - {/* Transaction History */} -
-

{t('balance.transactionHistory')}

+
+ - {isLoading ? ( -
-
-
- ) : transactions?.items && transactions.items.length > 0 ? ( -
- {transactions.items.map((tx) => { - // API returns negative values for debits, positive for credits - const isPositive = tx.amount_rubles >= 0 - const displayAmount = Math.abs(tx.amount_rubles) - const sign = isPositive ? '+' : '-' - const colorClass = isPositive ? 'text-success-400' : 'text-error-400' - - return ( -
-
-
- - {getTypeLabel(tx.type)} - - - {new Date(tx.created_at).toLocaleDateString()} - -
- {tx.description && ( -
{tx.description}
- )} -
-
- {sign}{formatAmount(displayAmount)} {currencySymbol} -
+ {isHistoryOpen && ( +
+ {isLoading ? ( +
+
- ) - })} -
- ) : ( -
-
- - - -
-
{t('balance.noTransactions')}
-
- )} + ) : transactions?.items && transactions.items.length > 0 ? ( +
+ {transactions.items.map((tx) => { + const isPositive = tx.amount_rubles >= 0 + const displayAmount = Math.abs(tx.amount_rubles) + const sign = isPositive ? '+' : '-' + const colorClass = isPositive ? 'text-success-400' : 'text-error-400' - {transactions && transactions.pages > 1 && ( -
- -
- {t('balance.page', { current: transactions.page, total: transactions.pages })} -
- + return ( +
+
+
+ + {getTypeLabel(tx.type)} + + + {new Date(tx.created_at).toLocaleDateString()} + +
+ {tx.description && ( +
{tx.description}
+ )} +
+
+ {sign}{formatAmount(displayAmount)} {currencySymbol} +
+
+ ) + })} +
+ ) : ( +
+
+ + + +
+
{t('balance.noTransactions')}
+
+ )} + + {transactions && transactions.pages > 1 && ( +
+ +
+ {t('balance.page', { current: transactions.page, total: transactions.pages })} +
+ +
+ )}
)}
diff --git a/src/pages/Contests.tsx b/src/pages/Contests.tsx index c0b8790..34fc3ac 100644 --- a/src/pages/Contests.tsx +++ b/src/pages/Contests.tsx @@ -86,8 +86,8 @@ export default function Contests() { {/* Game Modal */} {selectedContest && ( -
-
+
+
e.stopPropagation()}>

{selectedContest.name}

+
- {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)}% +
@@ -277,9 +336,9 @@ export default function Dashboard() { )} {/* Stats Grid */} -
+
{/* Balance */} - +
{t('balance.currentBalance')} @@ -293,7 +352,7 @@ export default function Dashboard() { {/* Subscription */} - +
{t('subscription.title')} @@ -329,7 +388,7 @@ export default function Dashboard() { {/* Referrals */} - +
{t('referral.stats.totalReferrals')} @@ -344,7 +403,7 @@ export default function Dashboard() { {/* Earnings */} - +
{t('referral.stats.totalEarnings')} @@ -363,7 +422,7 @@ export default function Dashboard() { {/* Trial Activation */} {hasNoSubscription && !trialLoading && trialInfo?.is_available && ( -
+
@@ -424,7 +483,7 @@ export default function Dashboard() { {wheelConfig?.is_enabled && (
{/* Emoji */} @@ -445,7 +504,7 @@ export default function Dashboard() { )} {/* Quick Actions */} -
+

{t('dashboard.quickActions')}

diff --git a/src/pages/Info.tsx b/src/pages/Info.tsx index 5e246ea..1ba1e16 100644 --- a/src/pages/Info.tsx +++ b/src/pages/Info.tsx @@ -166,7 +166,7 @@ export default function Info() { return (
{faqPages.map((faq: FaqPage) => ( -
+
+ ) : ( +
+
+

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

+ +
+ + {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)} +
+ )} +
+ ) : ( +
+ Нет доступных серверов для управления +
+ )} +
+ )} +
+ )}
)} {/* My Devices Section */} {subscription && ( -
+

{t('subscription.myDevices')}

{devicesData && devicesData.devices.length > 0 && ( @@ -987,7 +1220,7 @@ export default function Subscription() { {/* Tariffs Section - Combined Purchase/Extend/Switch like MiniApp */} {isTariffsMode && tariffs.length > 0 && ( -
+

{subscription?.is_daily && !subscription?.is_trial @@ -1142,10 +1375,10 @@ export default function Subscription() { return (
@@ -1689,7 +1922,7 @@ export default function Subscription() { {/* Purchase/Extend Section - Classic Mode */} {classicOptions && classicOptions.periods.length > 0 && ( -
+

{subscription && !subscription.is_trial ? t('subscription.extend') : t('subscription.getSubscription')} @@ -1751,10 +1984,10 @@ export default function Subscription() { setSelectedDevices(period.devices.current) } }} - className={`p-4 rounded-xl border text-left transition-all relative ${ + className={`bento-card-hover p-4 text-left transition-all relative ${ selectedPeriod?.id === period.id - ? 'border-accent-500 bg-accent-500/10' - : 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30' + ? 'bento-card-glow border-accent-500' + : '' }`} > {displayDiscount && displayDiscount > 0 && ( @@ -1789,13 +2022,11 @@ export default function Subscription() { key={option.value} onClick={() => setSelectedTraffic(option.value)} disabled={!option.is_available} - className={`p-4 rounded-xl border text-center transition-all relative ${ + className={`bento-card-hover p-4 text-center transition-all relative ${ selectedTraffic === option.value - ? 'border-accent-500 bg-accent-500/10' - : option.is_available - ? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30' - : 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed' - }`} + ? 'bento-card-glow border-accent-500' + : '' + } ${!option.is_available ? 'opacity-50 cursor-not-allowed' : ''}`} > {promoTraffic.percent && (
diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 0e248e5..ba6099d 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -385,7 +385,7 @@ export default function Support() { return (
-
+
@@ -454,7 +454,7 @@ export default function Support() {
{/* Tickets List */} -
+

{t('support.yourTickets')}

{isLoading ? ( @@ -471,7 +471,7 @@ export default function Support() { setShowCreateForm(false) setReplyAttachment(null) }} - className={`w-full text-left p-4 rounded-xl border transition-all ${ + className={`w-full text-left p-4 rounded-bento border transition-all ${ selectedTicket?.id === ticket.id ? 'border-accent-500 bg-accent-500/10' : 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30' @@ -502,7 +502,7 @@ export default function Support() {
{/* Ticket Detail / Create Form */} -
+
{showCreateForm ? (

{t('support.createTicket')}

diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 9bb871d..bcc66c6 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -692,7 +692,7 @@ export default function Wheel() { {/* Result Modal */} {showResultModal && spinResult && ( -
+
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..4f14a5d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap'); @tailwind base; @tailwind components; @@ -8,6 +8,14 @@ :root { --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); + /* Bento Design System */ + --bento-radius: 24px; + --bento-radius-lg: 32px; + --bento-gap: 16px; + --bento-gap-lg: 24px; + --bento-padding: 24px; + --bento-padding-lg: 32px; + /* Theme colors - RGB format for opacity support */ /* Dark palette (RGB values) */ --color-dark-50: 248, 250, 252; @@ -102,6 +110,34 @@ html { overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: rgb(var(--color-dark-700)) transparent; + } + + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: rgb(var(--color-dark-700)); + border-radius: 3px; + } + + ::-webkit-scrollbar-thumb:hover { + background: rgb(var(--color-dark-600)); + } + + .light ::-webkit-scrollbar-thumb { + background: rgb(var(--color-champagne-400)); + } + + .light ::-webkit-scrollbar-thumb:hover { + background: rgb(var(--color-champagne-500)); } /* Smooth scroll only on desktop - mobile uses native */ @@ -117,6 +153,19 @@ overscroll-behavior-y: contain; /* iOS smooth scrolling */ -webkit-overflow-scrolling: touch; + position: relative; + } + + /* Global Noise Texture */ + body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); + opacity: 0.03; + mix-blend-mode: overlay; } /* Optimize main scrollable content */ @@ -203,6 +252,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 */ } @@ -323,6 +376,135 @@ } @layer components { + /* ========== BENTO DESIGN SYSTEM ========== */ + + .bento-card { + @apply bg-dark-900/70 border border-dark-700/40; + border-radius: var(--bento-radius); + padding: var(--bento-padding); + transform: translateZ(0); + /* Glass Border - Inset Highlight */ + box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05); + /* Stagger animation support */ + animation: bentoFadeIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-delay: calc(var(--stagger, 0) * 50ms); + position: relative; + overflow: hidden; + } + + @media (min-width: 1024px) { + .bento-card { + @apply bg-dark-900/50 backdrop-blur-sm; + } + } + + /* Disable animation if user prefers reduced motion */ + @media (prefers-reduced-motion: reduce) { + .bento-card { + animation: none; + } + } + + .bento-card-hover { + @apply bento-card cursor-pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .bento-card-hover:hover { + @apply bg-dark-800/60 border-dark-600/50 shadow-lg; + transform: translateY(-4px); + /* Intensify Glass Border & Add Top Spotlight */ + box-shadow: + inset 0 1px 0 0 rgba(255, 255, 255, 0.1), + 0 10px 40px -10px rgba(0,0,0,0.5); + } + + /* CSS Spotlight Effect via pseudo-element */ + .bento-card-hover::after { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + z-index: 10; + } + + .bento-card-hover:hover::after { + opacity: 1; + } + + .bento-card-hover:active { + transform: scale(0.98); + transition-duration: 0.15s; + } + + .bento-card-glow { + @apply bento-card cursor-pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .bento-card-glow:hover { + @apply shadow-glow border-accent-500/30; + transform: translateY(-4px); + } + + .bento-card-glow:active { + transform: scale(0.98); + transition-duration: 0.15s; + } + + .bento-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--bento-gap); + } + + @media (min-width: 375px) { + .bento-grid { + grid-template-columns: repeat(2, 1fr); + } + } + + @media (min-width: 768px) { + .bento-grid { + grid-template-columns: repeat(4, 1fr); + gap: var(--bento-gap-lg); + } + } + + .light .bento-card { + @apply bg-white/90 border-champagne-300/50 shadow-sm; + /* Light Theme Glass Border */ + box-shadow: inset 0 1px 0 0 rgba(0, 0, 0, 0.03); + } + + @media (min-width: 1024px) { + .light .bento-card { + @apply bg-white/80 backdrop-blur-sm; + } + } + + .light .bento-card-hover:hover { + @apply bg-white border-champagne-400/50 shadow-md; + transform: translateY(-4px); + /* Intensify Light Theme Glass Border */ + box-shadow: + inset 0 1px 0 0 rgba(0, 0, 0, 0.06), + 0 10px 40px -10px rgba(160, 139, 94, 0.15); + } + + /* Adjust spotlight for light theme to be a warm glow */ + .light .bento-card-hover::after { + background: radial-gradient(circle at top, rgba(255, 253, 249, 0.8), transparent 70%); + } + + .light .bento-card-glow:hover { + @apply shadow-lg border-accent-400/40; + transform: translateY(-4px); + } + /* ========== DARK THEME COMPONENTS (default) ========== */ /* Cards - Dark (optimized for mobile) */ @@ -489,12 +671,17 @@ @apply nav-item text-accent-400 bg-accent-500/10; } - /* Bottom nav - Dark (optimized for mobile) */ .bottom-nav { - @apply fixed bottom-0 left-0 right-0 z-50 - bg-dark-900/95 border-t border-dark-800/50 - safe-area-pb; + @apply fixed z-50 bg-dark-900/95; + bottom: 16px; + left: 16px; + right: 16px; + border-radius: var(--bento-radius); + padding: 8px 4px; + padding-bottom: calc(8px + var(--safe-area-inset-bottom)); transform: translateZ(0); + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.05) inset; } @media (min-width: 1024px) { @@ -504,12 +691,17 @@ } .bottom-nav-item { - @apply flex flex-col items-center justify-center py-2 px-2 flex-1 min-w-[60px] - text-dark-500 transition-colors duration-200 shrink-0; + @apply flex flex-col items-center justify-center py-2.5 px-3 flex-1 min-w-[56px] + text-dark-500 transition-all duration-200 shrink-0 rounded-2xl; + } + + .bottom-nav-item:hover { + @apply text-dark-300; } .bottom-nav-item-active { - @apply bottom-nav-item text-accent-400; + @apply flex flex-col items-center justify-center py-2.5 px-3 flex-1 min-w-[56px] + text-accent-400 bg-accent-500/15 rounded-2xl transition-all duration-200 shrink-0; } /* Divider - Dark */ @@ -664,17 +856,28 @@ @apply text-champagne-800 bg-champagne-200/70; } - /* Bottom nav - Light */ .light .bottom-nav { - @apply bg-white/90 backdrop-blur-xl border-t border-champagne-200; + @apply bg-white/95; + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(0, 0, 0, 0.05); + } + + @media (min-width: 1024px) { + .light .bottom-nav { + @apply bg-white/80 backdrop-blur-xl; + } } .light .bottom-nav-item { @apply text-champagne-500; } + .light .bottom-nav-item:hover { + @apply text-champagne-700; + } + .light .bottom-nav-item-active { - @apply text-champagne-800; + @apply text-champagne-800 bg-champagne-300/40; } /* Divider - Light */ @@ -920,6 +1123,17 @@ } /* Animations */ +@keyframes bentoFadeIn { + 0% { + opacity: 0; + transform: translateY(16px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } @@ -1246,6 +1460,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 { diff --git a/src/types/index.ts b/src/types/index.ts index 480fd88..2154f86 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -65,6 +65,7 @@ export interface Subscription { autopay_enabled: boolean autopay_days_before: number subscription_url: string | null + hide_subscription_link: boolean is_active: boolean is_expired: boolean traffic_purchases?: TrafficPurchase[] diff --git a/src/utils/colorConversion.ts b/src/utils/colorConversion.ts new file mode 100644 index 0000000..acb39fb --- /dev/null +++ b/src/utils/colorConversion.ts @@ -0,0 +1,102 @@ +export interface HSLColor { + h: number + s: number + l: number +} + +export interface RGBColor { + r: number + g: number + b: number +} + +export function hexToRgb(hex: string): RGBColor { + if (hex.length === 4) { + hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3] + } + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return { r, g, b } +} + +export function rgbToHex(r: number, g: number, b: number): string { + return ( + '#' + + [r, g, b] + .map((x) => { + const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16) + return hex.length === 1 ? '0' + hex : hex + }) + .join('') + ) +} + +export function hexToHsl(hex: string): HSLColor { + const { r, g, b } = hexToRgb(hex) + const rNorm = r / 255 + const gNorm = g / 255 + const bNorm = b / 255 + + const max = Math.max(rNorm, gNorm, bNorm) + const min = Math.min(rNorm, gNorm, bNorm) + let h = 0 + let 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 rNorm: + h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6 + break + case gNorm: + h = ((bNorm - rNorm) / d + 2) / 6 + break + case bNorm: + h = ((rNorm - gNorm) / d + 4) / 6 + break + } + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + } +} + +export function hslToRgb(h: number, s: number, l: number): RGBColor { + const sNorm = s / 100 + const lNorm = l / 100 + + const a = sNorm * Math.min(lNorm, 1 - lNorm) + const f = (n: number) => { + const k = (n + h / 30) % 12 + const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) + return Math.round(255 * color) + } + + return { r: f(0), g: f(8), b: f(4) } +} + +export function hslToHex(h: number, s: number, l: number): string { + const { r, g, b } = hslToRgb(h, s, l) + return rgbToHex(r, g, b) +} + +export function isValidHex(hex: string): boolean { + return /^#[0-9A-Fa-f]{6}$/.test(hex) +} + +export function normalizeHex(hex: string): string { + if (!hex.startsWith('#')) { + hex = '#' + hex + } + if (hex.length === 4) { + hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3] + } + return hex.toLowerCase() +} diff --git a/tailwind.config.js b/tailwind.config.js index b6d30c7..e3b947b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -106,7 +106,15 @@ export default { }, }, fontFamily: { - sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + sans: ['Manrope', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + }, + borderRadius: { + 'bento': '24px', + '4xl': '32px', + }, + spacing: { + 'bento': '16px', + 'bento-lg': '24px', }, fontSize: { '2xs': ['0.625rem', { lineHeight: '0.875rem' }],