Merge pull request #12 from PEDZEO/main

pull
This commit is contained in:
PEDZEO
2026-01-20 23:41:45 +03:00
committed by GitHub
22 changed files with 2024 additions and 709 deletions

View File

@@ -2,7 +2,7 @@
<html lang="ru" class="dark"> <html lang="ru" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" href="data:," />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0a0f1a" /> <meta name="theme-color" content="#0a0f1a" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />

View File

@@ -1,8 +1,10 @@
import { lazy, Suspense } from 'react' import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/auth' import { useAuthStore } from './store/auth'
import { useBlockingStore } from './store/blocking'
import Layout from './components/layout/Layout' import Layout from './components/layout/Layout'
import PageLoader from './components/common/PageLoader' import PageLoader from './components/common/PageLoader'
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking'
import { saveReturnUrl } from './utils/token' import { saveReturnUrl } from './utils/token'
// Auth pages - load immediately (small) // Auth pages - load immediately (small)
@@ -89,9 +91,25 @@ function LazyPage({ children }: { children: React.ReactNode }) {
) )
} }
function BlockingOverlay() {
const { blockingType } = useBlockingStore()
if (blockingType === 'maintenance') {
return <MaintenanceScreen />
}
if (blockingType === 'channel_subscription') {
return <ChannelSubscriptionScreen />
}
return null
}
function App() { function App() {
return ( return (
<Routes> <>
<BlockingOverlay />
<Routes>
{/* Public routes */} {/* Public routes */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/auth/telegram/callback" element={<TelegramCallback />} /> <Route path="/auth/telegram/callback" element={<TelegramCallback />} />
@@ -316,6 +334,7 @@ function App() {
{/* Catch all */} {/* Catch all */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</>
) )
} }

View File

@@ -1,5 +1,6 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token' import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token'
import { useBlockingStore } from '../store/blocking'
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
@@ -87,12 +88,57 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
return config 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( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } 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 не сработала) // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true originalRequest._retry = true

View File

@@ -156,14 +156,19 @@ export const subscriptionApi = {
uuid: string uuid: string
name: string name: string
country_code: string | null country_code: string | null
base_price_kopeks: number
price_kopeks: number price_kopeks: number
price_per_month_kopeks: number
price_rubles: number price_rubles: number
is_available: boolean is_available: boolean
is_connected: boolean is_connected: boolean
is_trial_eligible: boolean has_discount: boolean
discount_percent: number
}> }>
connected_count: number connected_count: number
has_subscription: boolean has_subscription: boolean
days_left: number
discount_percent: number
}> => { }> => {
const response = await apiClient.get('/cabinet/subscription/countries') const response = await apiClient.get('/cabinet/subscription/countries')
return response.data return response.data
@@ -321,4 +326,24 @@ export const subscriptionApi = {
const response = await apiClient.put('/cabinet/subscription/traffic', { gb }) const response = await apiClient.put('/cabinet/subscription/traffic', { gb })
return response.data 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
},
} }

View File

@@ -4,16 +4,11 @@ import { brandingApi } from '../api/branding'
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled' const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled'
// Detect low-performance device (mobile in Telegram WebApp) // Detect if user prefers reduced motion
const isLowPerformance = (): boolean => { const isLowPerformance = (): boolean => {
// Check if running in Telegram WebApp // Only check for reduced motion preference - let animation run everywhere else
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
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
return prefersReducedMotion
return prefersReducedMotion || (isTelegramWebApp && isMobile)
} }
// Get cached value from localStorage // Get cached value from localStorage

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react' import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { createPortal } from 'react-dom'
interface ColorPickerProps { interface ColorPickerProps {
value: string value: string
@@ -8,7 +9,7 @@ interface ColorPickerProps {
disabled?: boolean disabled?: boolean
} }
// Check if running in Telegram WebApp (native color picker causes crash) // Check if running in Telegram WebApp
const isTelegramWebApp = (): boolean => { const isTelegramWebApp = (): boolean => {
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp 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('') 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 = [ const PRESET_COLORS = [
'#3b82f6', // Blue '#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
'#ef4444', // Red '#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6',
'#22c55e', // Green '#84cc16', '#f97316', '#6366f1', '#a855f7',
'#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
] ]
export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) { export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [localValue, setLocalValue] = useState(value) const [localValue, setLocalValue] = useState(value)
const [rgb, setRgb] = useState(() => hexToRgb(value)) const [hsl, setHsl] = useState(() => {
const containerRef = useRef<HTMLDivElement>(null) 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<HTMLButtonElement>(null)
const pickerRef = useRef<HTMLDivElement>(null)
const colorInputRef = useRef<HTMLInputElement>(null) const colorInputRef = useRef<HTMLInputElement>(null)
// Memoize Telegram check to avoid recalculating
const isTelegram = useMemo(() => isTelegramWebApp(), []) const isTelegram = useMemo(() => isTelegramWebApp(), [])
// Sync with external value
useEffect(() => { useEffect(() => {
setLocalValue(value) setLocalValue(value)
setRgb(hexToRgb(value)) const rgb = hexToRgb(value)
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
}, [value]) }, [value])
// Handle RGB slider change // Calculate picker position
const handleRgbChange = useCallback((channel: 'r' | 'g' | 'b', val: number) => { const updatePosition = useCallback(() => {
const newRgb = { ...rgb, [channel]: val } if (!buttonRef.current) return
setRgb(newRgb)
const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b)
setLocalValue(hex)
onChange(hex)
}, [rgb, onChange])
// Close on outside click const rect = buttonRef.current.getBoundingClientRect()
useEffect(() => { const pickerHeight = 320
function handleClickOutside(event: MouseEvent) { const pickerWidth = 280
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { const padding = 12
setIsOpen(false)
} // 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) if (left < padding) left = padding
return () => document.removeEventListener('mousedown', handleClickOutside)
setPickerPosition({
top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8,
left,
openUp
})
}, []) }, [])
const handleColorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { // 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<HTMLInputElement>) => {
updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) })
}, [hsl, updateColorFromHsl])
// Handle saturation change
const handleSaturationChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) })
}, [hsl, updateColorFromHsl])
// Handle lightness change
const handleLightnessChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) })
}, [hsl, updateColorFromHsl])
// Handle native color input
const handleColorInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newColor = e.target.value const newColor = e.target.value
setLocalValue(newColor) setLocalValue(newColor)
const rgb = hexToRgb(newColor)
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
onChange(newColor) onChange(newColor)
} }, [onChange])
const handleHexInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { // Handle hex input
const handleHexInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value let newValue = e.target.value
// Add # if not present
if (newValue && !newValue.startsWith('#')) { if (newValue && !newValue.startsWith('#')) {
newValue = '#' + newValue newValue = '#' + newValue
} }
// Validate hex format
if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) { if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) {
setLocalValue(newValue) setLocalValue(newValue)
// Only trigger onChange for valid complete hex
if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) { if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) {
const rgb = hexToRgb(newValue)
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
onChange(newValue) onChange(newValue)
} }
} }
} }, [onChange])
const handlePresetClick = (color: string) => { // Handle preset click
const handlePresetClick = useCallback((color: string) => {
setLocalValue(color) setLocalValue(color)
const rgb = hexToRgb(color)
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
onChange(color) onChange(color)
setIsOpen(false) handleClose()
} }, [onChange, handleClose])
// Picker content
const pickerContent = isOpen ? (
<div
ref={pickerRef}
className="fixed z-[9999] w-[280px] bg-dark-900 rounded-2xl border border-dark-700 shadow-2xl overflow-hidden"
style={{
top: pickerPosition.top,
left: pickerPosition.left,
}}
onClick={(e) => e.stopPropagation()}
>
{/* Color preview header */}
<div
className="h-16 w-full"
style={{ backgroundColor: localValue || '#000000' }}
/>
{/* Controls */}
<div className="p-4 space-y-4">
{/* Hue slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-dark-400">Hue</span>
<span className="text-xs text-dark-500">{hsl.h}°</span>
</div>
<input
type="range"
min="0"
max="360"
value={hsl.h}
onChange={handleHueChange}
className="w-full h-3 rounded-full appearance-none cursor-pointer"
style={{
background: 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
}}
/>
</div>
{/* Saturation slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-dark-400">Saturation</span>
<span className="text-xs text-dark-500">{hsl.s}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={hsl.s}
onChange={handleSaturationChange}
className="w-full h-3 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`,
}}
/>
</div>
{/* Lightness slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-dark-400">Lightness</span>
<span className="text-xs text-dark-500">{hsl.l}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={hsl.l}
onChange={handleLightnessChange}
className="w-full h-3 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`,
}}
/>
</div>
{/* Hex input */}
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-dark-400">HEX</span>
<input
type="text"
value={localValue}
onChange={handleHexInputChange}
className="flex-1 h-9 px-3 text-sm font-mono uppercase bg-dark-800 border border-dark-700 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
placeholder="#000000"
maxLength={7}
/>
</div>
{/* Presets */}
<div className="pt-2 border-t border-dark-700">
<span className="text-xs font-medium text-dark-400 block mb-2">Presets</span>
<div className="grid grid-cols-6 gap-1.5">
{PRESET_COLORS.map((preset) => (
<button
key={preset}
onClick={() => handlePresetClick(preset)}
className={`w-full aspect-square rounded-lg transition-transform hover:scale-110 active:scale-95 ${
localValue.toLowerCase() === preset.toLowerCase()
? 'ring-2 ring-white ring-offset-2 ring-offset-dark-900'
: ''
}`}
style={{ backgroundColor: preset }}
title={preset}
/>
))}
</div>
</div>
</div>
</div>
) : null
return ( return (
<div ref={containerRef} className="relative"> <div className="relative">
<label className="block text-sm font-medium text-dark-200 mb-1">{label}</label> <label className="block text-sm font-medium text-dark-200 mb-1">{label}</label>
{description && <p className="text-xs text-dark-500 mb-2">{description}</p>} {description && <p className="text-xs text-dark-500 mb-2">{description}</p>}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Color preview button - min 44px for touch accessibility */} {/* Color preview button */}
<button <button
ref={buttonRef}
type="button" type="button"
onClick={() => !disabled && setIsOpen(!isOpen)} onClick={handleOpen}
disabled={disabled} disabled={disabled}
className="w-11 h-11 rounded-lg border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0" className="w-11 h-11 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
style={{ backgroundColor: localValue || '#000000' }} style={{ backgroundColor: localValue || '#000000' }}
title={localValue} title={localValue}
aria-label={`Select color: ${localValue}`}
/> />
{/* Hex input */} {/* Hex input */}
@@ -138,12 +363,12 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
value={localValue} value={localValue}
onChange={handleHexInputChange} onChange={handleHexInputChange}
disabled={disabled} disabled={disabled}
className="input w-28 py-2 font-mono text-sm uppercase" className="w-28 h-11 px-3 font-mono text-sm uppercase bg-dark-800 border border-dark-700 rounded-xl text-dark-100 focus:outline-none focus:border-accent-500 disabled:opacity-50"
placeholder="#000000" placeholder="#000000"
maxLength={7} maxLength={7}
/> />
{/* Native color picker - hidden in Telegram WebApp (causes crash) */} {/* Native color picker button (hidden in Telegram) */}
{!isTelegram && ( {!isTelegram && (
<> <>
<input <input
@@ -154,105 +379,23 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
disabled={disabled} disabled={disabled}
className="sr-only" className="sr-only"
/> />
{/* Native picker button - min 44px for touch accessibility */}
<button <button
type="button" type="button"
onClick={() => colorInputRef.current?.click()} onClick={() => colorInputRef.current?.click()}
disabled={disabled} disabled={disabled}
className="btn-secondary min-w-[44px] min-h-[44px] p-2.5 disabled:opacity-50" className="w-11 h-11 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50"
title="Open color picker" title="System color picker"
aria-label="Open color picker"
> >
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path <path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
strokeLinecap="round"
strokeLinejoin="round"
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
/>
</svg> </svg>
</button> </button>
</> </>
)} )}
</div> </div>
{/* Dropdown with presets and RGB sliders */} {/* Render picker in portal */}
{isOpen && ( {typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
<div className="absolute z-50 mt-2 p-3 bg-dark-800 rounded-xl border border-dark-700 shadow-xl animate-fade-in w-72">
{/* RGB Sliders - shown in Telegram instead of native picker */}
{isTelegram && (
<div className="mb-3 pb-3 border-b border-dark-700">
<div className="text-xs text-dark-400 mb-2">RGB</div>
<div className="space-y-2">
{/* Red */}
<div className="flex items-center gap-2">
<span className="text-xs text-red-400 w-4">R</span>
<input
type="range"
min="0"
max="255"
value={rgb.r}
onChange={(e) => 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}))`,
}}
/>
<span className="text-xs text-dark-400 w-8 text-right">{rgb.r}</span>
</div>
{/* Green */}
<div className="flex items-center gap-2">
<span className="text-xs text-green-400 w-4">G</span>
<input
type="range"
min="0"
max="255"
value={rgb.g}
onChange={(e) => 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}))`,
}}
/>
<span className="text-xs text-dark-400 w-8 text-right">{rgb.g}</span>
</div>
{/* Blue */}
<div className="flex items-center gap-2">
<span className="text-xs text-blue-400 w-4">B</span>
<input
type="range"
min="0"
max="255"
value={rgb.b}
onChange={(e) => 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))`,
}}
/>
<span className="text-xs text-dark-400 w-8 text-right">{rgb.b}</span>
</div>
</div>
</div>
)}
<div className="text-xs text-dark-400 mb-2">Preset colors</div>
<div className="grid grid-cols-4 gap-1">
{PRESET_COLORS.map((preset) => (
<button
key={preset}
onClick={() => handlePresetClick(preset)}
className={`min-w-[44px] min-h-[44px] w-full aspect-square rounded-lg border-2 transition-all hover:scale-105 ${
localValue.toLowerCase() === preset.toLowerCase() ? 'border-white ring-2 ring-white/30' : 'border-dark-600 hover:border-dark-500'
}`}
style={{ backgroundColor: preset }}
title={preset}
aria-label={`Select color ${preset}`}
/>
))}
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -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 { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { subscriptionApi } from '../api/subscription' import { subscriptionApi } from '../api/subscription'
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
import type { AppInfo, AppConfig, LocalizedText } from '../types' import type { AppInfo, AppConfig, LocalizedText } from '../types'
interface ConnectionModalProps { interface ConnectionModalProps {
onClose: () => void onClose: () => void
} }
// Platform SVG Icons // Icons
const IosIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
)
const AndroidIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24a11.463 11.463 0 00-8.94 0L5.65 5.67c-.19-.29-.58-.38-.87-.2-.28.18-.37.54-.22.83L6.4 9.48A10.78 10.78 0 003 18h18a10.78 10.78 0 00-3.4-8.52zM7 15.25a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5zm10 0a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5z"/>
</svg>
)
const WindowsIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm17 .25V22l-10-1.91V13.1l10 .15z"/>
</svg>
)
const MacosIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4h16a2 2 0 012 2v10a2 2 0 01-2 2h-6v2h2a1 1 0 110 2H8a1 1 0 110-2h2v-2H4a2 2 0 01-2-2V6a2 2 0 012-2zm0 2v10h16V6H4z"/>
</svg>
)
const LinuxIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C9.5 2 8 4.5 8 7c0 1.5.5 3 1 4-1.5 1-3 3-3 5 0 .5 0 1 .5 1.5-.5.5-1.5 1-1.5 2 0 1.5 2 2.5 4 2.5h6c2 0 4-1 4-2.5 0-1-1-1.5-1.5-2 .5-.5.5-1 .5-1.5 0-2-1.5-4-3-5 .5-1 1-2.5 1-4 0-2.5-1.5-5-4-5zm-2 5c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm4 0c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm-2 3c1 0 2 .5 2 1s-1 1-2 1-2-.5-2-1 1-1 2-1z"/>
</svg>
)
const TvIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="13" rx="2" ry="2"/>
<polyline points="8 21 12 17 16 21"/>
</svg>
)
const CloseIcon = () => ( const CloseIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) )
const BackIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
const CopyIcon = () => ( const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg> </svg>
) )
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
) )
const LinkIcon = () => ( const LinkIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /> <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg> </svg>
) )
const ChevronIcon = () => ( const ChevronIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg> </svg>
) )
// Platform icon components map const BackIcon = () => (
const platformIconComponents: Record<string, React.FC> = { <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
ios: IosIcon, <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
android: AndroidIcon, </svg>
macos: MacosIcon, )
windows: WindowsIcon,
linux: LinuxIcon,
androidTV: TvIcon,
appleTV: TvIcon,
}
// App icons // App icons
const HappIcon = () => ( const HappIcon = () => (
@@ -108,20 +62,6 @@ const HappIcon = () => (
const ClashMetaIcon = () => ( const ClashMetaIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor"> <svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M4.99239 5.21742C4.0328 5.32232 3.19446 5.43999 3.12928 5.47886C2.94374 5.58955 2.96432 33.4961 3.14997 33.6449C3.2266 33.7062 4.44146 34.002 5.84976 34.3022C7.94234 34.7483 8.60505 34.8481 9.47521 34.8481C10.3607 34.8481 10.5706 34.8154 10.7219 34.6541C10.8859 34.479 10.9066 33.7222 10.9338 26.9143L10.9638 19.3685L11.2759 19.1094C11.6656 18.7859 12.1188 18.7789 12.5285 19.0899C12.702 19.2216 14.319 20.624 16.1219 22.2061C17.9247 23.7883 19.5136 25.1104 19.6527 25.144C19.7919 25.1777 20.3714 25.105 20.9406 24.9825C22.6144 24.6221 23.3346 24.5424 24.9233 24.5421C26.4082 24.5417 27.8618 24.71 29.2219 25.0398C29.6074 25.1333 30.0523 25.1784 30.2107 25.1399C30.369 25.1016 31.1086 24.5336 31.8543 23.8777C33.3462 22.5653 33.6461 22.3017 35.4359 20.7293C36.1082 20.1388 36.6831 19.6313 36.7137 19.6017C37.5681 18.7742 38.0857 18.6551 38.6132 19.1642L38.9383 19.478V34.5138L39.1856 34.6809C39.6343 34.9843 41.2534 34.9022 43.195 34.4775C44.1268 34.2737 45.2896 34.0291 45.779 33.9339C46.2927 33.8341 46.7276 33.687 46.8079 33.5861C47.0172 33.3228 47.0109 5.87708 46.8014 5.6005C46.6822 5.4431 46.2851 5.37063 44.605 5.1996C43.477 5.08482 42.2972 5.00505 41.983 5.02223L41.4121 5.05368L35.4898 10.261C27.3144 17.4495 27.7989 17.0418 27.5372 16.9533C27.4148 16.912 26.1045 16.8746 24.6253 16.8702C22.0674 16.8626 21.9233 16.8513 21.6777 16.6396C21.0693 16.115 17.2912 12.8028 14.5726 10.4108C12.9548 8.98729 10.9055 7.18761 10.0186 6.41134L8.40584 5L7.5715 5.01331C7.11256 5.02072 5.95198 5.11252 4.99239 5.21742Z"/> <path fillRule="evenodd" clipRule="evenodd" d="M4.99239 5.21742C4.0328 5.32232 3.19446 5.43999 3.12928 5.47886C2.94374 5.58955 2.96432 33.4961 3.14997 33.6449C3.2266 33.7062 4.44146 34.002 5.84976 34.3022C7.94234 34.7483 8.60505 34.8481 9.47521 34.8481C10.3607 34.8481 10.5706 34.8154 10.7219 34.6541C10.8859 34.479 10.9066 33.7222 10.9338 26.9143L10.9638 19.3685L11.2759 19.1094C11.6656 18.7859 12.1188 18.7789 12.5285 19.0899C12.702 19.2216 14.319 20.624 16.1219 22.2061C17.9247 23.7883 19.5136 25.1104 19.6527 25.144C19.7919 25.1777 20.3714 25.105 20.9406 24.9825C22.6144 24.6221 23.3346 24.5424 24.9233 24.5421C26.4082 24.5417 27.8618 24.71 29.2219 25.0398C29.6074 25.1333 30.0523 25.1784 30.2107 25.1399C30.369 25.1016 31.1086 24.5336 31.8543 23.8777C33.3462 22.5653 33.6461 22.3017 35.4359 20.7293C36.1082 20.1388 36.6831 19.6313 36.7137 19.6017C37.5681 18.7742 38.0857 18.6551 38.6132 19.1642L38.9383 19.478V34.5138L39.1856 34.6809C39.6343 34.9843 41.2534 34.9022 43.195 34.4775C44.1268 34.2737 45.2896 34.0291 45.779 33.9339C46.2927 33.8341 46.7276 33.687 46.8079 33.5861C47.0172 33.3228 47.0109 5.87708 46.8014 5.6005C46.6822 5.4431 46.2851 5.37063 44.605 5.1996C43.477 5.08482 42.2972 5.00505 41.983 5.02223L41.4121 5.05368L35.4898 10.261C27.3144 17.4495 27.7989 17.0418 27.5372 16.9533C27.4148 16.912 26.1045 16.8746 24.6253 16.8702C22.0674 16.8626 21.9233 16.8513 21.6777 16.6396C21.0693 16.115 17.2912 12.8028 14.5726 10.4108C12.9548 8.98729 10.9055 7.18761 10.0186 6.41134L8.40584 5L7.5715 5.01331C7.11256 5.02072 5.95198 5.11252 4.99239 5.21742Z"/>
<path d="M25.572 37.9556C25.3176 38.3822 24.6815 38.3822 24.427 37.9556L23.4728 36.3558C23.2184 35.9292 23.5364 35.396 24.0453 35.396H25.9537C26.4626 35.396 26.7807 35.9292 26.5262 36.3558L25.572 37.9556Z"/>
<path d="M3 37.3157C3 36.9034 3.3453 36.5691 3.77126 36.5691H14.3485C14.7745 36.5691 15.1198 36.9034 15.1198 37.3157C15.1198 37.728 14.7745 38.0623 14.3485 38.0623H3.77126C3.3453 38.0623 3 37.728 3 37.3157Z"/>
<path d="M3.58851 44.5029C3.44604 44.1144 3.65596 43.6876 4.05738 43.5497L14.0254 40.1251C14.4269 39.9872 14.8678 40.1904 15.0102 40.5789C15.1527 40.9675 14.9428 41.3943 14.5414 41.5322L4.57331 44.9568C4.17189 45.0947 3.73098 44.8915 3.58851 44.5029Z"/>
<path d="M47 37.3157C47 36.9034 46.6547 36.5691 46.2287 36.5691H35.6515C35.2255 36.5691 34.8802 36.9034 34.8802 37.3157C34.8802 37.728 35.2255 38.0623 35.6515 38.0623H46.2287C46.6547 38.0623 47 37.728 47 37.3157Z"/>
<path d="M46.4115 44.5029C46.554 44.1144 46.344 43.6876 45.9426 43.5497L35.9746 40.1251C35.5731 39.9872 35.1322 40.1904 34.9898 40.5789C34.8473 40.9675 35.0572 41.3943 35.4586 41.5322L45.4267 44.9568C45.8281 45.0947 46.269 44.8915 46.4115 44.5029Z"/>
</svg>
)
const ClashVergeIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="none">
<path d="M30.9988 13.4144C27.2973 12.0435 23.5958 11.9064 19.0716 13.4144C14.1362 10.6726 10.846 4.36632 7.967 5.05171C5.08802 5.7371 5.91058 16.8418 5.91058 24.2448C4.26545 26.4384 3.64225 29.205 4.12911 31.7851C7.69353 48.9219 40.8711 49.8816 45.8057 31.7851C46.6656 28.6319 44.8461 25.0674 44.1599 24.2448C44.1599 16.7047 44.9825 5.20727 42.2406 5.05168C38.539 4.84163 35.523 10.8096 30.9988 13.4144Z" stroke="currentColor" strokeWidth="2.5"/>
<path d="M27.9837 34.1749C27.9836 33.078 21.5403 33.0711 21.5403 34.1749C21.5403 35.8199 24.6933 37.328 24.6933 37.328C24.6933 37.328 27.9838 35.8822 27.9837 34.1749Z" fill="currentColor"/>
<path d="M17.8383 25.5362C16.6044 24.5766 13.3656 23.754 12.0803 25.8105C10.9382 27.6378 12.4017 29.8157 14.2738 30.8829C16.1459 31.95 20.0317 31.2941 20.0317 29.649C20.0317 28.004 19.0721 26.4957 17.8383 25.5362Z" fill="currentColor"/>
<path d="M31.8219 25.4405C33.0557 24.4809 36.2945 23.6583 37.5799 25.7148C38.722 27.5421 37.2584 29.72 35.3863 30.7872C33.5142 31.8543 29.6285 31.1984 29.6285 29.5533C29.6285 27.9083 30.588 26.4 31.8219 25.4405Z" fill="currentColor"/>
</svg> </svg>
) )
@@ -133,95 +73,136 @@ const ShadowrocketIcon = () => (
const StreisandIcon = () => ( const StreisandIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor"> <svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
<path d="M25 46L24.2602 47.0076C24.7027 47.3325 25.3054 47.3306 25.7459 47.0031L25 46ZM6.14773 32.1591H4.89773C4.89773 32.557 5.0872 32.9312 5.40797 33.1667L6.14773 32.1591ZM43.6136 32.1591L44.3595 33.1622C44.6767 32.9263 44.8636 32.5543 44.8636 32.1591H43.6136ZM6.14773 19.9886L5.42485 18.9689C5.09421 19.2032 4.89773 19.5834 4.89773 19.9886H6.14773ZM25 6.625L25.729 5.6096L25.0046 5.08952L24.2771 5.60522L25 6.625ZM43.6136 19.9886H44.8636C44.8636 19.586 44.6697 19.208 44.3426 18.9732L43.6136 19.9886ZM3.25 38.5568C2.69772 38.971 2.58579 39.7545 3 40.3068C3.41421 40.8591 4.19772 40.971 4.75 40.5568L4 39.5568L3.25 38.5568ZM13.3409 34.1136C13.8932 33.6994 14.0051 32.9159 13.5909 32.3636C13.1767 31.8114 12.3932 31.6994 11.8409 32.1136L12.5909 33.1136L13.3409 34.1136ZM8.97727 42.8523C8.42499 43.2665 8.31306 44.05 8.72727 44.6023C9.14149 45.1546 9.92499 45.2665 10.4773 44.8523L9.72727 43.8523L8.97727 42.8523ZM19.0682 38.4091C19.6205 37.9949 19.7324 37.2114 19.3182 36.6591C18.904 36.1068 18.1205 35.9949 17.5682 36.4091L18.3182 37.4091L19.0682 38.4091ZM45.25 40.5568C45.8023 40.971 46.5858 40.8591 47 40.3068C47.4142 39.7545 47.3023 38.971 46.75 38.5568L46 39.5568L45.25 40.5568ZM38.1591 32.1136C37.6068 31.6994 36.8233 31.8114 36.4091 32.3636C35.9949 32.9159 36.1068 33.6994 36.6591 34.1136L37.4091 33.1136L38.1591 32.1136ZM39.5227 44.8523C40.075 45.2665 40.8585 45.1546 41.2727 44.6023C41.6869 44.05 41.575 43.2665 41.0227 42.8523L40.2727 43.8523L39.5227 44.8523ZM32.4318 36.4091C31.8795 35.9949 31.096 36.1068 30.6818 36.6591C30.2676 37.2114 30.3795 37.9949 30.9318 38.4091L31.6818 37.4091L32.4318 36.4091ZM46.2727 9.29545C46.825 8.88124 46.9369 8.09774 46.5227 7.54545C46.1085 6.99317 45.325 6.88124 44.7727 7.29545L45.5227 8.29545L46.2727 9.29545ZM36.1818 13.7386C35.6295 14.1528 35.5176 14.9364 35.9318 15.4886C36.346 16.0409 37.1295 16.1528 37.6818 15.7386L36.9318 14.7386L36.1818 13.7386ZM40.5455 5C41.0977 4.58579 41.2097 3.80228 40.7955 3.25C40.3812 2.69772 39.5977 2.58579 39.0455 3L39.7955 4L40.5455 5ZM30.4545 9.44318C29.9023 9.8574 29.7903 10.6409 30.2045 11.1932C30.6188 11.7455 31.4023 11.8574 31.9545 11.4432L31.2045 10.4432L30.4545 9.44318ZM5.70455 7.29545C5.15226 6.88124 4.36876 6.99317 3.95455 7.54545C3.54033 8.09774 3.65226 8.88124 4.20455 9.29545L4.95455 8.29545L5.70455 7.29545ZM12.7955 15.7386C13.3477 16.1528 14.1312 16.0409 14.5455 15.4886C14.9597 14.9364 14.8477 14.1528 14.2955 13.7386L13.5455 14.7386L12.7955 15.7386ZM11.4318 3C10.8795 2.58579 10.096 2.69772 9.68182 3.25C9.2676 3.80228 9.37953 4.58579 9.93182 5L10.6818 4L11.4318 3ZM18.5227 11.4432C19.075 11.8574 19.8585 11.7455 20.2727 11.1932C20.6869 10.6409 20.575 9.8574 20.0227 9.44318L19.2727 10.4432L18.5227 11.4432ZM25 46L25.7398 44.9924L6.88748 31.1515L6.14773 32.1591L5.40797 33.1667L24.2602 47.0076L25 46ZM43.6136 32.1591L42.8678 31.156L24.2541 44.9969L25 46L25.7459 47.0031L44.3595 33.1622L43.6136 32.1591ZM25 33.8295L25.7398 32.8219L6.88748 18.981L6.14773 19.9886L5.40797 20.9962L24.2602 34.8371L25 33.8295ZM6.14773 19.9886L6.87061 21.0084L25.7229 7.64478L25 6.625L24.2771 5.60522L5.42485 18.9689L6.14773 19.9886ZM25 6.625L24.271 7.6404L42.8846 21.004L43.6136 19.9886L44.3426 18.9732L25.729 5.6096L25 6.625ZM43.6136 19.9886L42.8678 18.9856L24.2541 32.8265L25 33.8295L25.7459 34.8326L44.3595 20.9917L43.6136 19.9886ZM43.6136 32.1591H44.8636V19.9886H43.6136H42.3636V32.1591H43.6136ZM25 33.8295H23.75V46H25H26.25V33.8295H25ZM6.14773 32.1591H7.39773V19.9886H6.14773H4.89773V32.1591H6.14773ZM4 39.5568L4.75 40.5568L13.3409 34.1136L12.5909 33.1136L11.8409 32.1136L3.25 38.5568L4 39.5568ZM9.72727 43.8523L10.4773 44.8523L19.0682 38.4091L18.3182 37.4091L17.5682 36.4091L8.97727 42.8523L9.72727 43.8523ZM46 39.5568L46.75 38.5568L38.1591 32.1136L37.4091 33.1136L36.6591 34.1136L45.25 40.5568L46 39.5568ZM40.2727 43.8523L41.0227 42.8523L32.4318 36.4091L31.6818 37.4091L30.9318 38.4091L39.5227 44.8523L40.2727 43.8523ZM45.5227 8.29545L44.7727 7.29545L36.1818 13.7386L36.9318 14.7386L37.6818 15.7386L46.2727 9.29545L45.5227 8.29545ZM39.7955 4L39.0455 3L30.4545 9.44318L31.2045 10.4432L31.9545 11.4432L40.5455 5L39.7955 4ZM4.95455 8.29545L4.20455 9.29545L12.7955 15.7386L13.5455 14.7386L14.2955 13.7386L5.70455 7.29545L4.95455 8.29545ZM10.6818 4L9.93182 5L18.5227 11.4432L19.2727 10.4432L20.0227 9.44318L11.4318 3L10.6818 4Z"/> <path d="M25 46L24.2602 47.0076C24.7027 47.3325 25.3054 47.3306 25.7459 47.0031L25 46ZM6.14773 32.1591H4.89773C4.89773 32.557 5.0872 32.9312 5.40797 33.1667L6.14773 32.1591ZM43.6136 32.1591L44.3595 33.1622C44.6767 32.9263 44.8636 32.5543 44.8636 32.1591H43.6136ZM6.14773 19.9886L5.42485 18.9689C5.09421 19.2032 4.89773 19.5834 4.89773 19.9886H6.14773ZM25 6.625L25.729 5.6096L25.0046 5.08952L24.2771 5.60522L25 6.625ZM43.6136 19.9886H44.8636C44.8636 19.586 44.6697 19.208 44.3426 18.9732L43.6136 19.9886ZM25 46L25.7398 44.9924L6.88748 31.1515L6.14773 32.1591L5.40797 33.1667L24.2602 47.0076L25 46ZM43.6136 32.1591L42.8678 31.156L24.2541 44.9969L25 46L25.7459 47.0031L44.3595 33.1622L43.6136 32.1591Z"/>
</svg> </svg>
) )
// App icon mapping by name (case-insensitive) const getAppIcon = (appName: string): React.ReactNode => {
const getAppIcon = (appName: string, isFeatured: boolean): React.ReactNode => {
const name = appName.toLowerCase() const name = appName.toLowerCase()
if (name.includes('happ')) { if (name.includes('happ')) return <HappIcon />
return <HappIcon /> if (name.includes('shadowrocket') || name.includes('rocket')) return <ShadowrocketIcon />
} if (name.includes('streisand')) return <StreisandIcon />
if (name.includes('shadowrocket') || name.includes('rocket')) { if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return <ClashMetaIcon />
return <ShadowrocketIcon /> return <span className="text-lg">📦</span>
}
if (name.includes('streisand')) {
return <StreisandIcon />
}
if (name.includes('verge')) {
return <ClashVergeIcon />
}
if (name.includes('clash') || name.includes('meta')) {
return <ClashMetaIcon />
}
// Default icons
return isFeatured ? '⭐' : '📦'
} }
// Platform order for display
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
// Dangerous schemes that should never be allowed
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
function isValidExternalUrl(url: string | undefined): boolean { function isValidExternalUrl(url: string | undefined): boolean {
if (!url) return false if (!url) return false
const lowerUrl = url.toLowerCase().trim() const lowerUrl = url.toLowerCase().trim()
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false
return false
}
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://') return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')
} }
function isValidDeepLink(url: string | undefined): boolean { function isValidDeepLink(url: string | undefined): boolean {
if (!url) return false if (!url) return false
const lowerUrl = url.toLowerCase().trim() const lowerUrl = url.toLowerCase().trim()
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false
return false
}
return lowerUrl.includes('://') return lowerUrl.includes('://')
} }
function detectPlatform(): string | null { function detectPlatform(): string | null {
if (typeof window === 'undefined' || !navigator?.userAgent) { if (typeof window === 'undefined' || !navigator?.userAgent) return null
return null
}
const ua = navigator.userAgent.toLowerCase() const ua = navigator.userAgent.toLowerCase()
if (/iphone|ipad|ipod/.test(ua)) return 'ios' if (/iphone|ipad|ipod/.test(ua)) return 'ios'
if (/android/.test(ua)) { if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android'
if (/tv|television|smart-tv|smarttv/.test(ua)) return 'androidTV'
return 'android'
}
if (/macintosh|mac os x/.test(ua)) return 'macos' if (/macintosh|mac os x/.test(ua)) return 'macos'
if (/windows/.test(ua)) return 'windows' if (/windows/.test(ua)) return 'windows'
if (/linux/.test(ua)) return 'linux' if (/linux/.test(ua)) return 'linux'
return null 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) { export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null)
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null) const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [detectedPlatform, setDetectedPlatform] = useState<string | null>(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<AppConfig>({ const { data: appConfig, isLoading, error } = useQuery<AppConfig>({
queryKey: ['appConfig'], queryKey: ['appConfig'],
queryFn: () => subscriptionApi.getAppConfig(), 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(() => { 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(() => { useEffect(() => {
const originalStyle = window.getComputedStyle(document.body).overflow const handleKeyDown = (e: KeyboardEvent) => {
document.body.style.overflow = 'hidden' if (e.key === 'Escape') {
return () => { e.preventDefault()
document.body.style.overflow = originalStyle 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 => { 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] || '' 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(() => { const availablePlatforms = useMemo(() => {
if (!appConfig?.platforms) return [] if (!appConfig?.platforms) return []
const available = platformOrder.filter( const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0)
(key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0 // Put detected platform first
)
if (detectedPlatform && available.includes(detectedPlatform)) { if (detectedPlatform && available.includes(detectedPlatform)) {
const filtered = available.filter(p => p !== detectedPlatform) return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)]
return [detectedPlatform, ...filtered]
} }
return available return available
}, [appConfig, detectedPlatform]) }, [appConfig, detectedPlatform])
const platformApps = useMemo(() => {
if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return []
return appConfig.platforms[selectedPlatform]
}, [selectedPlatform, appConfig])
const copySubscriptionLink = async () => { const copySubscriptionLink = async () => {
if (!appConfig?.subscriptionUrl) return if (!appConfig?.subscriptionUrl) return
try { try {
@@ -273,339 +240,263 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
} }
const handleConnect = (app: AppInfo) => { const handleConnect = (app: AppInfo) => {
if (!app.deepLink || !isValidDeepLink(app.deepLink)) { if (!app.deepLink || !isValidDeepLink(app.deepLink)) return
console.warn('Invalid or missing deep link:', app.deepLink)
return
}
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en' const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}` 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 const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp
if (isCustomScheme && tg?.openLink) { if (tg?.openLink) {
try { try {
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true })
return return
} catch (e) { } catch { /* fallback */ }
console.warn('tg.openLink failed:', e)
}
} }
window.location.href = redirectUrl window.location.href = redirectUrl
} }
// Modal wrapper - centered on desktop, top on mobile // Wrapper component
const ModalWrapper = ({ children }: { children: React.ReactNode }) => ( const Wrapper = ({ children }: { children: React.ReactNode }) => {
<div if (isMobile) {
className="fixed inset-0 bg-black/70 z-50 flex items-start sm:items-center justify-center overflow-y-auto" // Mobile fullscreen
style={{ const content = (
padding: '1rem', <div
paddingTop: 'calc(3rem + env(safe-area-inset-top, 0px))', className="fixed inset-0 z-[9999] bg-dark-900 flex flex-col"
paddingBottom: 'calc(1rem + env(safe-area-inset-bottom, 0px))' style={{
}} paddingTop: safeTop ? `${safeTop}px` : 'env(safe-area-inset-top, 0px)',
onClick={onClose} paddingBottom: safeBottom ? `${safeBottom}px` : 'env(safe-area-inset-bottom, 0px)'
> }}
<div >
className="w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 overflow-hidden animate-scale-in" {children}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
)
// Loading state
if (isLoading) {
return (
<ModalWrapper>
<div className="flex justify-center py-16">
<div className="w-10 h-10 border-3 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div> </div>
</ModalWrapper> )
if (typeof document !== 'undefined') return createPortal(content, document.body)
return content
}
// Desktop centered
return (
<div className="fixed inset-0 bg-black/60 z-[60] flex items-start justify-center p-4 pt-[8vh]" onClick={handleClose}>
<div
className="relative w-full max-w-md max-h-[85vh] bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
{children}
</div>
</div>
) )
} }
// Error state // Loading
if (isLoading) {
return (
<Wrapper>
<div className="flex-1 flex items-center justify-center">
<div className="w-10 h-10 border-[3px] border-accent-500/30 border-t-accent-500 rounded-full animate-spin" />
</div>
</Wrapper>
)
}
// Error
if (error || !appConfig) { if (error || !appConfig) {
return ( return (
<ModalWrapper> <Wrapper>
<div className="text-center p-6"> <div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
<div className="w-14 h-14 mx-auto mb-3 rounded-2xl bg-error-500/10 flex items-center justify-center"> <p className="text-dark-300 text-lg mb-4">{t('common.error')}</p>
<span className="text-2xl">😕</span> <button onClick={handleClose} className="btn-primary px-6 py-2">{t('common.close')}</button>
</div>
<p className="text-dark-300 mb-4 text-sm">{t('common.error')}</p>
<button onClick={onClose} className="btn-primary text-sm px-6">
{t('common.close')}
</button>
</div> </div>
</ModalWrapper> </Wrapper>
) )
} }
// No subscription // No subscription
if (!appConfig.hasSubscription) { if (!appConfig.hasSubscription) {
return ( return (
<ModalWrapper> <Wrapper>
<div className="text-center p-6"> <div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
<div className="w-14 h-14 mx-auto mb-3 rounded-2xl bg-warning-500/10 flex items-center justify-center"> <h3 className="font-bold text-dark-100 text-xl mb-2">{t('subscription.connection.title')}</h3>
<span className="text-2xl">📱</span> <p className="text-dark-400 mb-4">{t('subscription.connection.noSubscription')}</p>
</div> <button onClick={handleClose} className="btn-primary px-6 py-2">{t('common.close')}</button>
<h3 className="font-semibold text-dark-100 mb-1">{t('subscription.connection.title')}</h3>
<p className="text-dark-400 text-sm mb-4">{t('subscription.connection.noSubscription')}</p>
<button onClick={onClose} className="btn-primary text-sm px-6">
{t('common.close')}
</button>
</div> </div>
</ModalWrapper> </Wrapper>
) )
} }
// Step 1: Select platform // App selector
if (!selectedPlatform) { if (showAppSelector) {
const platformNames: Record<string, string> = {
ios: 'iOS', android: 'Android', windows: 'Windows',
macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV'
}
return ( return (
<ModalWrapper> <Wrapper>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-800"> <div className="flex items-center gap-3 p-4 border-b border-dark-800">
<div className="flex items-center gap-3"> <button onClick={handleBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300">
<div className="w-9 h-9 rounded-xl bg-accent-500/10 flex items-center justify-center">
<span className="text-lg">📱</span>
</div>
<div>
<h2 className="font-semibold text-dark-100 text-sm">{t('subscription.connection.title')}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.selectDevice')}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400">
<CloseIcon />
</button>
</div>
{/* Platforms grid */}
<div className="p-4">
<div className="grid grid-cols-3 gap-2">
{availablePlatforms.map((platform) => {
const IconComponent = platformIconComponents[platform]
const isDetected = platform === detectedPlatform
return (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`relative p-3 rounded-xl border transition-all flex flex-col items-center gap-2 ${
isDetected
? 'bg-accent-500/10 border-accent-500/50'
: 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
}`}
>
{isDetected && (
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded text-[8px] font-medium bg-accent-500 text-white whitespace-nowrap">
{t('subscription.connection.yourDevice')}
</div>
)}
<div className={isDetected ? 'text-accent-400' : 'text-dark-400'}>
{IconComponent && <IconComponent />}
</div>
<span className={`text-xs font-medium ${isDetected ? 'text-accent-300' : 'text-dark-300'}`}>
{getPlatformName(platform)}
</span>
</button>
)
})}
</div>
</div>
{/* Copy link */}
<div className="px-4 pb-4">
<button
onClick={copySubscriptionLink}
className={`w-full p-2.5 rounded-xl border-2 border-dashed transition-all flex items-center justify-center gap-2 text-sm ${
copied
? 'border-success-500/50 bg-success-500/10 text-success-400'
: 'border-dark-700 hover:border-accent-500/50 text-dark-400'
}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
</button>
</div>
</ModalWrapper>
)
}
// Step 2: Select app
if (!selectedApp) {
return (
<ModalWrapper>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-800">
<div className="flex items-center gap-2">
<button onClick={() => setSelectedPlatform(null)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400">
<BackIcon />
</button>
<div>
<h2 className="font-semibold text-dark-100 text-sm">{getPlatformName(selectedPlatform)}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.selectApp')}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400">
<CloseIcon />
</button>
</div>
{/* Apps list */}
<div className="p-4 max-h-[50vh] overflow-y-auto">
{platformApps.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-800 flex items-center justify-center">
<span className="text-xl">📭</span>
</div>
<p className="text-dark-500 text-sm">{t('subscription.connection.noApps')}</p>
</div>
) : (
<div className="space-y-2">
{platformApps.map((app) => (
<button
key={app.id}
onClick={() => setSelectedApp(app)}
className="w-full p-3 rounded-xl bg-dark-800/50 border border-dark-700/50 hover:border-dark-600 transition-all flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-dark-700 flex items-center justify-center text-base">
{getAppIcon(app.name, app.isFeatured)}
</div>
<div className="text-left">
<div className="flex items-center gap-2">
<span className="font-medium text-dark-100 text-sm">{app.name}</span>
{app.isFeatured && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-medium bg-accent-500/20 text-accent-400">
{t('subscription.connection.featured')}
</span>
)}
</div>
</div>
</div>
<div className="text-dark-500">
<ChevronIcon />
</div>
</button>
))}
</div>
)}
</div>
</ModalWrapper>
)
}
// Step 3: App instructions
return (
<ModalWrapper>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-800">
<div className="flex items-center gap-2">
<button onClick={() => setSelectedApp(null)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-400">
<BackIcon /> <BackIcon />
</button> </button>
<div> <h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.selectApp')}</h2>
<h2 className="font-semibold text-dark-100 text-sm">{selectedApp.name}</h2>
<p className="text-xs text-dark-500">{t('subscription.connection.instructions')}</p>
</div>
</div> </div>
<button onClick={onClose} className="p-2 rounded-xl hover:bg-dark-800 text-dark-400">
<CloseIcon /> {/* Apps grouped by platform */}
<div className={`${isMobile ? 'flex-1' : 'max-h-[60vh]'} overflow-y-auto p-4 space-y-5`}>
{availablePlatforms.map(platform => {
const apps = appConfig.platforms[platform]
if (!apps?.length) return null
const isCurrentPlatform = platform === detectedPlatform
return (
<div key={platform}>
{/* Platform header */}
<div className="flex items-center gap-2 mb-2 px-1">
<span className={`text-sm font-semibold ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-400'}`}>
{platformNames[platform] || platform}
</span>
{isCurrentPlatform && (
<span className="text-xs text-accent-500 bg-accent-500/10 px-2 py-0.5 rounded-full">
{t('subscription.connection.yourDevice')}
</span>
)}
</div>
{/* Apps for this platform */}
<div className="space-y-2">
{apps.map(app => (
<button
key={app.id}
onClick={() => { setSelectedApp(app); setShowAppSelector(false) }}
className={`w-full p-3 rounded-xl flex items-center gap-3 transition-all ${
selectedApp?.id === app.id
? 'bg-accent-500/10 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 hover:bg-dark-800'
}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
selectedApp?.id === app.id ? 'bg-accent-500/20 text-accent-400' : 'bg-dark-700 text-dark-300'
}`}>
{getAppIcon(app.name)}
</div>
<div className="flex-1 text-left">
<span className="font-medium text-dark-100">{app.name}</span>
{app.isFeatured && (
<span className="ml-2 text-xs text-accent-400">{t('subscription.connection.featured')}</span>
)}
</div>
</button>
))}
</div>
</div>
)
})}
</div>
</Wrapper>
)
}
// Main view
return (
<Wrapper>
{/* Header */}
<div className="p-4 border-b border-dark-800">
<div className="flex items-center justify-between mb-3">
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.title')}</h2>
<button onClick={handleClose} className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400">
<CloseIcon />
</button>
</div>
{/* App selector button */}
<button
onClick={() => setShowAppSelector(true)}
className="w-full flex items-center gap-3 p-3 rounded-xl bg-dark-800/50 hover:bg-dark-800 transition-colors"
>
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center text-accent-400">
{selectedApp && getAppIcon(selectedApp.name)}
</div>
<div className="flex-1 text-left">
<div className="font-medium text-dark-100">{selectedApp?.name}</div>
<div className="text-sm text-dark-400">{t('subscription.connection.changeApp') || 'Сменить приложение'}</div>
</div>
<ChevronIcon />
</button> </button>
</div> </div>
{/* Instructions */} {/* Steps */}
<div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto"> <div className={`${isMobile ? 'flex-1' : 'max-h-[50vh]'} overflow-y-auto p-4 space-y-4`}>
{/* Step 1: Install */} {/* Step 1: Install */}
{selectedApp.installationStep && ( {selectedApp?.installationStep && (
<div className="p-3 rounded-xl bg-dark-800/50 border border-dark-700/50"> <div className="space-y-2">
<div className="flex items-start gap-3"> <div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400 shrink-0 mt-0.5">1</div> <span className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400">1</span>
<div className="flex-1 min-w-0"> <span className="font-medium text-dark-100">{t('subscription.connection.installApp')}</span>
<h3 className="font-medium text-dark-100 text-sm mb-1">{t('subscription.connection.installApp')}</h3>
<p className="text-xs text-dark-400 mb-2">
{getLocalizedText(selectedApp.installationStep.description)}
</p>
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedApp.installationStep.buttons
.filter((btn) => isValidExternalUrl(btn.buttonLink))
.map((btn, idx) => (
<a
key={idx}
href={btn.buttonLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-700 text-dark-200 text-xs hover:bg-dark-600 transition-all"
>
<LinkIcon />
{getLocalizedText(btn.buttonText)}
</a>
))}
</div>
)}
</div>
</div> </div>
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.installationStep.description)}</p>
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
<div className="flex flex-wrap gap-2 ml-8">
{selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => (
<a
key={idx}
href={btn.buttonLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-800 text-dark-200 text-sm hover:bg-dark-700"
>
{getLocalizedText(btn.buttonText)}
</a>
))}
</div>
)}
</div> </div>
)} )}
{/* Step 2: Add subscription */} {/* Step 2: Add subscription */}
{selectedApp.addSubscriptionStep && ( {selectedApp?.addSubscriptionStep && (
<div className="p-3 rounded-xl bg-dark-800/50 border border-dark-700/50"> <div className="space-y-3">
<div className="flex items-start gap-3"> <div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400 shrink-0 mt-0.5">2</div> <span className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400">2</span>
<div className="flex-1 min-w-0"> <span className="font-medium text-dark-100">{t('subscription.connection.addSubscription')}</span>
<h3 className="font-medium text-dark-100 text-sm mb-1">{t('subscription.connection.addSubscription')}</h3> </div>
<p className="text-xs text-dark-400 mb-2"> <p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.addSubscriptionStep.description)}</p>
{getLocalizedText(selectedApp.addSubscriptionStep.description)}
</p> <div className="space-y-2 ml-8">
<div className="space-y-1.5"> {/* Connect button */}
{selectedApp.deepLink && ( {selectedApp.deepLink && (
<button <button
onClick={() => handleConnect(selectedApp)} onClick={() => handleConnect(selectedApp)}
className="btn-primary w-full py-2 text-sm flex items-center justify-center gap-2" className="w-full btn-primary h-11 text-sm font-semibold flex items-center justify-center gap-2"
> >
<LinkIcon /> <LinkIcon />
{t('subscription.connection.addToApp', { appName: selectedApp.name })} {t('subscription.connection.addToApp', { appName: selectedApp.name })}
</button> </button>
)} )}
<button
onClick={copySubscriptionLink} {/* Copy link */}
className={`w-full p-2 rounded-lg border transition-all flex items-center justify-center gap-2 text-xs ${ <button
copied onClick={copySubscriptionLink}
? 'border-success-500/50 bg-success-500/10 text-success-400' className={`w-full h-11 rounded-xl border transition-all flex items-center justify-center gap-2 text-sm font-medium ${
: 'border-dark-700 hover:border-dark-600 bg-dark-800/50 text-dark-300' copied
}`} ? 'border-success-500 bg-success-500/10 text-success-400'
> : 'border-dark-600 text-dark-300 hover:bg-dark-800'
{copied ? <CheckIcon /> : <CopyIcon />} }`}
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')} >
</button> {copied ? <CheckIcon /> : <CopyIcon />}
</div> {copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
</div> </button>
</div> </div>
</div> </div>
)} )}
{/* Step 3: Connect */} {/* Step 3: Connect */}
{selectedApp.connectAndUseStep && ( {selectedApp?.connectAndUseStep && (
<div className="p-3 rounded-xl bg-dark-800/50 border border-dark-700/50"> <div className="space-y-2">
<div className="flex items-start gap-3"> <div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-success-500/20 flex items-center justify-center text-xs font-bold text-success-400 shrink-0 mt-0.5">3</div> <span className="w-6 h-6 rounded-full bg-success-500/20 flex items-center justify-center text-xs font-bold text-success-400">3</span>
<div className="flex-1 min-w-0"> <span className="font-medium text-dark-100">{t('subscription.connection.connectVpn')}</span>
<h3 className="font-medium text-dark-100 text-sm mb-1">{t('subscription.connection.connectVpn')}</h3>
<p className="text-xs text-dark-400">
{getLocalizedText(selectedApp.connectAndUseStep.description)}
</p>
</div>
</div> </div>
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.connectAndUseStep.description)}</p>
</div> </div>
)} )}
</div> </div>
</Wrapper>
{/* Footer */}
<div className="p-4 border-t border-dark-800">
<button onClick={onClose} className="btn-secondary w-full text-sm">
{t('common.close')}
</button>
</div>
</ModalWrapper>
) )
} }

View File

@@ -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 { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { balanceApi } from '../api/balance' import { balanceApi } from '../api/balance'
import { useCurrency } from '../hooks/useCurrency' import { useCurrency } from '../hooks/useCurrency'
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit' import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
import type { PaymentMethod } from '../types' 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) } try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) }
} }
if (webApp?.openLink) { 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) } 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) { if (reservedWindow && !reservedWindow.closed) {
@@ -29,16 +30,78 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
window.location.href = url window.location.href = url
} }
// Icons
const CloseIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const WalletIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
</svg>
)
const StarIcon = () => (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
)
const CardIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
const CryptoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
)
const SparklesIcon = () => (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
)
interface TopUpModalProps { interface TopUpModalProps {
method: PaymentMethod method: PaymentMethod
onClose: () => void onClose: () => void
initialAmountRubles?: number 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 <StarIcon />
if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon />
return <CardIcon />
}
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency()
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const isMobileScreen = useIsMobile()
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
const getInitialAmount = (): string => { const getInitialAmount = (): string => {
if (!initialAmountRubles || initialAmountRubles <= 0) return '' 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 method.options && method.options.length > 0 ? method.options[0].id : null
) )
const popupRef = useRef<Window | null>(null) const popupRef = useRef<Window | null>(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 hasOptions = method.options && method.options.length > 0
const minRubles = method.min_amount_kopeks / 100 const minRubles = method.min_amount_kopeks / 100
@@ -132,105 +247,250 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
: convertAmount(rub).toFixed(currencyDecimals) : convertAmount(rub).toFixed(currencyDecimals)
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
return ( // Auto-focus input - works on mobile in Telegram WebApp
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0"> useEffect(() => {
<div className="absolute inset-0" onClick={onClose} /> // 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 = (
<div className="space-y-5">
{/* Header icon and method */}
<div className="flex items-center gap-4 pb-1">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${
isStarsMethod
? 'bg-gradient-to-br from-yellow-500/20 to-orange-500/20 text-yellow-400'
: 'bg-gradient-to-br from-accent-500/20 to-accent-600/20 text-accent-400'
}`}>
<div className="w-7 h-7 flex items-center justify-center">
{getMethodIcon(method.id)}
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-dark-100">{methodName}</h3>
<p className="text-sm text-dark-400">
{formatAmount(minRubles, 0)} {formatAmount(maxRubles, 0)} {currencySymbol}
</p>
</div>
</div>
{/* Payment options (if any) */}
{hasOptions && method.options && (
<div className="space-y-2">
<label className="text-sm font-medium text-dark-400">{t('balance.paymentMethod')}</label>
<div className="grid grid-cols-2 gap-2">
{method.options.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setSelectedOption(opt.id)}
className={`relative py-3 px-4 rounded-xl text-sm font-semibold transition-all duration-200 ${
selectedOption === opt.id
? 'bg-accent-500/15 text-accent-400 ring-2 ring-accent-500/40'
: 'bg-dark-800/70 text-dark-300 hover:bg-dark-700/70 border border-dark-700/50'
}`}
>
{opt.name}
{selectedOption === opt.id && (
<span className="absolute top-1.5 right-1.5">
<span className="w-2 h-2 rounded-full bg-accent-500 block" />
</span>
)}
</button>
))}
</div>
</div>
)}
{/* Amount input - modern design */}
<div className="space-y-2">
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
<div className={`relative rounded-2xl transition-all duration-200 ${
isInputFocused
? 'ring-2 ring-accent-500/50 bg-dark-800'
: 'bg-dark-800/70 border border-dark-700/50'
}`}>
<input
ref={inputRef}
type="number"
inputMode="decimal"
enterKeyHint="done"
value={amount}
onChange={(e) => 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
/>
<span className="absolute right-5 top-1/2 -translate-y-1/2 text-lg font-semibold text-dark-500">
{currencySymbol}
</span>
</div>
</div>
{/* Quick amount buttons */}
{quickAmounts.length > 0 && (
<div className="flex gap-2">
{quickAmounts.map((a) => {
const val = getQuickValue(a)
const isSelected = amount === val
return (
<button
key={a}
type="button"
onClick={() => { setAmount(val); inputRef.current?.blur() }}
className={`flex-1 py-3 rounded-xl text-sm font-bold transition-all duration-200 ${
isSelected
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/40'
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-800 hover:text-dark-300 border border-dark-700/30'
}`}
>
{formatAmount(a, 0)}
</button>
)
})}
</div>
)}
{/* Error message */}
{error && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-error-500/10 border border-error-500/20">
<svg className="w-5 h-5 text-error-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm text-error-400">{error}</span>
</div>
)}
{/* Submit button */}
<button
type="button"
onClick={handleSubmit}
disabled={isPending || !amount || parseFloat(amount) <= 0}
className={`relative w-full h-14 rounded-2xl text-base font-bold transition-colors duration-200 overflow-hidden ${
isPending || !amount || parseFloat(amount) <= 0
? 'bg-dark-700 text-dark-500 cursor-not-allowed'
: isStarsMethod
? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600'
: 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700'
}`}
>
{isPending ? (
<div className="flex items-center justify-center gap-2">
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>{t('common.loading')}</span>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<SparklesIcon />
<span>{t('balance.topUp')}</span>
{displayAmount > 0 && (
<span className="opacity-90">
{formatAmount(displayAmount, currencyDecimals)} {currencySymbol}
</span>
)}
</div>
)}
</button>
</div>
)
// Render modal based on screen size - NO nested components!
const modalContent = isMobileScreen ? (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[9998] bg-black/70"
onClick={handleClose}
/>
{/* Bottom sheet */}
<div
data-modal-content
className="fixed inset-x-0 bottom-0 z-[9999] bg-dark-900 rounded-t-3xl max-h-[90vh] flex flex-col overflow-hidden"
style={{ paddingBottom: safeBottom ? `${safeBottom + 20}px` : 'max(20px, env(safe-area-inset-bottom))' }}
onClick={(e) => e.stopPropagation()}
>
{/* Handle bar */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 rounded-full bg-dark-600" />
</div>
<div className="relative w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50"> <div className="flex items-center justify-between px-5 py-2">
<span className="font-semibold text-dark-100">{methodName}</span> <div className="flex items-center gap-2">
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400"> <WalletIcon />
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <span className="font-bold text-dark-100 text-lg">{t('balance.topUp')}</span>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> </div>
</svg> <button
onClick={handleClose}
className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400 transition-colors"
>
<CloseIcon />
</button> </button>
</div> </div>
<div className="p-4 space-y-3"> {/* Divider */}
{/* Payment options */} <div className="h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent mx-5" />
{hasOptions && method.options && (
<div className="flex gap-2">
{method.options.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setSelectedOption(opt.id)}
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all ${
selectedOption === opt.id
? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300'
}`}
>
{opt.name}
</button>
))}
</div>
)}
{/* Amount input */} {/* Content */}
<div className="relative"> <div className="px-5 py-5 overflow-y-auto">
<input {contentJSX}
ref={inputRef} </div>
type="number" </div>
inputMode="decimal" </>
value={amount} ) : (
onChange={(e) => setAmount(e.target.value)} <div
placeholder={`${formatAmount(minRubles, 0)} ${formatAmount(maxRubles, 0)}`} className="fixed inset-0 bg-black/60 z-[60] flex items-start justify-center p-4 pt-[10vh]"
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" onClick={handleClose}
autoComplete="off" >
/> <div
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-500 font-medium"> data-modal-content
{currencySymbol} className="w-full max-w-md bg-dark-900 rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden"
</span> onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-dark-800/80 to-dark-800/40 border-b border-dark-700/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center text-accent-400">
<WalletIcon />
</div>
<span className="font-bold text-dark-100 text-lg">{t('balance.topUp')}</span>
</div> </div>
{/* Quick amounts */}
{quickAmounts.length > 0 && (
<div className="flex gap-2">
{quickAmounts.map((a) => {
const val = getQuickValue(a)
return (
<button
key={a}
type="button"
onClick={() => { setAmount(val); inputRef.current?.blur() }}
className={`flex-1 py-2 rounded-lg text-sm font-medium ${
amount === val ? 'bg-accent-500 text-white' : 'bg-dark-800 text-dark-300'
}`}
>
{formatAmount(a, 0)}
</button>
)
})}
</div>
)}
{/* Error */}
{error && (
<div className="text-error-400 text-sm text-center py-1">{error}</div>
)}
{/* Submit */}
<button <button
type="button" onClick={handleClose}
onClick={handleSubmit} className="p-2 -mr-1 rounded-xl hover:bg-dark-700 text-dark-400 transition-colors"
disabled={isPending || !amount}
className="btn-primary w-full h-11 text-base font-semibold"
> >
{isPending ? ( <CloseIcon />
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
{t('balance.topUp')}
{amount && parseFloat(amount) > 0 && (
<span className="ml-2 opacity-80">{formatAmount(parseFloat(amount), currencyDecimals)} {currencySymbol}</span>
)}
</>
)}
</button> </button>
</div> </div>
{/* Content */}
<div className="p-6">
{contentJSX}
</div>
</div> </div>
</div> </div>
) )
if (typeof document !== 'undefined') {
return createPortal(modalContent, document.body)
}
return modalContent
} }

View File

@@ -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<string | null>(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 (
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-md text-center">
{/* Icon */}
<div className="mb-8">
<div className="w-24 h-24 mx-auto rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20 flex items-center justify-center">
<svg
className="w-12 h-12 text-blue-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
</div>
</div>
{/* Title */}
<h1 className="text-2xl font-bold text-white mb-4">
{t('blocking.channel.title', 'Подписка на канал')}
</h1>
{/* Message */}
<p className="text-gray-400 mb-8 text-lg">
{channelInfo?.message || t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
</p>
{/* Error message */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-6">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* Buttons */}
<div className="space-y-4">
{/* Open channel button */}
<button
onClick={openChannel}
disabled={!channelInfo?.channel_link}
className="w-full py-4 px-6 bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
{t('blocking.channel.openChannel', 'Открыть канал')}
</button>
{/* Check subscription button */}
<button
onClick={checkSubscription}
disabled={isChecking || cooldown > 0}
className="w-full py-4 px-6 bg-dark-800 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3"
>
{isChecking ? (
<>
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('blocking.channel.checking', 'Проверяем...')}
</>
) : cooldown > 0 ? (
<>
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', { seconds: cooldown })}
</>
) : (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{t('blocking.channel.checkSubscription', 'Проверить подписку')}
</>
)}
</button>
</div>
{/* Hint */}
<p className="text-gray-500 text-sm mt-6">
{t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')}
</p>
</div>
</div>
)
}

View File

@@ -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 (
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-md text-center">
{/* Icon */}
<div className="mb-8">
<div className="w-24 h-24 mx-auto rounded-full bg-dark-800 flex items-center justify-center">
<svg
className="w-12 h-12 text-amber-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z"
/>
</svg>
</div>
</div>
{/* Title */}
<h1 className="text-2xl font-bold text-white mb-4">
{t('blocking.maintenance.title', 'Технические работы')}
</h1>
{/* Message */}
<p className="text-gray-400 mb-6 text-lg">
{maintenanceInfo?.message || t('blocking.maintenance.defaultMessage', 'Сервис временно недоступен. Проводятся технические работы.')}
</p>
{/* Reason */}
{maintenanceInfo?.reason && (
<div className="bg-dark-800/50 rounded-xl p-4 mb-6">
<p className="text-gray-500 text-sm mb-1">
{t('blocking.maintenance.reason', 'Причина')}:
</p>
<p className="text-gray-300">
{maintenanceInfo.reason}
</p>
</div>
)}
{/* Decorative dots */}
<div className="flex items-center justify-center gap-2 mt-8">
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '0ms' }} />
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '300ms' }} />
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '600ms' }} />
</div>
<p className="text-gray-500 text-sm mt-4">
{t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')}
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { default as MaintenanceScreen } from './MaintenanceScreen'
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen'

View File

@@ -15,6 +15,7 @@ import { themeColorsApi } from '../../api/themeColors'
import { promoApi } from '../../api/promo' import { promoApi } from '../../api/promo'
import { useTheme } from '../../hooks/useTheme' import { useTheme } from '../../hooks/useTheme'
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp' import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
import { usePullToRefresh } from '../../hooks/usePullToRefresh'
// Fallback branding from environment variables // Fallback branding from environment variables
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet' const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
@@ -132,6 +133,12 @@ export default function Layout({ children }: LayoutProps) {
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null) const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
const { isFullscreen, 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 // Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({ const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'], 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(() => { useEffect(() => {
if (mobileMenuOpen) { if (!mobileMenuOpen) return
// Scroll to top so header is visible
window.scrollTo({ top: 0, behavior: 'instant' }) const body = document.body
document.body.style.overflow = 'hidden' const html = document.documentElement
} else {
document.body.style.overflow = '' // 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 () => { 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]) }, [mobileMenuOpen])
@@ -287,18 +327,42 @@ export default function Layout({ children }: LayoutProps) {
{/* Animated Background */} {/* Animated Background */}
<AnimatedBackground /> <AnimatedBackground />
{/* Pull to refresh indicator */}
{(isPulling || isRefreshing) && (
<div
className="fixed left-1/2 -translate-x-1/2 z-[100] flex items-center justify-center transition-all duration-200"
style={{
top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`,
opacity: isRefreshing ? 1 : progress,
}}
>
<div className={`w-10 h-10 rounded-full bg-dark-800 border border-dark-700 shadow-lg flex items-center justify-center ${isRefreshing ? 'animate-pulse' : ''}`}>
<svg
className={`w-5 h-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
</div>
)}
{/* Header */} {/* Header */}
<header <header
className="sticky top-0 z-50 glass border-b border-dark-800/50" className="fixed top-0 left-0 right-0 z-50 glass border-b border-dark-800/50"
style={{ style={{
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners) // In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined, paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined,
}} }}
> >
<div className="w-full mx-auto px-4 sm:px-6"> <div className="w-full mx-auto px-4 sm:px-6" onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}>
<div className="flex justify-between items-center h-16 lg:h-20"> <div className="flex justify-between items-center h-16 lg:h-20">
{/* Logo */} {/* Logo */}
<Link to="/" className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}> <Link to="/" onClick={() => setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
<div className="w-10 h-10 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center overflow-hidden shadow-lg shadow-accent-500/20 flex-shrink-0 relative"> <div className="w-10 h-10 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center overflow-hidden shadow-lg shadow-accent-500/20 flex-shrink-0 relative">
{/* Always show letter as fallback */} {/* Always show letter as fallback */}
<span className={`text-white font-bold text-lg sm:text-xl lg:text-2xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}> <span className={`text-white font-bold text-lg sm:text-xl lg:text-2xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
@@ -363,7 +427,10 @@ export default function Layout({ children }: LayoutProps) {
{/* Theme toggle button - only show if both themes are enabled */} {/* Theme toggle button - only show if both themes are enabled */}
{canToggle && ( {canToggle && (
<button <button
onClick={toggleTheme} onClick={() => {
toggleTheme()
setMobileMenuOpen(false)
}}
className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95 className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800 dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50" text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
@@ -381,10 +448,14 @@ export default function Layout({ children }: LayoutProps) {
</button> </button>
)} )}
<PromoDiscountBadge /> <div onClick={() => setMobileMenuOpen(false)}>
<TicketNotificationBell isAdmin={isAdminActive()} /> <PromoDiscountBadge />
</div>
<div onClick={() => setMobileMenuOpen(false)}>
<TicketNotificationBell isAdmin={isAdminActive()} />
</div>
{/* Hide language switcher on mobile when promo is active */} {/* Hide language switcher on mobile when promo is active */}
<div className={isPromoActive ? 'hidden sm:block' : ''}> <div className={isPromoActive ? 'hidden sm:block' : ''} onClick={() => setMobileMenuOpen(false)}>
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
@@ -413,7 +484,10 @@ export default function Layout({ children }: LayoutProps) {
{/* Mobile menu button */} {/* Mobile menu button */}
<button <button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={(e) => {
e.stopPropagation()
setMobileMenuOpen(!mobileMenuOpen)
}}
className="lg:hidden btn-icon" className="lg:hidden btn-icon"
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'} aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'}
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
@@ -426,9 +500,26 @@ export default function Layout({ children }: LayoutProps) {
</header> </header>
{/* Mobile menu - fixed overlay */} {/* Spacer for fixed header - matches header height */}
{isFullscreen ? (
<div
className="flex-shrink-0"
style={{ height: `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` }}
/>
) : (
<div className="flex-shrink-0 h-16 lg:h-20" />
)}
{/* Mobile menu - fixed overlay below header */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<div className="lg:hidden fixed inset-0 z-40 animate-fade-in" style={{ top: '64px' }}> <div
className="lg:hidden fixed inset-x-0 bottom-0 z-40 animate-fade-in"
style={{
top: isFullscreen
? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
: '64px'
}}
>
{/* Backdrop */} {/* Backdrop */}
<div <div
className="absolute inset-0 bg-black/50 backdrop-blur-sm" className="absolute inset-0 bg-black/50 backdrop-blur-sm"
@@ -436,7 +527,7 @@ export default function Layout({ children }: LayoutProps) {
/> />
{/* Menu content */} {/* Menu content */}
<div className="absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto pb-[calc(5rem+env(safe-area-inset-bottom,0px))]"> <div className="mobile-menu-content absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto overscroll-contain pb-[calc(5rem+env(safe-area-inset-bottom,0px))]" style={{ WebkitOverflowScrolling: 'touch' }}>
<div className="max-w-6xl mx-auto px-4 py-4"> <div className="max-w-6xl mx-auto px-4 py-4">
{/* User info */} {/* User info */}
<div className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50"> <div className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50">
@@ -546,6 +637,7 @@ export default function Layout({ children }: LayoutProps) {
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'} className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
> >
<item.icon /> <item.icon />

View File

@@ -0,0 +1,101 @@
import { useEffect, useRef, useState, useCallback } from 'react'
interface UsePullToRefreshOptions {
onRefresh?: () => void | Promise<void>
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),
}
}

View File

@@ -25,13 +25,14 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => {
* Provides fullscreen mode, safe area insets, and other WebApp features * Provides fullscreen mode, safe area insets, and other WebApp features
*/ */
export function useTelegramWebApp() { export function useTelegramWebApp() {
const [isFullscreen, setIsFullscreen] = useState(false) // Initialize synchronously to avoid flash/flicker on first render
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 })
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined 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(() => { useEffect(() => {
if (!webApp) { if (!webApp) {
setIsTelegramWebApp(false) setIsTelegramWebApp(false)

View File

@@ -33,7 +33,8 @@
"contests": "Contests", "contests": "Contests",
"polls": "Polls", "polls": "Polls",
"info": "Info", "info": "Info",
"wheel": "Fortune Wheel" "wheel": "Fortune Wheel",
"menu": "Menu"
}, },
"notifications": { "notifications": {
"ticketNotifications": "Ticket Notifications", "ticketNotifications": "Ticket Notifications",
@@ -185,7 +186,8 @@
"copyLink": "Copy subscription link", "copyLink": "Copy subscription link",
"copied": "Link copied!", "copied": "Link copied!",
"openLink": "Open link", "openLink": "Open link",
"instructions": "Instructions" "instructions": "Instructions",
"changeApp": "Change app"
}, },
"myDevices": "My Devices", "myDevices": "My Devices",
"noDevices": "No connected devices", "noDevices": "No connected devices",
@@ -251,6 +253,8 @@
"currentBalance": "Current Balance", "currentBalance": "Current Balance",
"topUp": "Top Up", "topUp": "Top Up",
"topUpBalance": "Top Up Balance", "topUpBalance": "Top Up Balance",
"enterAmount": "Enter amount",
"paymentMethod": "Payment Method",
"paymentMethods": "Payment Methods", "paymentMethods": "Payment Methods",
"transactionHistory": "Transaction History", "transactionHistory": "Transaction History",
"noTransactions": "No transactions", "noTransactions": "No transactions",
@@ -1313,5 +1317,24 @@
"hours": "h", "hours": "h",
"minutes": "m" "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."
}
} }
} }

View File

@@ -183,6 +183,8 @@
"currentBalance": "موجودی فعلی", "currentBalance": "موجودی فعلی",
"topUp": "شارژ", "topUp": "شارژ",
"topUpBalance": "شارژ موجودی", "topUpBalance": "شارژ موجودی",
"enterAmount": "مقدار را وارد کنید",
"paymentMethod": "روش پرداخت",
"paymentMethods": { "paymentMethods": {
"yookassa": { "yookassa": {
"name": "YooKassa", "name": "YooKassa",
@@ -794,5 +796,24 @@
"hours": "ساعت", "hours": "ساعت",
"minutes": "دقیقه" "minutes": "دقیقه"
} }
},
"blocking": {
"maintenance": {
"title": "تعمیرات",
"defaultMessage": "سرویس موقتاً در دسترس نیست. تعمیرات در حال انجام است.",
"reason": "دلیل",
"waitMessage": "لطفاً صبر کنید..."
},
"channel": {
"title": "اشتراک کانال",
"defaultMessage": "لطفاً برای ادامه در کانال ما عضو شوید",
"openChannel": "باز کردن کانال",
"checkSubscription": "بررسی اشتراک",
"checking": "در حال بررسی...",
"waitSeconds": "{{seconds}} ثانیه صبر کنید",
"hint": "پس از عضویت دکمه بررسی را بزنید",
"notSubscribed": "شما هنوز در کانال عضو نشده‌اید",
"checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید."
}
} }
} }

View File

@@ -33,7 +33,8 @@
"contests": "Конкурсы", "contests": "Конкурсы",
"polls": "Опросы", "polls": "Опросы",
"info": "Информация", "info": "Информация",
"wheel": "Колесо удачи" "wheel": "Колесо удачи",
"menu": "Меню"
}, },
"notifications": { "notifications": {
"ticketNotifications": "Уведомления о тикетах", "ticketNotifications": "Уведомления о тикетах",
@@ -185,7 +186,8 @@
"copyLink": "Скопировать ссылку", "copyLink": "Скопировать ссылку",
"copied": "Ссылка скопирована!", "copied": "Ссылка скопирована!",
"openLink": "Открыть ссылку", "openLink": "Открыть ссылку",
"instructions": "Инструкция" "instructions": "Инструкция",
"changeApp": "Сменить приложение"
}, },
"myDevices": "Мои устройства", "myDevices": "Мои устройства",
"noDevices": "Нет подключенных устройств", "noDevices": "Нет подключенных устройств",
@@ -251,6 +253,8 @@
"currentBalance": "Текущий баланс", "currentBalance": "Текущий баланс",
"topUp": "Пополнить", "topUp": "Пополнить",
"topUpBalance": "Пополнение баланса", "topUpBalance": "Пополнение баланса",
"enterAmount": "Введите сумму",
"paymentMethod": "Способ оплаты",
"paymentMethods": "Способы оплаты", "paymentMethods": "Способы оплаты",
"transactionHistory": "История операций", "transactionHistory": "История операций",
"noTransactions": "Нет операций", "noTransactions": "Нет операций",
@@ -1471,5 +1475,24 @@
"hours": "ч", "hours": "ч",
"minutes": "м" "minutes": "м"
} }
},
"blocking": {
"maintenance": {
"title": "Технические работы",
"defaultMessage": "Сервис временно недоступен. Проводятся технические работы.",
"reason": "Причина",
"waitMessage": "Пожалуйста, подождите..."
},
"channel": {
"title": "Подписка на канал",
"defaultMessage": "Для продолжения работы подпишитесь на наш канал",
"openChannel": "Открыть канал",
"checkSubscription": "Проверить подписку",
"checking": "Проверяем...",
"waitSeconds": "Подождите {{seconds}} сек.",
"hint": "После подписки нажмите кнопку проверки",
"notSubscribed": "Вы ещё не подписались на канал",
"checkError": "Ошибка проверки. Попробуйте позже."
}
} }
} }

View File

@@ -31,7 +31,8 @@
"contests": "活动", "contests": "活动",
"polls": "问卷", "polls": "问卷",
"info": "信息", "info": "信息",
"wheel": "幸运转盘" "wheel": "幸运转盘",
"menu": "菜单"
}, },
"notifications": { "notifications": {
"ticketNotifications": "工单通知", "ticketNotifications": "工单通知",
@@ -183,6 +184,8 @@
"currentBalance": "当前余额", "currentBalance": "当前余额",
"topUp": "充值", "topUp": "充值",
"topUpBalance": "充值余额", "topUpBalance": "充值余额",
"enterAmount": "输入金额",
"paymentMethod": "支付方式",
"paymentMethods": { "paymentMethods": {
"yookassa": { "yookassa": {
"name": "YooKassa", "name": "YooKassa",
@@ -794,5 +797,24 @@
"hours": "时", "hours": "时",
"minutes": "分" "minutes": "分"
} }
},
"blocking": {
"maintenance": {
"title": "系统维护",
"defaultMessage": "服务暂时不可用。正在进行系统维护。",
"reason": "原因",
"waitMessage": "请稍候..."
},
"channel": {
"title": "频道订阅",
"defaultMessage": "请订阅我们的频道以继续",
"openChannel": "打开频道",
"checkSubscription": "检查订阅",
"checking": "检查中...",
"waitSeconds": "请等待 {{seconds}} 秒",
"hint": "订阅后点击检查按钮",
"notSubscribed": "您还没有订阅该频道",
"checkError": "检查失败。请稍后重试。"
}
} }
} }

View File

@@ -32,6 +32,12 @@ const ChevronRightIcon = () => (
</svg> </svg>
) )
const RefreshIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
)
// Check if device might be low-performance (Telegram WebApp on mobile) // Check if device might be low-performance (Telegram WebApp on mobile)
const isLowPerfDevice = (() => { const isLowPerfDevice = (() => {
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp 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 const hasNoSubscription = !subscription && !subLoading && subError
// Show onboarding for new users after data loads // Show onboarding for new users after data loads
@@ -223,9 +270,19 @@ export default function Dashboard() {
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm text-dark-500 mb-1">{t('subscription.traffic')}</div> <div className="flex items-center gap-2 mb-1">
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
<button
onClick={() => refreshTrafficMutation.mutate()}
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
className="p-1 rounded-full hover:bg-dark-700/50 text-dark-400 hover:text-accent-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')}
>
<RefreshIcon className={`w-3.5 h-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="text-dark-100 font-medium"> <div className="text-dark-100 font-medium">
{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
</div> </div>
</div> </div>
<div> <div>
@@ -248,12 +305,14 @@ export default function Dashboard() {
<div className="mt-6"> <div className="mt-6">
<div className="flex justify-between text-sm mb-2"> <div className="flex justify-between text-sm mb-2">
<span className="text-dark-400">{t('subscription.trafficUsed')}</span> <span className="text-dark-400">{t('subscription.trafficUsed')}</span>
<span className="text-dark-300">{subscription.traffic_used_percent.toFixed(1)}%</span> <span className="text-dark-300">
{(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(1)}%
</span>
</div> </div>
<div className="progress-bar"> <div className="progress-bar">
<div <div
className={`progress-fill ${getTrafficColor(subscription.traffic_used_percent)}`} className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
style={{ width: `${Math.min(subscription.traffic_used_percent, 100)}%` }} style={{ width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%` }}
/> />
</div> </div>
</div> </div>

View File

@@ -97,6 +97,8 @@ export default function Subscription() {
const [devicesToAdd, setDevicesToAdd] = useState(1) const [devicesToAdd, setDevicesToAdd] = useState(1)
const [showTrafficTopup, setShowTrafficTopup] = useState(false) const [showTrafficTopup, setShowTrafficTopup] = useState(false)
const [selectedTrafficPackage, setSelectedTrafficPackage] = useState<number | null>(null) const [selectedTrafficPackage, setSelectedTrafficPackage] = useState<number | null>(null)
const [showServerManagement, setShowServerManagement] = useState(false)
const [selectedServersToUpdate, setSelectedServersToUpdate] = useState<string[]>([])
const { data: subscription, isLoading } = useQuery({ const { data: subscription, isLoading } = useQuery({
queryKey: ['subscription'], 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 // Auto-scroll to switch tariff modal when it appears
useEffect(() => { useEffect(() => {
if (switchTariffId && switchModalRef.current) { if (switchTariffId && switchModalRef.current) {
@@ -912,6 +939,212 @@ export default function Subscription() {
)} )}
</div> </div>
)} )}
{/* Server Management - only in classic mode */}
{!isTariffsMode && (
<div className="mt-4">
{!showServerManagement ? (
<button
onClick={() => setShowServerManagement(true)}
className="w-full p-4 rounded-xl bg-dark-800/30 border border-dark-700/50 text-left hover:border-dark-600 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">Управление серверами</div>
<div className="text-sm text-dark-400 mt-1">
Подключено серверов: {subscription.servers?.length || 0}
</div>
</div>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
) : (
<div className="bg-dark-800/30 rounded-xl p-5 border border-dark-700/50">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-dark-100">Управление серверами</h3>
<button
onClick={() => {
setShowServerManagement(false)
setSelectedServersToUpdate([])
}}
className="text-dark-400 hover:text-dark-200 text-sm"
>
</button>
</div>
{countriesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : countriesData && countriesData.countries.length > 0 ? (
<div className="space-y-4">
<div className="text-xs text-dark-500 p-2 bg-dark-700/30 rounded-lg">
подключено будет добавлено (платно) будет отключено
</div>
{countriesData.discount_percent > 0 && (
<div className="text-xs text-success-400 p-2 bg-success-500/10 rounded-lg border border-success-500/30">
🎁 Ваша скидка на серверы: -{countriesData.discount_percent}%
</div>
)}
<div className="space-y-2 max-h-64 overflow-y-auto">
{countriesData.countries.map((country) => {
const isCurrentlyConnected = country.is_connected
const isSelected = selectedServersToUpdate.includes(country.uuid)
const willBeAdded = !isCurrentlyConnected && isSelected
const willBeRemoved = isCurrentlyConnected && !isSelected
return (
<button
key={country.uuid}
onClick={() => {
if (isSelected) {
setSelectedServersToUpdate(prev => prev.filter(u => u !== country.uuid))
} else {
setSelectedServersToUpdate(prev => [...prev, country.uuid])
}
}}
disabled={!country.is_available && !isCurrentlyConnected}
className={`w-full p-3 rounded-xl border text-left transition-all flex items-center justify-between ${
isSelected
? willBeAdded
? 'border-success-500 bg-success-500/10'
: 'border-accent-500 bg-accent-500/10'
: willBeRemoved
? 'border-error-500/50 bg-error-500/5'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
} ${!country.is_available && !isCurrentlyConnected ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3">
<span className="text-lg">
{willBeAdded ? '' : willBeRemoved ? '' : isSelected ? '✅' : '⚪'}
</span>
<div>
<div className="font-medium text-dark-100 flex items-center gap-2">
{country.name}
{country.has_discount && !isCurrentlyConnected && (
<span className="text-xs px-1.5 py-0.5 rounded bg-success-500/20 text-success-400">
-{country.discount_percent}%
</span>
)}
</div>
{willBeAdded && (
<div className="text-xs text-success-400">
+{formatPrice(country.price_kopeks)} (за {countriesData.days_left} дн.)
{country.has_discount && (
<span className="ml-1 line-through text-dark-500">
{formatPrice(Math.round(country.base_price_kopeks * countriesData.days_left / 30))}
</span>
)}
</div>
)}
{!willBeAdded && !isCurrentlyConnected && (
<div className="text-xs text-dark-500">
{formatPrice(country.price_per_month_kopeks)}/мес
{country.has_discount && (
<span className="ml-1 line-through text-dark-600">
{formatPrice(country.base_price_kopeks)}
</span>
)}
</div>
)}
{!country.is_available && !isCurrentlyConnected && (
<div className="text-xs text-dark-500">Недоступен</div>
)}
</div>
</div>
{country.country_code && (
<span className="text-xl">{getFlagEmoji(country.country_code)}</span>
)}
</button>
)
})}
</div>
{(() => {
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 ? (
<div className="space-y-3 pt-3 border-t border-dark-700/50">
{added.length > 0 && (
<div className="text-sm">
<span className="text-success-400">Добавить:</span>{' '}
<span className="text-dark-300">
{addedServers.map(s => s.name).join(', ')}
</span>
</div>
)}
{removed.length > 0 && (
<div className="text-sm">
<span className="text-error-400">Отключить:</span>{' '}
<span className="text-dark-300">
{countriesData.countries.filter(c => removed.includes(c.uuid)).map(s => s.name).join(', ')}
</span>
</div>
)}
{totalCost > 0 && (
<div className="text-center">
<div className="text-sm text-dark-400">К оплате (пропорционально оставшимся дням):</div>
<div className="text-xl font-bold text-accent-400">{formatPrice(totalCost)}</div>
</div>
)}
{totalCost > 0 && !hasEnoughBalance && missingAmount > 0 && (
<InsufficientBalancePrompt
missingAmountKopeks={missingAmount}
compact
/>
)}
<button
onClick={() => updateCountriesMutation.mutate(selectedServersToUpdate)}
disabled={updateCountriesMutation.isPending || selectedServersToUpdate.length === 0 || (totalCost > 0 && !hasEnoughBalance)}
className="btn-primary w-full py-3"
>
{updateCountriesMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
</span>
) : (
'Применить изменения'
)}
</button>
</div>
) : (
<div className="text-sm text-dark-500 text-center py-2">
Выберите серверы для подключения или отключения
</div>
)
})()}
{updateCountriesMutation.isError && (
<div className="text-sm text-error-400 text-center">
{getErrorMessage(updateCountriesMutation.error)}
</div>
)}
</div>
) : (
<div className="text-sm text-dark-400 text-center py-4">
Нет доступных серверов для управления
</div>
)}
</div>
)}
</div>
)}
</div> </div>
)} )}

47
src/store/blocking.ts Normal file
View File

@@ -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<BlockingState>((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,
}),
}))

View File

@@ -203,6 +203,10 @@
background-color: rgba(254, 249, 240, 0.5) !important; /* champagne-100/50 */ 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 { .light .bg-dark-950 {
background-color: #F7E7CE !important; /* champagne-200 */ background-color: #F7E7CE !important; /* champagne-200 */
} }
@@ -958,31 +962,83 @@
input[type="range"] { input[type="range"] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
height: 8px; height: 12px;
border-radius: 4px; border-radius: 6px;
outline: none; 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 { input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 18px; width: 24px;
height: 18px; height: 24px;
border-radius: 50%; border-radius: 50%;
background: white; background: white;
cursor: pointer; cursor: pointer;
border: 2px solid rgba(0, 0, 0, 0.2); border: 2px solid rgba(0, 0, 0, 0.15);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); 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 { input[type="range"]::-moz-range-thumb {
width: 18px; width: 24px;
height: 18px; height: 24px;
border-radius: 50%; border-radius: 50%;
background: white; background: white;
cursor: pointer; cursor: pointer;
border: 2px solid rgba(0, 0, 0, 0.2); border: 2px solid rgba(0, 0, 0, 0.15);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); 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 */ /* 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%); 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 */ /* Disable animations for reduced motion preference */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.wave-blob { .wave-blob {