Merge pull request #66 from enkinvsh/bento-grids

feat(ui): Global Redesign "Bento Grids" & Visual Polish
This commit is contained in:
PEDZEO
2026-01-20 23:39:51 +03:00
committed by GitHub
42 changed files with 3118 additions and 27758 deletions

3
.gitignore vendored
View File

@@ -43,3 +43,6 @@ coverage/
tmp/
temp/
miniapp/
# AI & Agents context
.ai/

View File

@@ -2,7 +2,7 @@
<html lang="ru" class="dark">
<head>
<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="theme-color" content="#0a0f1a" />
<meta name="apple-mobile-web-app-capable" content="yes" />

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -11,6 +11,10 @@ export interface AnimationEnabled {
enabled: boolean
}
export interface FullscreenEnabled {
enabled: boolean
}
const BRANDING_CACHE_KEY = 'cabinet_branding'
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
@@ -121,4 +125,21 @@ export const brandingApi = {
const response = await apiClient.patch<AnimationEnabled>('/cabinet/branding/animation', { enabled })
return response.data
},
// Get fullscreen enabled (public, no auth required)
getFullscreenEnabled: async (): Promise<FullscreenEnabled> => {
try {
const response = await apiClient.get<FullscreenEnabled>('/cabinet/branding/fullscreen')
return response.data
} catch {
// If endpoint doesn't exist, default to disabled
return { enabled: false }
}
},
// Update fullscreen enabled (admin only)
updateFullscreenEnabled: async (enabled: boolean): Promise<FullscreenEnabled> => {
const response = await apiClient.patch<FullscreenEnabled>('/cabinet/branding/fullscreen', { enabled })
return response.data
},
}

View File

@@ -1,5 +1,6 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token'
import { useBlockingStore } from '../store/blocking'
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
@@ -87,12 +88,57 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
return config
})
// Response interceptor - handle 401 as fallback
// Custom error types for special handling
export interface MaintenanceError {
code: 'maintenance'
message: string
reason?: string
}
export interface ChannelSubscriptionError {
code: 'channel_subscription_required'
message: string
channel_link?: string
}
export function isMaintenanceError(error: unknown): error is { response: { status: 503, data: { detail: MaintenanceError } } } {
if (!error || typeof error !== 'object') return false
const err = error as AxiosError<{ detail: MaintenanceError }>
return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance'
}
export function isChannelSubscriptionError(error: unknown): error is { response: { status: 403, data: { detail: ChannelSubscriptionError } } } {
if (!error || typeof error !== 'object') return false
const err = error as AxiosError<{ detail: ChannelSubscriptionError }>
return err.response?.status === 403 && err.response?.data?.detail?.code === 'channel_subscription_required'
}
// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription)
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// Handle maintenance mode (503)
if (isMaintenanceError(error)) {
const detail = (error.response?.data as { detail: MaintenanceError }).detail
useBlockingStore.getState().setMaintenance({
message: detail.message,
reason: detail.reason,
})
return Promise.reject(error)
}
// Handle channel subscription required (403)
if (isChannelSubscriptionError(error)) {
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail
useBlockingStore.getState().setChannelSubscription({
message: detail.message,
channel_link: detail.channel_link,
})
return Promise.reject(error)
}
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true

View File

@@ -156,14 +156,19 @@ export const subscriptionApi = {
uuid: string
name: string
country_code: string | null
base_price_kopeks: number
price_kopeks: number
price_per_month_kopeks: number
price_rubles: number
is_available: boolean
is_connected: boolean
is_trial_eligible: boolean
has_discount: boolean
discount_percent: number
}>
connected_count: number
has_subscription: boolean
days_left: number
discount_percent: number
}> => {
const response = await apiClient.get('/cabinet/subscription/countries')
return response.data
@@ -321,4 +326,24 @@ export const subscriptionApi = {
const response = await apiClient.put('/cabinet/subscription/traffic', { gb })
return response.data
},
// Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds)
refreshTraffic: async (): Promise<{
success: boolean
cached: boolean
rate_limited?: boolean
retry_after_seconds?: number
source?: string
traffic_used_bytes: number
traffic_used_gb: number
traffic_limit_bytes: number
traffic_limit_gb: number
traffic_used_percent: number
is_unlimited: boolean
lifetime_used_bytes?: number
lifetime_used_gb?: number
}> => {
const response = await apiClient.post('/cabinet/subscription/refresh-traffic')
return response.data
},
}

View File

@@ -4,16 +4,11 @@ import { brandingApi } from '../api/branding'
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled'
// Detect low-performance device (mobile in Telegram WebApp)
// Detect if user prefers reduced motion
const isLowPerformance = (): boolean => {
// Check if running in Telegram WebApp
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
// Check if mobile
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
// Check for reduced motion preference
// Only check for reduced motion preference - let animation run everywhere else
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
return prefersReducedMotion || (isTelegramWebApp && isMobile)
return prefersReducedMotion
}
// Get cached value from localStorage

View File

@@ -1,192 +1,185 @@
import { useState, useMemo, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { subscriptionApi } from '../api/subscription'
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
import type { AppInfo, AppConfig, LocalizedText } from '../types'
interface ConnectionModalProps {
onClose: () => void
}
// Platform SVG Icons
const IosIcon = () => (
<svg className="w-8 h-8" 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"/>
const CloseIcon = () => (
<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" />
</svg>
)
const AndroidIcon = () => (
<svg className="w-8 h-8" 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"/>
const CopyIcon = () => (
<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" />
</svg>
)
const WindowsIcon = () => (
<svg className="w-8 h-8" 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"/>
const CheckIcon = () => (
<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" />
</svg>
)
const MacosIcon = () => (
<svg className="w-8 h-8" 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 2v10h16V6H4zm8 2.5a1 1 0 011 1v3a1 1 0 11-2 0v-3a1 1 0 011-1z"/>
const LinkIcon = () => (
<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" />
</svg>
)
const LinuxIcon = () => (
<svg className="w-8 h-8" 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"/>
const ChevronIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
)
const TvIcon = () => (
<svg className="w-8 h-8" 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"/>
<line x1="12" y1="17" x2="12" y2="13"/>
const BackIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
)
// Platform icon components map
const platformIconComponents: Record<string, React.FC> = {
ios: IosIcon,
android: AndroidIcon,
macos: MacosIcon,
windows: WindowsIcon,
linux: LinuxIcon,
androidTV: TvIcon,
appleTV: TvIcon,
const HappIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
<path d="M22.3264 3H12.3611L9.44444 20.1525L21.3542 8.22034L22.3264 3Z"/>
<path d="M10.9028 20.1525L22.8125 8.22034L20.8681 21.1469H28.4028L27.9167 21.6441L20.8681 28.8531H19.4097V30.5932L7.5 42.5254L10.9028 20.1525Z"/>
<path d="M41.0417 8.22034L28.8889 20.1525L31.684 3H41.7708L41.0417 8.22034Z"/>
<path d="M30.3472 20.1525L42.5 8.22034L38.6111 30.3446L26.9444 42.5254L29.0104 28.8531H22.3264L29.6181 21.1469H30.3472V20.1525Z"/>
<path d="M40.0694 30.3446L28.4028 42.5254L27.9167 47H37.8819L40.0694 30.3446Z"/>
<path d="M18.6806 47H8.47222L8.95833 42.5254L20.8681 30.5932L18.6806 47Z"/>
</svg>
)
const ClashMetaIcon = () => (
<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"/>
</svg>
)
const ShadowrocketIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.2394 36.832L16.5386 39.568C16.5386 39.568 13.7182 36.832 11.8379 33.184C9.95756 29.536 16.5386 23.152 16.5386 23.152M21.2394 36.832H28.7606M21.2394 36.832C21.2394 36.832 15.5985 24.064 17.4788 16.768C19.3591 9.472 25 4 25 4C25 4 30.6409 9.472 32.5212 16.768C34.4015 24.064 28.7606 36.832 28.7606 36.832M28.7606 36.832L33.4614 39.568C33.4614 39.568 36.2818 36.832 38.1621 33.184C40.0424 29.536 33.4614 23.152 33.4614 23.152M25 46L26.8803 40.528H23.1197L25 46ZM25.9402 17.68C26.4594 18.1837 26.4594 19.0003 25.9402 19.504C25.4209 20.0077 24.5791 20.0077 24.0598 19.504C23.5406 19.0003 23.5406 18.1837 24.0598 17.68C24.5791 17.1763 25.4209 17.1763 25.9402 17.68Z"/>
</svg>
)
const StreisandIcon = () => (
<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.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>
)
const getAppIcon = (appName: string): React.ReactNode => {
const name = appName.toLowerCase()
if (name.includes('happ')) return <HappIcon />
if (name.includes('shadowrocket') || name.includes('rocket')) return <ShadowrocketIcon />
if (name.includes('streisand')) return <StreisandIcon />
if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return <ClashMetaIcon />
return <span className="text-lg">📦</span>
}
// Platform order for display
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
// Dangerous schemes that should never be allowed
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
/**
* Validate URL to prevent XSS via javascript: and other dangerous schemes
* Only allows http, https, and known app store URLs
*/
function isValidExternalUrl(url: string | undefined): boolean {
if (!url) return false
const lowerUrl = url.toLowerCase().trim()
// Block dangerous schemes
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
return false
}
// Allow only http/https URLs
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')
}
/**
* Validate deep link URL - blocks dangerous schemes only
* Allows any custom app scheme (clash://, hiddify://, etc.)
*/
function isValidDeepLink(url: string | undefined): boolean {
if (!url) return false
const lowerUrl = url.toLowerCase().trim()
// Block dangerous schemes
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
return false
}
// Allow any URL that has a scheme (contains ://)
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false
return lowerUrl.includes('://')
}
// Detect user's platform from user agent
function detectPlatform(): string | null {
if (typeof window === 'undefined' || !navigator?.userAgent) {
return null
}
if (typeof window === 'undefined' || !navigator?.userAgent) return null
const ua = navigator.userAgent.toLowerCase()
// Check for mobile devices first
if (/iphone|ipad|ipod/.test(ua)) {
return 'ios'
}
if (/android/.test(ua)) {
// Check if it's Android TV
if (/tv|television|smart-tv|smarttv/.test(ua)) {
return 'androidTV'
}
return 'android'
}
// Desktop platforms
if (/macintosh|mac os x/.test(ua)) {
return 'macos'
}
if (/windows/.test(ua)) {
return 'windows'
}
if (/linux/.test(ua)) {
return 'linux'
}
if (/iphone|ipad|ipod/.test(ua)) return 'ios'
if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android'
if (/macintosh|mac os x/.test(ua)) return 'macos'
if (/windows/.test(ua)) return 'windows'
if (/linux/.test(ua)) return 'linux'
return null
}
function useIsMobile() {
// Initialize synchronously to avoid flash between desktop/mobile layouts
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false
return window.innerWidth < 768
})
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
return isMobile
}
export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const { t, i18n } = useTranslation()
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null)
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null)
const [copied, setCopied] = useState(false)
const [detectedPlatform, setDetectedPlatform] = useState<string | null>(null)
const [showAppSelector, setShowAppSelector] = useState(false)
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
const isMobileScreen = useIsMobile()
// Use mobile layout only on small screens, even in Telegram Desktop
const isMobile = isMobileScreen
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
// In fullscreen mode, add +45px for Telegram native controls (close/menu buttons in corners)
const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0
const { data: appConfig, isLoading, error } = useQuery<AppConfig>({
queryKey: ['appConfig'],
queryFn: () => subscriptionApi.getAppConfig(),
})
// Auto-detect platform on mount
useEffect(() => {
const detected = detectPlatform()
setDetectedPlatform(detected)
setDetectedPlatform(detectPlatform())
}, [])
useEffect(() => {
if (!appConfig?.platforms || selectedApp) return
const platform = detectedPlatform || platformOrder.find(p => appConfig.platforms[p]?.length > 0)
if (!platform || !appConfig.platforms[platform]?.length) return
const apps = appConfig.platforms[platform]
const app = apps.find(a => a.isFeatured) || apps[0]
if (app) setSelectedApp(app)
}, [appConfig, detectedPlatform, selectedApp])
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [])
// Helper to get localized text
const getLocalizedText = (text: LocalizedText | undefined): string => {
if (!text) return ''
const lang = i18n.language || 'en'
return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''
}
// Get platform name
const getPlatformName = (platformKey: string): string => {
if (!appConfig?.platformNames?.[platformKey]) {
return platformKey
}
return getLocalizedText(appConfig.platformNames[platformKey])
}
// Get available platforms sorted (detected platform first)
const availablePlatforms = useMemo(() => {
if (!appConfig?.platforms) return []
const available = platformOrder.filter(
(key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0
)
// Move detected platform to the front
const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0)
if (detectedPlatform && available.includes(detectedPlatform)) {
const filtered = available.filter(p => p !== detectedPlatform)
return [detectedPlatform, ...filtered]
return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)]
}
return available
}, [appConfig, detectedPlatform])
// Get apps for selected platform
const platformApps = useMemo(() => {
if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return []
return appConfig.platforms[selectedPlatform]
}, [selectedPlatform, appConfig])
// Copy subscription link
const copySubscriptionLink = async () => {
if (!appConfig?.subscriptionUrl) return
try {
@@ -194,7 +187,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = appConfig.subscriptionUrl
document.body.appendChild(textarea)
@@ -206,402 +198,289 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
}
}
// Handle deep link click - use miniapp redirect page like in miniapp index.html
const handleConnect = (app: AppInfo) => {
// Validate deep link to prevent XSS
if (!app.deepLink || !isValidDeepLink(app.deepLink)) {
console.warn('Invalid or missing deep link:', app.deepLink)
return
}
if (!app.deepLink || !isValidDeepLink(app.deepLink)) return
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}`
// Check if it's a custom URL scheme (not http/https)
const isCustomScheme = !/^https?:\/\//i.test(app.deepLink)
const tg = (window as any).Telegram?.WebApp
if (isCustomScheme && tg?.openLink) {
// For custom URL schemes - open redirect page in external browser via Telegram
// try_browser: true - показывает диалог для перехода во внешний браузер (важно для мобильных)
const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp
if (tg?.openLink) {
try {
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true })
return
} catch (e) {
console.warn('tg.openLink failed:', e)
}
} catch { /* fallback */ }
}
// Fallback - direct navigation
window.location.href = redirectUrl
}
// Modal wrapper classes - bottom sheet on mobile, centered on desktop
const modalOverlayClass = "fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center sm:justify-center"
const modalCardClass = "w-full sm:max-w-lg sm:mx-4 bg-dark-850 sm:rounded-2xl rounded-t-2xl rounded-b-none border-t border-x sm:border border-dark-700/50 shadow-2xl overflow-hidden"
const modalContentClass = "p-4 sm:p-6 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6"
// Allow the sheet to almost fill the viewport height on phones
const modalScrollableClass = "h-[calc(100vh-1rem)] sm:h-auto sm:max-h-[85vh]"
// Desktop modal wrapper - compact centered modal with max height
const DesktopWrapper = ({ children }: { children: React.ReactNode }) => (
<div
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[60] flex items-center justify-center p-4 animate-fade-in"
onClick={onClose}
>
<div
className="relative w-full max-w-md max-h-[85vh] bg-dark-900/95 backdrop-blur-xl rounded-3xl border border-dark-700/50 shadow-2xl flex flex-col overflow-hidden animate-scale-in"
onClick={e => e.stopPropagation()}
>
{/* Desktop close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 p-2 rounded-xl bg-dark-800/80 hover:bg-dark-700 text-dark-400 hover:text-dark-200 transition-colors"
>
<CloseIcon />
</button>
{children}
</div>
</div>
)
// Mobile fullscreen wrapper - like React Native Modal with animationType="slide"
// Use portal to render directly in body, avoiding transform/filter issues with fixed positioning
const MobileWrapper = ({ children }: { children: React.ReactNode }) => {
const content = (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[9998] bg-black/50 animate-fade-in" onClick={onClose} />
{/* Modal - fullscreen overlay */}
<div
className="fixed inset-0 z-[9999] bg-dark-900/95 backdrop-blur-xl flex flex-col animate-slide-up"
style={{
paddingTop: safeTop ? `${safeTop}px` : 'env(safe-area-inset-top, 0px)',
paddingBottom: safeBottom ? `${safeBottom}px` : 'env(safe-area-inset-bottom, 0px)'
}}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute right-4 z-10 p-2.5 rounded-full bg-dark-800/80 text-dark-200 active:bg-dark-700"
style={{ top: safeTop ? `${safeTop + 16}px` : 'calc(env(safe-area-inset-top, 0px) + 16px)' }}
>
<CloseIcon />
</button>
{children}
</div>
</>
)
if (typeof document !== 'undefined') {
return createPortal(content, document.body)
}
return content
}
const Wrapper = isMobile ? MobileWrapper : DesktopWrapper
// Loading
if (isLoading) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
</div>
<Wrapper>
<div className={`${isMobile ? 'flex-1' : ''} flex items-center justify-center p-12`}>
<div className="w-10 h-10 border-3 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
</div>
</Wrapper>
)
}
// Error
if (error || !appConfig) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="text-center py-8">
<p className="text-error-400">{t('common.error')}</p>
<button onClick={onClose} className="btn-secondary mt-4">
{t('common.close')}
</button>
</div>
</div>
<Wrapper>
<div className={`${isMobile ? 'flex-1' : ''} flex flex-col items-center justify-center p-8 text-center`}>
<div className="text-5xl mb-4">😕</div>
<p className="text-dark-300 text-lg mb-6">{t('common.error')}</p>
<button onClick={onClose} className="btn-primary px-8 py-3 text-base">{t('common.close')}</button>
</div>
</div>
</Wrapper>
)
}
// No subscription
if (!appConfig.hasSubscription) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={modalCardClass} onClick={(e) => e.stopPropagation()}>
<div className={modalContentClass}>
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-dark-100">
{t('subscription.connection.title')}
</h2>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="text-center py-8">
<p className="text-dark-400">{t('subscription.connection.noSubscription')}</p>
</div>
</div>
<Wrapper>
<div className={`${isMobile ? 'flex-1' : ''} flex flex-col items-center justify-center p-8 text-center`}>
<div className="text-5xl mb-4">📱</div>
<h3 className="font-bold text-dark-100 text-xl mb-2">{t('subscription.connection.title')}</h3>
<p className="text-dark-400 mb-6">{t('subscription.connection.noSubscription')}</p>
<button onClick={onClose} className="btn-primary px-8 py-3 text-base">{t('common.close')}</button>
</div>
</div>
</Wrapper>
)
}
// Step 1: Select platform
if (!selectedPlatform) {
// App selector
if (showAppSelector) {
const platformNames: Record<string, string> = {
ios: 'iOS',
android: 'Android',
windows: 'Windows',
macos: 'macOS',
linux: 'Linux',
androidTV: 'Android TV',
appleTV: 'Apple TV'
}
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<h2 className="text-lg font-semibold text-dark-100">
{t('subscription.connection.title')}
</h2>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<Wrapper>
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-dark-800">
<button onClick={() => setShowAppSelector(false)} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300">
<BackIcon />
</button>
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.selectApp')}</h2>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4">
<p className="text-dark-400 text-sm sm:text-base mb-3 sm:mb-4">{t('subscription.connection.selectDevice')}</p>
{/* 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
<div className="grid grid-cols-2 gap-2 sm:gap-3">
{availablePlatforms.map((platform) => {
const IconComponent = platformIconComponents[platform]
return (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`p-3 sm:p-4 rounded-xl border transition-all text-left group ${
platform === detectedPlatform
? 'bg-accent-500/10 border-accent-500/50 hover:border-accent-500'
: 'bg-dark-800/50 border-dark-700 hover:border-accent-500/50 hover:bg-dark-700/50'
}`}
>
<div className="flex items-start justify-between">
<div className={`${
platform === detectedPlatform ? 'text-accent-400' : 'text-dark-400 group-hover:text-dark-200'
} transition-colors`}>
{IconComponent && <IconComponent />}
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/15 ring-2 ring-accent-500/50'
: 'bg-dark-800/40 hover:bg-dark-800/70 active:bg-dark-800'
}`}
>
<div className="w-10 h-10 rounded-lg bg-dark-700 flex items-center justify-center text-dark-200">
{getAppIcon(app.name)}
</div>
{platform === detectedPlatform && (
<span className="px-2 py-0.5 rounded-full text-xs bg-accent-500/20 text-accent-400">
{t('subscription.connection.yourDevice')}
<span className="font-medium text-dark-100 flex-1 text-left">{app.name}</span>
{app.isFeatured && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-accent-500/20 text-accent-400">
{t('subscription.connection.featured')}
</span>
)}
</div>
<p className="text-dark-200 font-medium mt-2">{getPlatformName(platform)}</p>
</button>
)
})}
</div>
</div>
{/* Footer - fixed with safe area */}
<div className="flex-shrink-0 p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6 border-t border-dark-700/50">
<button
onClick={copySubscriptionLink}
className="w-full p-2.5 sm:p-3 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 transition-all flex items-center justify-center gap-2 text-sm sm:text-base"
>
{copied ? (
<>
<svg className="w-5 h-5 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-success-400">{t('subscription.connection.copied')}</span>
</>
) : (
<>
<svg className="w-5 h-5 text-dark-400" 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" />
</svg>
<span className="text-dark-300">{t('subscription.connection.copyLink')}</span>
</>
)}
</button>
</div>
</button>
))}
</div>
</div>
)
})}
</div>
</div>
</Wrapper>
)
}
// Step 2: Select app (if not selected yet)
if (!selectedApp) {
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedPlatform(null)}
className="btn-icon"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<h2 className="text-lg font-semibold text-dark-100">
{getPlatformName(selectedPlatform)}
</h2>
</div>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
// Main view
return (
<Wrapper>
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
<h2 className="text-lg font-bold text-dark-100 flex-1">{t('subscription.connection.title')}</h2>
</div>
<div className="px-4 pb-4 border-b border-dark-800">
<button
onClick={() => setShowAppSelector(true)}
className="w-full flex items-center gap-4 p-3 rounded-2xl bg-dark-800/50 hover:bg-dark-800 transition-colors"
>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-accent-500/30 to-accent-600/10 flex items-center justify-center text-accent-400">
{selectedApp && getAppIcon(selectedApp.name)}
</div>
<div className="flex-1 text-left">
<div className="font-bold text-dark-100 text-lg">{selectedApp?.name}</div>
<div className="text-sm text-accent-400">{t('subscription.connection.changeApp') || 'Сменить приложение'}</div>
</div>
<ChevronIcon />
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6">
<p className="text-dark-400 text-sm sm:text-base mb-3 sm:mb-4">{t('subscription.connection.selectApp')}</p>
{platformApps.length === 0 ? (
<div className="text-center py-6 sm:py-8">
<p className="text-dark-500">{t('subscription.connection.noApps')}</p>
</div>
) : (
<div className="space-y-2 sm:space-y-3">
{platformApps.map((app) => (
<button
key={app.id}
onClick={() => setSelectedApp(app)}
className="w-full p-3 sm:p-4 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 hover:bg-dark-700/50 transition-all text-left flex items-center justify-between"
{/* Content */}
<div className={`${isMobile ? 'flex-1' : 'max-h-[60vh]'} overflow-y-auto p-4 space-y-4`}>
{/* Step 1 */}
{selectedApp?.installationStep && (
<div className="p-4 rounded-2xl bg-dark-800/30">
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-full bg-accent-500/20 flex items-center justify-center text-sm font-bold text-accent-400">1</div>
<h3 className="font-semibold text-dark-100">{t('subscription.connection.installApp')}</h3>
</div>
<p className="text-dark-300 mb-3 leading-relaxed">{getLocalizedText(selectedApp.installationStep.description)}</p>
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
<div className="flex flex-wrap gap-2">
{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-2 px-4 py-2 rounded-xl bg-dark-700 text-dark-200 text-sm font-medium hover:bg-dark-600 transition-colors"
>
<div>
<div className="flex items-center gap-2">
<span className="text-dark-100 font-medium">{app.name}</span>
{app.isFeatured && (
<span className="px-2 py-0.5 rounded-full text-xs bg-accent-500/20 text-accent-400">
{t('subscription.connection.featured')}
</span>
)}
</div>
</div>
<svg className="w-5 h-5 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
<LinkIcon />
{getLocalizedText(btn.buttonText)}
</a>
))}
</div>
)}
</div>
</div>
</div>
)
}
)}
// Step 3: Show app instructions and connect button
return (
<div className={modalOverlayClass} onClick={onClose}>
<div className={`${modalCardClass} ${modalScrollableClass} flex flex-col animate-slide-up`} onClick={(e) => e.stopPropagation()}>
{/* Header - fixed */}
<div className="flex justify-between items-center p-4 sm:p-6 pb-0 flex-shrink-0">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedApp(null)}
className="btn-icon"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<h2 className="text-lg font-semibold text-dark-100">
{selectedApp.name}
</h2>
</div>
<button onClick={onClose} className="btn-icon">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Step 2 */}
{selectedApp?.addSubscriptionStep && (
<div className="p-4 rounded-2xl bg-dark-800/30">
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-full bg-accent-500/20 flex items-center justify-center text-sm font-bold text-accent-400">2</div>
<h3 className="font-semibold text-dark-100">{t('subscription.connection.addSubscription')}</h3>
</div>
<p className="text-dark-300 mb-4 leading-relaxed">{getLocalizedText(selectedApp.addSubscriptionStep.description)}</p>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 pt-3 sm:pt-4">
<div className="space-y-4 sm:space-y-6">
{/* Step 1: Install */}
{selectedApp.installationStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.installApp')}
</h3>
<p className="text-sm text-dark-400">
{getLocalizedText(selectedApp.installationStep.description)}
</p>
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
<div className="flex flex-wrap gap-2">
{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-2 px-4 py-2 rounded-xl bg-dark-700 text-dark-200 text-sm hover:bg-dark-600 transition-all"
>
<svg className="w-4 h-4" 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" />
</svg>
{getLocalizedText(btn.buttonText)}
</a>
))}
</div>
)}
</div>
)}
{/* Additional before add subscription */}
{selectedApp.additionalBeforeAddSubscriptionStep && (
<div className="space-y-2 p-3 rounded-xl bg-dark-800/50 border border-dark-700">
{selectedApp.additionalBeforeAddSubscriptionStep.title && (
<h4 className="text-sm font-medium text-dark-200">
{getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.title)}
</h4>
)}
<p className="text-xs text-dark-400">
{getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.description)}
</p>
</div>
)}
{/* Step 2: Add subscription */}
{selectedApp.addSubscriptionStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.addSubscription')}
</h3>
<p className="text-sm text-dark-400">
{getLocalizedText(selectedApp.addSubscriptionStep.description)}
</p>
{/* Connect button */}
{selectedApp.deepLink && (
<button
onClick={() => handleConnect(selectedApp)}
className="btn-primary w-full py-2.5 sm:py-3 flex items-center justify-center gap-2 text-sm sm:text-base"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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>
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
</button>
)}
{/* Copy link fallback */}
<div className="space-y-3">
{selectedApp.deepLink && (
<button
onClick={copySubscriptionLink}
className="w-full p-2.5 sm:p-3 rounded-xl bg-dark-800/50 border border-dark-700 hover:border-accent-500/50 transition-all flex items-center justify-center gap-2 text-sm sm:text-base"
onClick={() => handleConnect(selectedApp)}
className="btn-primary w-full py-3 text-base font-semibold flex items-center justify-center gap-2"
>
{copied ? (
<>
<svg className="w-5 h-5 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-success-400">{t('subscription.connection.copied')}</span>
</>
) : (
<>
<svg className="w-5 h-5 text-dark-400" 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" />
</svg>
<span className="text-dark-300">{t('subscription.connection.copyLink')}</span>
</>
)}
<LinkIcon />
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
</button>
</div>
)}
{/* Additional after add subscription */}
{selectedApp.additionalAfterAddSubscriptionStep && (
<div className="space-y-2 p-3 rounded-xl bg-dark-800/50 border border-dark-700">
{selectedApp.additionalAfterAddSubscriptionStep.title && (
<h4 className="text-sm font-medium text-dark-200">
{getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.title)}
</h4>
)}
<p className="text-xs text-dark-400">
{getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.description)}
</p>
</div>
)}
{/* Step 3: Connect */}
{selectedApp.connectAndUseStep && (
<div className="space-y-2 sm:space-y-3">
<h3 className="text-sm font-medium text-accent-400">
{t('subscription.connection.connectVpn')}
</h3>
<p className="text-sm text-dark-400">
{getLocalizedText(selectedApp.connectAndUseStep.description)}
</p>
</div>
)}
)}
<button
onClick={copySubscriptionLink}
className={`w-full py-3 rounded-xl border-2 transition-all flex items-center justify-center gap-2 text-base font-medium ${
copied
? 'border-success-500 bg-success-500/10 text-success-400'
: 'border-dark-600 hover:border-dark-500 text-dark-300 hover:text-dark-200'
}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
</button>
</div>
</div>
</div>
)}
{/* Footer - fixed with safe area */}
<div className="flex-shrink-0 p-4 sm:p-6 pt-3 sm:pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6 border-t border-dark-700/50">
<button onClick={onClose} className="btn-secondary w-full text-sm sm:text-base">
{t('common.close')}
</button>
</div>
{/* Step 3 */}
{selectedApp?.connectAndUseStep && (
<div className="p-4 rounded-2xl bg-success-500/5 border border-success-500/20">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full bg-success-500/20 flex items-center justify-center text-sm font-bold text-success-400">3</div>
<h3 className="font-semibold text-dark-100">{t('subscription.connection.connectVpn')}</h3>
</div>
<p className="text-dark-300 leading-relaxed">{getLocalizedText(selectedApp.connectAndUseStep.description)}</p>
</div>
)}
</div>
</div>
</Wrapper>
)
}

View File

@@ -145,10 +145,10 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod
const { formatAmount, currencySymbol } = useCurrency()
return (
<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">
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[60] flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
<div className="absolute inset-0" onClick={onClose} />
<div className="relative w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col">
<div className="relative w-full max-w-sm bg-dark-900/95 backdrop-blur-xl rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>

View File

@@ -41,13 +41,13 @@ export default function LanguageSwitcher() {
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-xl border border-dark-700/50 hover:border-dark-600 bg-dark-800/50 transition-all text-sm"
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-dark-700/50 hover:border-dark-600 bg-dark-800/50 hover:bg-dark-700 transition-all text-sm"
aria-label="Change language"
>
<span>{currentLang.flag}</span>
<span className="font-medium text-dark-200">{currentLang.name}</span>
<svg
className={`w-4 h-4 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
className={`w-3.5 h-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"

View File

@@ -0,0 +1,559 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets'
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion'
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme'
import { applyThemeColors } from '../hooks/useThemeColors'
interface ThemeBentoPickerProps {
currentColors: ThemeColors
onColorsChange: (colors: ThemeColors) => void
onSave: () => void
isSaving: boolean
}
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
const ChevronDownIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
)
const MoonIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
)
const SunIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)
const StatusIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
)
const PaletteIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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" />
</svg>
)
function PresetCard({
preset,
isSelected,
onClick,
}: {
preset: ColorPreset
isSelected: boolean
onClick: () => void
}) {
const { i18n } = useTranslation()
const isRu = i18n.language === 'ru'
return (
<button
onClick={onClick}
className={`relative h-full w-full p-3 text-left transition-all duration-200 group rounded-2xl flex flex-col ${
isSelected
? 'bg-dark-800/90 border-2 border-accent-500 shadow-lg shadow-accent-500/20 scale-[1.02] z-10'
: 'bg-dark-900/60 border border-dark-700/50 hover:bg-dark-800/70 hover:border-dark-600/60 hover:scale-[1.01]'
}`}
>
<div
className="w-full h-12 rounded-xl mb-2.5 relative overflow-hidden shrink-0"
style={{ backgroundColor: preset.preview.background }}
>
<div
className="absolute bottom-1.5 left-1.5 w-6 h-6 rounded-lg shadow-md"
style={{ backgroundColor: preset.preview.accent }}
/>
<div
className="absolute bottom-2.5 right-2 w-10 h-1 rounded-full opacity-60"
style={{ backgroundColor: preset.preview.text }}
/>
<div
className="absolute bottom-5 right-2 w-7 h-1 rounded-full opacity-40"
style={{ backgroundColor: preset.preview.text }}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-xs font-semibold text-dark-100 truncate">
{isRu ? preset.nameRu : preset.name}
</h4>
</div>
{isSelected && (
<div className="w-5 h-5 rounded-full bg-accent-500 flex items-center justify-center text-white shrink-0">
<CheckIcon />
</div>
)}
</div>
</button>
)
}
function HSLSlider({
label,
value,
onChange,
max,
gradient,
suffix = '',
}: {
label: string
value: number
onChange: (value: number) => void
max: number
gradient: string
suffix?: string
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-dark-300">{label}</label>
<span className="text-xs text-dark-500 font-mono tabular-nums">
{value}
{suffix}
</span>
</div>
<input
type="range"
min="0"
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value))}
className="w-full h-2.5 rounded-full appearance-none cursor-pointer"
style={{ background: gradient }}
/>
</div>
)
}
function CompactColorInput({
label,
value,
onChange,
}: {
label: string
value: string
onChange: (color: string) => void
}) {
const [localValue, setLocalValue] = useState(value)
const [isEditing, setIsEditing] = useState(false)
useEffect(() => {
setLocalValue(value)
}, [value])
const handleChange = (newValue: string) => {
let formatted = newValue.toUpperCase()
if (!formatted.startsWith('#')) {
formatted = '#' + formatted
}
setLocalValue(formatted)
if (isValidHex(formatted)) {
onChange(formatted)
}
}
const handleBlur = () => {
setIsEditing(false)
if (!isValidHex(localValue)) {
setLocalValue(value)
}
}
return (
<div className="flex items-center gap-2 p-2 rounded-xl bg-dark-800/40 hover:bg-dark-800/60 transition-colors group">
<button
type="button"
onClick={() => setIsEditing(true)}
className="w-8 h-8 rounded-lg border border-dark-600/50 shadow-inner shrink-0 transition-transform hover:scale-105"
style={{ backgroundColor: value }}
/>
<div className="flex-1 min-w-0">
<label className="text-[10px] uppercase tracking-wide text-dark-500 block leading-none mb-0.5">
{label}
</label>
{isEditing ? (
<input
type="text"
value={localValue}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
autoFocus
className="bg-transparent text-xs font-mono text-dark-200 w-full outline-none"
maxLength={7}
/>
) : (
<button
type="button"
onClick={() => setIsEditing(true)}
className="text-xs font-mono text-dark-300 hover:text-dark-100 transition-colors text-left"
>
{value.toUpperCase()}
</button>
)}
</div>
</div>
)
}
function CollapsibleSection({
title,
icon,
isOpen,
onToggle,
children,
badge,
}: {
title: string
icon: React.ReactNode
isOpen: boolean
onToggle: () => void
children: React.ReactNode
badge?: string
}) {
return (
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 overflow-hidden">
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-dark-800/30 transition-colors"
>
<div className="flex items-center gap-2.5">
<div className="text-dark-400">{icon}</div>
<span className="text-sm font-medium text-dark-200">{title}</span>
{badge && (
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-dark-700/50 text-dark-400 font-mono">
{badge}
</span>
)}
</div>
<div
className={`text-dark-500 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
>
<ChevronDownIcon />
</div>
</button>
<div
className={`grid transition-all duration-200 ease-out ${
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="overflow-hidden">
<div className="px-4 pb-4 pt-1 border-t border-dark-700/30">{children}</div>
</div>
</div>
</div>
)
}
export function ThemeBentoPicker({
currentColors,
onColorsChange,
onSave,
isSaving,
}: ThemeBentoPickerProps) {
const { t } = useTranslation()
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent))
const [hexInput, setHexInput] = useState(currentColors.accent)
const [hasChanges, setHasChanges] = useState(false)
const [isPresetsOpen, setIsPresetsOpen] = useState(false)
const [isAccentOpen, setIsAccentOpen] = useState(false)
const [isDarkOpen, setIsDarkOpen] = useState(false)
const [isLightOpen, setIsLightOpen] = useState(false)
const [isStatusOpen, setIsStatusOpen] = useState(false)
const selectedPresetId = useMemo(() => {
const match = COLOR_PRESETS.find(
(p) =>
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase()
)
return match?.id ?? null
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground])
useEffect(() => {
setHsl(hexToHsl(currentColors.accent))
setHexInput(currentColors.accent)
}, [currentColors.accent])
const updateColor = useCallback(
(key: keyof ThemeColors, value: string) => {
const newColors = { ...currentColors, [key]: value }
onColorsChange(newColors)
applyThemeColors(newColors)
setHasChanges(true)
},
[currentColors, onColorsChange]
)
const updateAccentFromHsl = useCallback(
(newHsl: HSLColor) => {
setHsl(newHsl)
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l)
setHexInput(newHex)
updateColor('accent', newHex)
},
[updateColor]
)
const handleHexInputChange = (value: string) => {
setHexInput(value)
if (isValidHex(value)) {
const newHsl = hexToHsl(value)
setHsl(newHsl)
updateColor('accent', value)
}
}
const handlePresetSelect = (preset: ColorPreset) => {
onColorsChange(preset.colors)
applyThemeColors(preset.colors)
setHasChanges(true)
}
const hueGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(0, ${hsl.s}%, ${hsl.l}%),
hsl(60, ${hsl.s}%, ${hsl.l}%),
hsl(120, ${hsl.s}%, ${hsl.l}%),
hsl(180, ${hsl.s}%, ${hsl.l}%),
hsl(240, ${hsl.s}%, ${hsl.l}%),
hsl(300, ${hsl.s}%, ${hsl.l}%),
hsl(360, ${hsl.s}%, ${hsl.l}%)
)`
}, [hsl.s, hsl.l])
const saturationGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(${hsl.h}, 0%, ${hsl.l}%),
hsl(${hsl.h}, 100%, ${hsl.l}%)
)`
}, [hsl.h, hsl.l])
const lightnessGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(${hsl.h}, ${hsl.s}%, 0%),
hsl(${hsl.h}, ${hsl.s}%, 50%),
hsl(${hsl.h}, ${hsl.s}%, 100%)
)`
}, [hsl.h, hsl.s])
return (
<div className="space-y-5">
<CollapsibleSection
title={t('admin.theme.quickPresets', 'Quick Presets')}
icon={<PaletteIcon />}
badge={`${COLOR_PRESETS.length}`}
isOpen={isPresetsOpen}
onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
>
<div className="grid grid-cols-2 sm:grid-cols-3 auto-rows-fr gap-4 p-1">
{COLOR_PRESETS.map((preset, index) => (
<div key={preset.id} className="min-h-[100px]" style={{ '--stagger': index } as React.CSSProperties}>
<PresetCard
preset={preset}
isSelected={selectedPresetId === preset.id}
onClick={() => handlePresetSelect(preset)}
/>
</div>
))}
</div>
</CollapsibleSection>
<div className="space-y-2">
<h3 className="text-xs font-medium text-dark-400 uppercase tracking-wide">
{t('admin.theme.customizeColors', 'Customize Colors')}
</h3>
<CollapsibleSection
title={t('admin.theme.accentColor', 'Accent Color')}
icon={<PaletteIcon />}
badge={hexInput.toUpperCase()}
isOpen={isAccentOpen}
onToggle={() => setIsAccentOpen(!isAccentOpen)}
>
<div className="space-y-4">
<div
className="w-full h-14 rounded-xl shadow-inner relative overflow-hidden"
style={{
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
}}
>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
<div className="absolute bottom-2 right-3 text-white/80 text-xs font-mono drop-shadow">
{hexInput.toUpperCase()}
</div>
</div>
<HSLSlider
label={t('admin.theme.hue', 'Hue')}
value={hsl.h}
onChange={(h) => updateAccentFromHsl({ ...hsl, h })}
max={360}
gradient={hueGradient}
suffix="°"
/>
<HSLSlider
label={t('admin.theme.saturation', 'Saturation')}
value={hsl.s}
onChange={(s) => updateAccentFromHsl({ ...hsl, s })}
max={100}
gradient={saturationGradient}
suffix="%"
/>
<HSLSlider
label={t('admin.theme.lightness', 'Lightness')}
value={hsl.l}
onChange={(l) => updateAccentFromHsl({ ...hsl, l })}
max={100}
gradient={lightnessGradient}
suffix="%"
/>
<div>
<label className="text-xs font-medium text-dark-300 mb-1.5 block">
{t('admin.theme.hexCode', 'HEX Code')}
</label>
<input
type="text"
value={hexInput}
onChange={(e) => handleHexInputChange(e.target.value)}
placeholder="#3b82f6"
maxLength={7}
className="input w-full text-sm font-mono uppercase"
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.darkTheme', 'Dark Theme')}
icon={<MoonIcon />}
isOpen={isDarkOpen}
onToggle={() => setIsDarkOpen(!isDarkOpen)}
>
<div className="grid grid-cols-2 gap-2">
<CompactColorInput
label={t('theme.background', 'Background')}
value={currentColors.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
onChange={(c) => updateColor('darkBackground', c)}
/>
<CompactColorInput
label={t('theme.surface', 'Surface')}
value={currentColors.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
onChange={(c) => updateColor('darkSurface', c)}
/>
<CompactColorInput
label={t('theme.text', 'Text')}
value={currentColors.darkText || DEFAULT_THEME_COLORS.darkText}
onChange={(c) => updateColor('darkText', c)}
/>
<CompactColorInput
label={t('theme.textSecondary', 'Secondary')}
value={currentColors.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
onChange={(c) => updateColor('darkTextSecondary', c)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.lightTheme', 'Light Theme')}
icon={<SunIcon />}
isOpen={isLightOpen}
onToggle={() => setIsLightOpen(!isLightOpen)}
>
<div className="grid grid-cols-2 gap-2">
<CompactColorInput
label={t('theme.background', 'Background')}
value={currentColors.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
onChange={(c) => updateColor('lightBackground', c)}
/>
<CompactColorInput
label={t('theme.surface', 'Surface')}
value={currentColors.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
onChange={(c) => updateColor('lightSurface', c)}
/>
<CompactColorInput
label={t('theme.text', 'Text')}
value={currentColors.lightText || DEFAULT_THEME_COLORS.lightText}
onChange={(c) => updateColor('lightText', c)}
/>
<CompactColorInput
label={t('theme.textSecondary', 'Secondary')}
value={currentColors.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
onChange={(c) => updateColor('lightTextSecondary', c)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.statusColors', 'Status Colors')}
icon={<StatusIcon />}
isOpen={isStatusOpen}
onToggle={() => setIsStatusOpen(!isStatusOpen)}
>
<div className="grid grid-cols-3 gap-2">
<CompactColorInput
label={t('theme.success', 'Success')}
value={currentColors.success || DEFAULT_THEME_COLORS.success}
onChange={(c) => updateColor('success', c)}
/>
<CompactColorInput
label={t('theme.warning', 'Warning')}
value={currentColors.warning || DEFAULT_THEME_COLORS.warning}
onChange={(c) => updateColor('warning', c)}
/>
<CompactColorInput
label={t('theme.error', 'Error')}
value={currentColors.error || DEFAULT_THEME_COLORS.error}
onChange={(c) => updateColor('error', c)}
/>
</div>
</CollapsibleSection>
</div>
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 p-4">
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-3">
{t('theme.preview', 'Preview')}
</h4>
<div className="flex flex-wrap gap-2">
<button className="btn-primary text-sm">{t('theme.previewButton', 'Primary')}</button>
<button className="btn-secondary text-sm">
{t('theme.previewSecondary', 'Secondary')}
</button>
<span className="badge-success">{t('theme.success', 'Success')}</span>
<span className="badge-warning">{t('theme.warning', 'Warning')}</span>
<span className="badge-error">{t('theme.error', 'Error')}</span>
</div>
</div>
{hasChanges && (
<div className="flex justify-end animate-fade-in">
<button onClick={onSave} disabled={isSaving} className="btn-primary">
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
</button>
</div>
)}
</div>
)
}

View File

@@ -198,7 +198,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
{/* Bell button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2.5 rounded-xl transition-all duration-200 hover:bg-dark-800/50 text-dark-400 hover:text-dark-100"
className="relative p-2 rounded-xl transition-all duration-200 bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50 text-dark-400 hover:text-accent-400"
title={t('notifications.ticketNotifications', 'Ticket notifications')}
>
<BellIcon />

View File

@@ -1,10 +1,12 @@
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import { balanceApi } from '../api/balance'
import { useCurrency } from '../hooks/useCurrency'
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
import type { PaymentMethod } from '../types'
import BentoCard from './ui/BentoCard'
const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i
const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url)
@@ -55,6 +57,35 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
)
const popupRef = useRef<Window | null>(null)
// Scroll lock when modal is open
useEffect(() => {
const scrollY = window.scrollY
// Prevent all touch/wheel scroll on backdrop
const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement
if (target.closest('[data-modal-content]')) return
e.preventDefault()
}
const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement
if (target.closest('[data-modal-content]')) return
e.preventDefault()
}
document.addEventListener('touchmove', preventScroll, { passive: false })
document.addEventListener('wheel', preventWheel, { passive: false })
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('touchmove', preventScroll)
document.removeEventListener('wheel', preventWheel)
document.body.style.overflow = ''
window.scrollTo(0, scrollY)
}
}, [])
const hasOptions = method.options && method.options.length > 0
const minRubles = method.min_amount_kopeks / 100
const maxRubles = method.max_amount_kopeks / 100
@@ -132,105 +163,181 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
: convertAmount(rub).toFixed(currencyDecimals)
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
return (
<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">
<div className="absolute inset-0" onClick={onClose} />
// Auto-focus input on mount
useEffect(() => {
const timer = setTimeout(() => {
inputRef.current?.focus()
}, 100)
return () => clearTimeout(timer)
}, [])
<div className="relative w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden">
const modalContent = (
<div
className="fixed inset-0 bg-dark-950/80 backdrop-blur-sm z-[60] flex items-center justify-center p-4 overflow-hidden animate-in fade-in duration-200"
style={{
paddingBottom: `max(1rem, env(safe-area-inset-bottom, 0px))`,
}}
onClick={onClose}
>
<div
data-modal-content
className="w-full max-w-sm bg-dark-900/90 backdrop-blur-xl rounded-[32px] border border-dark-700/50 shadow-2xl overflow-hidden animate-scale-in flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
<span className="font-semibold text-dark-100">{methodName}</span>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<div className="flex items-center justify-between px-6 py-5 bg-dark-800/30 border-b border-dark-700/30">
<span className="text-lg font-bold text-dark-50 tracking-tight">{methodName}</span>
<button
onClick={onClose}
className="p-2 -mr-2 rounded-xl hover:bg-dark-700/50 text-dark-400 hover:text-dark-100 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-4 space-y-3">
{/* Payment options */}
{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 */}
<div className="relative">
<input
ref={inputRef}
type="number"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={`${formatAmount(minRubles, 0)} ${formatAmount(maxRubles, 0)}`}
className="w-full h-12 px-4 pr-12 text-lg font-semibold bg-dark-800 border border-dark-700 rounded-xl text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500"
autoComplete="off"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-500 font-medium">
{currencySymbol}
</span>
</div>
{/* Quick amounts */}
<div className="p-6 space-y-6 overflow-y-auto custom-scrollbar">
{quickAmounts.length > 0 && (
<div className="flex gap-2">
<div className="grid grid-cols-2 gap-3">
{quickAmounts.map((a) => {
const val = getQuickValue(a)
const isSelected = amount === val
return (
<button
<BentoCard
key={a}
as="button"
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'
}`}
className={`flex flex-col items-center justify-center py-4 px-2 transition-all duration-300 group
${isSelected
? 'border-accent-500 bg-accent-500/10 shadow-glow'
: 'hover:bg-dark-800/80 hover:border-dark-600'
}`}
>
{formatAmount(a, 0)}
</button>
<span className={`text-xl font-extrabold mb-0.5 ${isSelected ? 'text-accent-400' : 'text-dark-100 group-hover:text-white'}`}>
{formatAmount(a, 0)}
</span>
<span className={`text-xs font-semibold uppercase tracking-wider ${isSelected ? 'text-accent-300/80' : 'text-dark-500 group-hover:text-dark-400'}`}>
{currencySymbol}
</span>
</BentoCard>
)
})}
</div>
)}
{/* Error */}
{error && (
<div className="text-error-400 text-sm text-center py-1">{error}</div>
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<label className="text-xs font-semibold text-dark-400 uppercase tracking-wider">
{t('balance.amount') || 'Amount'}
</label>
<span className="text-xs text-dark-500 font-medium">
{formatAmount(minRubles, 0)} {formatAmount(maxRubles, 0)} {currencySymbol}
</span>
</div>
<div className="relative group">
<div className="absolute -inset-0.5 bg-gradient-to-r from-accent-500/20 to-accent-600/20 rounded-2xl blur opacity-0 group-focus-within:opacity-100 transition duration-500" />
<div className="relative flex items-center bg-dark-800/50 border border-dark-700/50 rounded-2xl p-1 focus-within:bg-dark-800 focus-within:border-accent-500/50 transition-all duration-300">
<input
ref={inputRef}
type="number"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0"
className="w-full h-14 pl-5 pr-12 text-2xl font-bold bg-transparent text-dark-50 placeholder:text-dark-600 focus:outline-none"
autoComplete="off"
/>
<div className="absolute right-5 pointer-events-none">
<span className="text-lg font-bold text-dark-400 group-focus-within:text-accent-400 transition-colors">
{currencySymbol}
</span>
</div>
</div>
</div>
</div>
{hasOptions && method.options && (
<div className="space-y-3">
<label className="text-xs font-semibold text-dark-400 uppercase tracking-wider px-1">
Method Type
</label>
<div className="grid grid-cols-2 gap-2 bg-dark-800/30 p-1.5 rounded-2xl border border-dark-700/30">
{method.options.map((opt) => {
const isSelected = selectedOption === opt.id
return (
<button
key={opt.id}
type="button"
onClick={() => setSelectedOption(opt.id)}
className={`relative py-2.5 px-3 rounded-xl text-sm font-bold transition-all duration-300 ${
isSelected
? 'bg-dark-700 text-white shadow-lg'
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800/50'
}`}
>
{isSelected && (
<div className="absolute inset-0 bg-gradient-to-tr from-accent-500/10 to-transparent rounded-xl pointer-events-none" />
)}
{opt.name}
</button>
)
})}
</div>
</div>
)}
{error && (
<div className="bg-error-500/10 border border-error-500/20 rounded-xl p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2">
<div className="text-error-400 mt-0.5">
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<p className="text-sm font-medium text-error-200 leading-snug">{error}</p>
</div>
)}
{/* Submit */}
<button
type="button"
onClick={handleSubmit}
disabled={isPending || !amount}
className="btn-primary w-full h-11 text-base font-semibold"
className={`
w-full h-14 rounded-2xl text-base font-bold tracking-wide transition-all duration-300
${isPending || !amount
? 'bg-dark-800 text-dark-500 cursor-not-allowed border border-dark-700'
: 'bg-accent-500 text-white shadow-lg shadow-accent-500/30 hover:bg-accent-400 hover:scale-[1.02] active:scale-[0.98]'
}
`}
>
{isPending ? (
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<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>Processing...</span>
</div>
) : (
<>
{t('balance.topUp')}
<div className="flex items-center justify-center gap-2">
<span>{t('balance.topUp')}</span>
{amount && parseFloat(amount) > 0 && (
<span className="ml-2 opacity-80">{formatAmount(parseFloat(amount), currencyDecimals)} {currencySymbol}</span>
<>
<span className="w-1 h-1 bg-white/40 rounded-full" />
<span>{formatAmount(parseFloat(amount), currencyDecimals)} {currencySymbol}</span>
</>
)}
</>
</div>
)}
</button>
</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 { useTheme } from '../../hooks/useTheme'
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
import { usePullToRefresh } from '../../hooks/usePullToRefresh'
// Fallback branding from environment variables
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
@@ -123,18 +124,6 @@ const WheelIcon = () => (
</svg>
)
const FullscreenIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)
const ExitFullscreenIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
</svg>
)
export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation()
const location = useLocation()
@@ -142,7 +131,13 @@ export default function Layout({ children }: LayoutProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const { toggleTheme, isDark } = useTheme()
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
// Pull to refresh (disabled when mobile menu is open)
const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({
disabled: mobileMenuOpen,
threshold: 80,
})
// Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({
@@ -167,17 +162,50 @@ export default function Layout({ children }: LayoutProps) {
}
}, [])
// Lock body scroll and scroll to top when mobile menu is open
// Lock body scroll when mobile menu is open (cross-platform)
// Note: We avoid using body position:fixed with top:-scrollY as it causes issues
// in Telegram Mini App where the menu disappears when opened from scrolled position
useEffect(() => {
if (mobileMenuOpen) {
// Scroll to top so header is visible
window.scrollTo({ top: 0, behavior: 'instant' })
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
if (!mobileMenuOpen) return
const body = document.body
const html = document.documentElement
// Save original styles
const originalStyles = {
bodyOverflow: body.style.overflow,
htmlOverflow: html.style.overflow,
}
// Lock scroll - simple approach without body position manipulation
body.style.overflow = 'hidden'
html.style.overflow = 'hidden'
// Prevent touchmove on body (critical for mobile, especially Telegram Mini App)
const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement
// Allow scroll inside menu content
if (target.closest('.mobile-menu-content')) return
e.preventDefault()
}
document.addEventListener('touchmove', preventScroll, { passive: false })
// Also prevent wheel scroll on desktop
const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement
if (target.closest('.mobile-menu-content')) return
e.preventDefault()
}
document.addEventListener('wheel', preventWheel, { passive: false })
return () => {
document.body.style.overflow = ''
// Restore original styles
body.style.overflow = originalStyles.bodyOverflow
html.style.overflow = originalStyles.htmlOverflow
// Remove listeners
document.removeEventListener('touchmove', preventScroll)
document.removeEventListener('wheel', preventWheel)
}
}, [mobileMenuOpen])
@@ -299,21 +327,45 @@ export default function Layout({ children }: LayoutProps) {
{/* Animated Background */}
<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
className="sticky top-0 z-50 glass border-b border-dark-800/50"
className="fixed top-0 left-0 right-0 z-50 glass shadow-lg shadow-black/10"
style={{
// 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,
}}
>
<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">
{/* Logo */}
<Link to="/" 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">
<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-11 sm:h-11 lg:w-12 lg:h-12 rounded-xl bg-dark-800/80 dark:bg-dark-800/80 border border-dark-700/50 flex items-center justify-center overflow-hidden shadow-md flex-shrink-0 relative">
{/* 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-accent-400 font-bold text-lg sm:text-xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
{logoLetter}
</span>
{/* Logo image with smooth fade-in */}
@@ -371,28 +423,18 @@ export default function Layout({ children }: LayoutProps) {
</nav>
{/* Right side */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Fullscreen toggle - only show in Telegram WebApp */}
{isTelegramWebApp && isFullscreenSupported && (
<button
onClick={toggleFullscreen}
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
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</button>
)}
<div className="flex items-center gap-1.5 sm:gap-2">
{/* Theme toggle button - only show if both themes are enabled */}
{canToggle && (
<button
onClick={toggleTheme}
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
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
onClick={() => {
toggleTheme()
setMobileMenuOpen(false)
}}
className="relative p-2 rounded-xl transition-all duration-200
bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50
dark:text-dark-400 dark:hover:text-accent-400
text-champagne-500 hover:text-champagne-800"
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to dark mode'}
>
@@ -407,10 +449,14 @@ export default function Layout({ children }: LayoutProps) {
</button>
)}
<PromoDiscountBadge />
<TicketNotificationBell isAdmin={isAdminActive()} />
<div onClick={() => setMobileMenuOpen(false)}>
<PromoDiscountBadge />
</div>
<div onClick={() => setMobileMenuOpen(false)}>
<TicketNotificationBell isAdmin={isAdminActive()} />
</div>
{/* 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 />
</div>
@@ -439,7 +485,10 @@ export default function Layout({ children }: LayoutProps) {
{/* Mobile menu button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
onClick={(e) => {
e.stopPropagation()
setMobileMenuOpen(!mobileMenuOpen)
}}
className="lg:hidden btn-icon"
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'}
aria-expanded={mobileMenuOpen}
@@ -452,9 +501,26 @@ export default function Layout({ children }: LayoutProps) {
</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 && (
<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 */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
@@ -462,7 +528,7 @@ export default function Layout({ children }: LayoutProps) {
/>
{/* 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">
{/* User info */}
<div className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50">
@@ -572,6 +638,7 @@ export default function Layout({ children }: LayoutProps) {
<Link
key={item.path}
to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
>
<item.icon />

View File

@@ -0,0 +1,115 @@
import { Link } from 'react-router-dom'
import { forwardRef } from 'react'
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl'
interface BentoCardBaseProps {
size?: BentoSize
children: React.ReactNode
className?: string
hover?: boolean
glow?: boolean
}
interface BentoCardDivProps extends BentoCardBaseProps {
as?: 'div'
onClick?: () => void
}
interface BentoCardLinkProps extends BentoCardBaseProps {
as: 'link'
to: string
state?: unknown
}
interface BentoCardButtonProps extends BentoCardBaseProps {
as: 'button'
onClick?: () => void
disabled?: boolean
type?: 'button' | 'submit'
}
export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps
const sizeClasses: Record<BentoSize, string> = {
sm: '',
md: 'col-span-2',
lg: 'row-span-2',
xl: 'col-span-2 row-span-2',
}
const baseClasses = `
bento-card
rounded-[var(--bento-radius)]
p-[var(--bento-padding)]
bg-dark-900/70
border border-dark-700/40
transition-all duration-300 ease-smooth
`
const hoverClasses = `
cursor-pointer
hover:bg-dark-800/60
hover:border-dark-600/50
hover:shadow-lg
hover:scale-[1.01]
active:scale-[0.99]
`
const glowClasses = `
hover:shadow-glow
hover:border-accent-500/30
`
export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => {
const {
size = 'sm',
children,
className = '',
hover = false,
glow = false,
} = props
const classes = [
baseClasses,
sizeClasses[size],
hover && hoverClasses,
glow && glowClasses,
className,
].filter(Boolean).join(' ')
if (props.as === 'link') {
const { to, state } = props as BentoCardLinkProps
return (
<Link to={to} state={state} className={classes}>
{children}
</Link>
)
}
if (props.as === 'button') {
const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
type={type}
onClick={onClick}
disabled={disabled}
className={classes}
>
{children}
</button>
)
}
const { onClick } = props as BentoCardDivProps
return (
<div ref={ref} onClick={onClick} className={classes}>
{children}
</div>
)
})
BentoCard.displayName = 'BentoCard'
export default BentoCard

View File

@@ -0,0 +1,28 @@
interface BentoSkeletonProps {
className?: string
count?: number
}
export default function BentoSkeleton({ className = '', count = 1 }: BentoSkeletonProps) {
const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}`
if (count > 1) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className={baseClasses}
style={{ '--stagger': i } as React.CSSProperties}
/>
))}
</>
)
}
return (
<div
className={baseClasses}
/>
)
}

282
src/data/colorPresets.ts Normal file
View File

@@ -0,0 +1,282 @@
import { ThemeColors } from '../types/theme'
export interface ColorPreset {
id: string
name: string
nameRu: string
description: string
descriptionRu: string
colors: ThemeColors
preview: {
background: string
accent: string
text: string
}
}
export const COLOR_PRESETS: ColorPreset[] = [
{
id: 'electric-blue',
name: 'Electric Blue',
nameRu: 'Электрик',
description: 'Classic tech blue, reliable and clean',
descriptionRu: 'Классический технологичный синий',
colors: {
accent: '#3b82f6',
darkBackground: '#0a0f1a',
darkSurface: '#0f172a',
darkText: '#f1f5f9',
darkTextSecondary: '#94a3b8',
lightBackground: '#f1f5f9',
lightSurface: '#ffffff',
lightText: '#0f172a',
lightTextSecondary: '#475569',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
preview: {
background: '#0a0f1a',
accent: '#3b82f6',
text: '#f1f5f9',
},
},
{
id: 'toxic-neon',
name: 'Toxic Neon',
nameRu: 'Токсичный неон',
description: 'Cyberpunk vibes, high energy',
descriptionRu: 'Киберпанк атмосфера, высокая энергия',
colors: {
accent: '#22c55e',
darkBackground: '#030712',
darkSurface: '#0a0f14',
darkText: '#e2e8f0',
darkTextSecondary: '#64748b',
lightBackground: '#f0fdf4',
lightSurface: '#ffffff',
lightText: '#14532d',
lightTextSecondary: '#166534',
success: '#22c55e',
warning: '#eab308',
error: '#ef4444',
},
preview: {
background: '#030712',
accent: '#22c55e',
text: '#e2e8f0',
},
},
{
id: 'royal-purple',
name: 'Royal Purple',
nameRu: 'Королевский пурпур',
description: 'Premium, sophisticated, Stripe-like',
descriptionRu: 'Премиальный, утончённый, как Stripe',
colors: {
accent: '#8b5cf6',
darkBackground: '#0c0a14',
darkSurface: '#13111c',
darkText: '#f1f0f5',
darkTextSecondary: '#a1a1aa',
lightBackground: '#faf5ff',
lightSurface: '#ffffff',
lightText: '#3b0764',
lightTextSecondary: '#581c87',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
preview: {
background: '#0c0a14',
accent: '#8b5cf6',
text: '#f1f0f5',
},
},
{
id: 'sunset-orange',
name: 'Sunset Orange',
nameRu: 'Закатный оранж',
description: 'Warm, energetic, action-oriented',
descriptionRu: 'Тёплый, энергичный, призыв к действию',
colors: {
accent: '#f97316',
darkBackground: '#0f0906',
darkSurface: '#1a120d',
darkText: '#fef3e2',
darkTextSecondary: '#a3a3a3',
lightBackground: '#fff7ed',
lightSurface: '#ffffff',
lightText: '#7c2d12',
lightTextSecondary: '#9a3412',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
preview: {
background: '#0f0906',
accent: '#f97316',
text: '#fef3e2',
},
},
{
id: 'ocean-teal',
name: 'Ocean Teal',
nameRu: 'Океанский бирюзовый',
description: 'Calm, trustworthy, health-tech',
descriptionRu: 'Спокойный, надёжный, медтех',
colors: {
accent: '#14b8a6',
darkBackground: '#042f2e',
darkSurface: '#0d3d3b',
darkText: '#f0fdfa',
darkTextSecondary: '#5eead4',
lightBackground: '#f0fdfa',
lightSurface: '#ffffff',
lightText: '#134e4a',
lightTextSecondary: '#115e59',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
preview: {
background: '#042f2e',
accent: '#14b8a6',
text: '#f0fdfa',
},
},
{
id: 'champagne-gold',
name: 'Champagne Gold',
nameRu: 'Шампанское золото',
description: 'Luxury, premium, elegant',
descriptionRu: 'Роскошный, премиальный, элегантный',
colors: {
accent: '#b8860b',
darkBackground: '#0a0f1a',
darkSurface: '#0f172a',
darkText: '#f1f5f9',
darkTextSecondary: '#94a3b8',
lightBackground: '#fefce8',
lightSurface: '#ffffff',
lightText: '#713f12',
lightTextSecondary: '#92400e',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
preview: {
background: '#0a0f1a',
accent: '#b8860b',
text: '#f1f5f9',
},
},
{
id: 'quantum-teal',
name: 'Quantum Teal',
nameRu: 'Квантовый бирюзовый',
description: 'Bio-synthetic, futuristic fintech',
descriptionRu: 'Био-синтетический, футуристичный финтех',
colors: {
accent: '#0d9488',
darkBackground: '#0f172a',
darkSurface: '#1e293b',
darkText: '#f1f5f9',
darkTextSecondary: '#94a3b8',
lightBackground: '#f0fdfa',
lightSurface: '#ffffff',
lightText: '#134e4a',
lightTextSecondary: '#0f766e',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
preview: {
background: '#0f172a',
accent: '#0d9488',
text: '#f1f5f9',
},
},
{
id: 'cosmic-violet',
name: 'Cosmic Violet',
nameRu: 'Космический фиолет',
description: 'Digital lavender, wellness vibes',
descriptionRu: 'Цифровая лаванда, атмосфера спокойствия',
colors: {
accent: '#7c3aed',
darkBackground: '#0b0d10',
darkSurface: '#18181b',
darkText: '#f4f4f5',
darkTextSecondary: '#a1a1aa',
lightBackground: '#faf5ff',
lightSurface: '#ffffff',
lightText: '#3b0764',
lightTextSecondary: '#5b21b6',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
preview: {
background: '#0b0d10',
accent: '#7c3aed',
text: '#f4f4f5',
},
},
{
id: 'solar-coral',
name: 'Solar Coral',
nameRu: 'Солнечный коралл',
description: 'Hyper-coral, high energy social',
descriptionRu: 'Гипер-коралловый, энергия соцсетей',
colors: {
accent: '#ea580c',
darkBackground: '#18181b',
darkSurface: '#27272a',
darkText: '#fafafa',
darkTextSecondary: '#a1a1aa',
lightBackground: '#fff7ed',
lightSurface: '#ffffff',
lightText: '#7c2d12',
lightTextSecondary: '#9a3412',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
preview: {
background: '#18181b',
accent: '#ea580c',
text: '#fafafa',
},
},
{
id: 'frost-blue',
name: 'Frost Blue',
nameRu: 'Морозный синий',
description: 'Liquid chrome, enterprise trust',
descriptionRu: 'Жидкий хром, корпоративное доверие',
colors: {
accent: '#0284c7',
darkBackground: '#0b0d10',
darkSurface: '#1e293b',
darkText: '#f1f5f9',
darkTextSecondary: '#94a3b8',
lightBackground: '#f0f9ff',
lightSurface: '#ffffff',
lightText: '#0c4a6e',
lightTextSecondary: '#075985',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
preview: {
background: '#0b0d10',
accent: '#0284c7',
text: '#f1f5f9',
},
},
]
export function getPresetById(id: string): ColorPreset | undefined {
return COLOR_PRESETS.find((preset) => preset.id === id)
}

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

@@ -1,17 +1,38 @@
import { useEffect, useState, useCallback } from 'react'
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'
// Get cached fullscreen setting
export const getCachedFullscreenEnabled = (): boolean => {
try {
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'
} catch {
return false
}
}
// Set cached fullscreen setting
export const setCachedFullscreenEnabled = (enabled: boolean) => {
try {
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled))
} catch {
// localStorage not available
}
}
/**
* Hook for Telegram WebApp API integration
* Provides fullscreen mode, safe area insets, and other WebApp features
*/
export function useTelegramWebApp() {
const [isFullscreen, setIsFullscreen] = useState(false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
const [safeAreaInset, setSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 })
const [contentSafeAreaInset, setContentSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 })
// Initialize synchronously to avoid flash/flicker on first render
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp)
const [safeAreaInset, setSafeAreaInset] = useState(() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
useEffect(() => {
if (!webApp) {
setIsTelegramWebApp(false)
@@ -123,5 +144,15 @@ export function initTelegramWebApp() {
if (webApp.disableVerticalSwipes) {
webApp.disableVerticalSwipes()
}
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
const fullscreenEnabled = getCachedFullscreenEnabled()
if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) {
try {
webApp.requestFullscreen()
} catch (e) {
console.warn('Auto-fullscreen failed:', e)
}
}
}
}

View File

@@ -33,7 +33,8 @@
"contests": "Contests",
"polls": "Polls",
"info": "Info",
"wheel": "Fortune Wheel"
"wheel": "Fortune Wheel",
"menu": "Menu"
},
"notifications": {
"ticketNotifications": "Ticket Notifications",
@@ -184,7 +185,9 @@
"yourDevice": "Your device",
"copyLink": "Copy subscription link",
"copied": "Link copied!",
"openLink": "Open link"
"openLink": "Open link",
"instructions": "Instructions",
"changeApp": "Change app"
},
"myDevices": "My Devices",
"noDevices": "No connected devices",
@@ -1312,5 +1315,24 @@
"hours": "h",
"minutes": "m"
}
},
"blocking": {
"maintenance": {
"title": "Maintenance",
"defaultMessage": "Service is temporarily unavailable. Maintenance in progress.",
"reason": "Reason",
"waitMessage": "Please wait..."
},
"channel": {
"title": "Channel Subscription",
"defaultMessage": "Please subscribe to our channel to continue",
"openChannel": "Open Channel",
"checkSubscription": "Check Subscription",
"checking": "Checking...",
"waitSeconds": "Wait {{seconds}} sec.",
"hint": "Click check button after subscribing",
"notSubscribed": "You haven't subscribed to the channel yet",
"checkError": "Check failed. Please try again later."
}
}
}

View File

@@ -794,5 +794,24 @@
"hours": "ساعت",
"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": "Конкурсы",
"polls": "Опросы",
"info": "Информация",
"wheel": "Колесо удачи"
"wheel": "Колесо удачи",
"menu": "Меню"
},
"notifications": {
"ticketNotifications": "Уведомления о тикетах",
@@ -184,7 +185,9 @@
"yourDevice": "Ваше устройство",
"copyLink": "Скопировать ссылку",
"copied": "Ссылка скопирована!",
"openLink": "Открыть ссылку"
"openLink": "Открыть ссылку",
"instructions": "Инструкция",
"changeApp": "Сменить приложение"
},
"myDevices": "Мои устройства",
"noDevices": "Нет подключенных устройств",
@@ -1470,5 +1473,24 @@
"hours": "ч",
"minutes": "м"
}
},
"blocking": {
"maintenance": {
"title": "Технические работы",
"defaultMessage": "Сервис временно недоступен. Проводятся технические работы.",
"reason": "Причина",
"waitMessage": "Пожалуйста, подождите..."
},
"channel": {
"title": "Подписка на канал",
"defaultMessage": "Для продолжения работы подпишитесь на наш канал",
"openChannel": "Открыть канал",
"checkSubscription": "Проверить подписку",
"checking": "Проверяем...",
"waitSeconds": "Подождите {{seconds}} сек.",
"hint": "После подписки нажмите кнопку проверки",
"notSubscribed": "Вы ещё не подписались на канал",
"checkError": "Ошибка проверки. Попробуйте позже."
}
}
}

View File

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

View File

@@ -5,9 +5,10 @@ import { Link } from 'react-router-dom'
import { adminSettingsApi, SettingDefinition, SettingCategorySummary } from '../api/adminSettings'
import { brandingApi, setCachedBranding } from '../api/branding'
import { setCachedAnimationEnabled } from '../components/AnimatedBackground'
import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp'
import { themeColorsApi } from '../api/themeColors'
import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme'
import { ColorPicker } from '../components/ColorPicker'
import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES, ThemeColors } from '../types/theme'
import { ThemeBentoPicker } from '../components/ThemeBentoPicker'
import { applyThemeColors } from '../hooks/useThemeColors'
import { updateEnabledThemesCache } from '../hooks/useTheme'
@@ -857,6 +858,7 @@ export default function AdminSettings() {
const [searchQuery, setSearchQuery] = useState('')
const [editingName, setEditingName] = useState(false)
const [newName, setNewName] = useState('')
const [localColors, setLocalColors] = useState<ThemeColors | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Branding query and mutations
@@ -880,6 +882,21 @@ export default function AdminSettings() {
},
})
// Fullscreen toggle query and mutation
const { data: fullscreenSettings } = useQuery({
queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled,
})
const updateFullscreenMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
onSuccess: (data) => {
// Update local cache immediately for instant effect
setCachedFullscreenEnabled(data.enabled)
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] })
},
})
const updateNameMutation = useMutation({
mutationFn: (name: string) => brandingApi.updateName(name),
onSuccess: (data) => {
@@ -1229,6 +1246,29 @@ export default function AdminSettings() {
</button>
</div>
</div>
{/* Fullscreen Toggle */}
<div className="mt-4 pt-4 border-t border-dark-700/50">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-dark-200">Авто-Fullscreen</h3>
<p className="text-xs text-dark-500 mt-0.5">
Автоматически открывать на полный экран в Telegram
</p>
</div>
<button
onClick={() => updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))}
disabled={updateFullscreenMutation.isPending}
className={`relative w-12 h-6 rounded-full transition-colors ${
(fullscreenSettings?.enabled ?? false) ? 'bg-accent-500' : 'bg-dark-600'
} ${updateFullscreenMutation.isPending ? 'opacity-50' : ''}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
(fullscreenSettings?.enabled ?? false) ? 'left-7' : 'left-1'
}`} />
</button>
</div>
</div>
</div>
{/* Theme Colors Card */}
@@ -1316,113 +1356,18 @@ export default function AdminSettings() {
Включите нужные темы для пользователей. Минимум одна тема должна быть активна.
</p>
</div>
{/* Accent Color */}
<ColorPicker
label={t('theme.accent')}
description={t('theme.accentDescription')}
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
onChange={(color) => updateColorsMutation.mutate({ accent: color })}
disabled={updateColorsMutation.isPending}
<ThemeBentoPicker
currentColors={localColors || themeColors || DEFAULT_THEME_COLORS}
onColorsChange={(colors) => setLocalColors(colors)}
onSave={() => {
if (localColors) {
updateColorsMutation.mutate(localColors)
setLocalColors(null)
}
}}
isSaving={updateColorsMutation.isPending}
/>
{/* Dark Theme Section */}
<div>
<h3 className="text-sm font-medium text-dark-300 mb-3">{t('theme.darkTheme')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<ColorPicker
label={t('theme.background')}
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
onChange={(color) => updateColorsMutation.mutate({ darkBackground: color })}
disabled={updateColorsMutation.isPending}
/>
<ColorPicker
label={t('theme.surface')}
value={themeColors?.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
onChange={(color) => updateColorsMutation.mutate({ darkSurface: color })}
disabled={updateColorsMutation.isPending}
/>
<ColorPicker
label={t('theme.text')}
value={themeColors?.darkText || DEFAULT_THEME_COLORS.darkText}
onChange={(color) => updateColorsMutation.mutate({ darkText: color })}
disabled={updateColorsMutation.isPending}
/>
<ColorPicker
label={t('theme.textSecondary')}
value={themeColors?.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
onChange={(color) => updateColorsMutation.mutate({ darkTextSecondary: color })}
disabled={updateColorsMutation.isPending}
/>
</div>
</div>
{/* Light Theme Section */}
<div>
<h3 className="text-sm font-medium text-dark-300 mb-3">{t('theme.lightTheme')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<ColorPicker
label={t('theme.background')}
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
onChange={(color) => updateColorsMutation.mutate({ lightBackground: color })}
disabled={updateColorsMutation.isPending}
/>
<ColorPicker
label={t('theme.surface')}
value={themeColors?.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
onChange={(color) => updateColorsMutation.mutate({ lightSurface: color })}
disabled={updateColorsMutation.isPending}
/>
<ColorPicker
label={t('theme.text')}
value={themeColors?.lightText || DEFAULT_THEME_COLORS.lightText}
onChange={(color) => updateColorsMutation.mutate({ lightText: color })}
disabled={updateColorsMutation.isPending}
/>
<ColorPicker
label={t('theme.textSecondary')}
value={themeColors?.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
onChange={(color) => updateColorsMutation.mutate({ lightTextSecondary: color })}
disabled={updateColorsMutation.isPending}
/>
</div>
</div>
{/* Status Colors */}
<div>
<h3 className="text-sm font-medium text-dark-300 mb-3">{t('theme.statusColors')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<ColorPicker
label={t('theme.success')}
value={themeColors?.success || DEFAULT_THEME_COLORS.success}
onChange={(color) => updateColorsMutation.mutate({ success: color })}
disabled={updateColorsMutation.isPending}
/>
<ColorPicker
label={t('theme.warning')}
value={themeColors?.warning || DEFAULT_THEME_COLORS.warning}
onChange={(color) => updateColorsMutation.mutate({ warning: color })}
disabled={updateColorsMutation.isPending}
/>
<ColorPicker
label={t('theme.error')}
value={themeColors?.error || DEFAULT_THEME_COLORS.error}
onChange={(color) => updateColorsMutation.mutate({ error: color })}
disabled={updateColorsMutation.isPending}
/>
</div>
</div>
{/* Preview */}
<div className="p-4 bg-dark-800/50 rounded-xl">
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('theme.preview')}</h4>
<div className="flex flex-wrap gap-2">
<button className="btn-primary text-sm">{t('theme.previewButton')}</button>
<button className="btn-secondary text-sm">{t('theme.previewSecondary')}</button>
<span className="badge-success">{t('theme.success')}</span>
<span className="badge-warning">{t('theme.warning')}</span>
<span className="badge-error">{t('theme.error')}</span>
</div>
</div>
</div>
</div>

View File

@@ -32,6 +32,7 @@ export default function Balance() {
const [promocodeSuccess, setPromocodeSuccess] =
useState<{ message: string; amount: number } | null>(null)
const [transactionsPage, setTransactionsPage] = useState(1)
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const { data: transactions, isLoading } = useQuery<PaginatedResponse<Transaction>>({
queryKey: ['transactions', transactionsPage],
@@ -122,7 +123,7 @@ export default function Balance() {
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('balance.title')}</h1>
{/* Balance Card */}
<div className="card bg-gradient-to-br from-accent-500/10 to-transparent border-accent-500/20">
<div className="bento-card bento-card-glow bg-gradient-to-br from-accent-500/10 to-transparent">
<div className="text-sm text-dark-400 mb-2">{t('balance.currentBalance')}</div>
<div className="text-4xl sm:text-5xl font-bold text-dark-50">
{formatAmount(balanceData?.balance_rubles || 0)}
@@ -131,7 +132,7 @@ export default function Balance() {
</div>
{/* Promo Code Section */}
<div className="card">
<div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.promocode.title')}</h2>
<div className="flex gap-3">
<input
@@ -168,7 +169,7 @@ export default function Balance() {
{/* Payment Methods */}
{paymentMethods && paymentMethods.length > 0 && (
<div className="card">
<div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.topUpBalance')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{paymentMethods.map((method) => {
@@ -181,10 +182,10 @@ export default function Balance() {
key={method.id}
disabled={!method.is_available}
onClick={() => method.is_available && setSelectedMethod(method)}
className={`p-4 rounded-xl border text-left transition-all ${
className={`bento-card-hover p-4 text-left transition-all ${
method.is_available
? 'border-dark-700/50 hover:border-accent-500/50 bg-dark-800/30 cursor-pointer'
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
? 'cursor-pointer'
: 'opacity-50 cursor-not-allowed'
}`}
>
<div className="font-semibold text-dark-100">{translatedName || method.name}</div>
@@ -201,88 +202,104 @@ export default function Balance() {
</div>
)}
{/* Transaction History */}
<div className="card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('balance.transactionHistory')}</h2>
<div className="bento-card overflow-hidden">
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className="w-full flex items-center justify-between text-left"
>
<h2 className="text-lg font-semibold text-dark-100">{t('balance.transactionHistory')}</h2>
<svg
className={`w-5 h-5 text-dark-400 transition-transform duration-200 ${isHistoryOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : transactions?.items && transactions.items.length > 0 ? (
<div className="space-y-3">
{transactions.items.map((tx) => {
// API returns negative values for debits, positive for credits
const isPositive = tx.amount_rubles >= 0
const displayAmount = Math.abs(tx.amount_rubles)
const sign = isPositive ? '+' : '-'
const colorClass = isPositive ? 'text-success-400' : 'text-error-400'
return (
<div
key={tx.id}
className="flex items-center justify-between p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<span className={getTypeBadge(tx.type)}>
{getTypeLabel(tx.type)}
</span>
<span className="text-xs text-dark-500">
{new Date(tx.created_at).toLocaleDateString()}
</span>
</div>
{tx.description && (
<div className="text-sm text-dark-400">{tx.description}</div>
)}
</div>
<div className={`text-lg font-semibold ${colorClass}`}>
{sign}{formatAmount(displayAmount)} {currencySymbol}
</div>
{isHistoryOpen && (
<div className="mt-4">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
)
})}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
</div>
<div className="text-dark-400">{t('balance.noTransactions')}</div>
</div>
)}
) : transactions?.items && transactions.items.length > 0 ? (
<div className="space-y-3">
{transactions.items.map((tx) => {
const isPositive = tx.amount_rubles >= 0
const displayAmount = Math.abs(tx.amount_rubles)
const sign = isPositive ? '+' : '-'
const colorClass = isPositive ? 'text-success-400' : 'text-error-400'
{transactions && transactions.pages > 1 && (
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
<button
type="button"
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
disabled={transactions.page <= 1}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
transactions.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{t('common.back')}
</button>
<div className="flex-1 text-center">
{t('balance.page', { current: transactions.page, total: transactions.pages })}
</div>
<button
type="button"
onClick={() =>
setTransactionsPage((prev) =>
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1
)
}
disabled={transactions.page >= transactions.pages}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
transactions.page >= transactions.pages ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{t('common.next')}
</button>
return (
<div
key={tx.id}
className="flex items-center justify-between p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<span className={getTypeBadge(tx.type)}>
{getTypeLabel(tx.type)}
</span>
<span className="text-xs text-dark-500">
{new Date(tx.created_at).toLocaleDateString()}
</span>
</div>
{tx.description && (
<div className="text-sm text-dark-400">{tx.description}</div>
)}
</div>
<div className={`text-lg font-semibold ${colorClass}`}>
{sign}{formatAmount(displayAmount)} {currencySymbol}
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
</svg>
</div>
<div className="text-dark-400">{t('balance.noTransactions')}</div>
</div>
)}
{transactions && transactions.pages > 1 && (
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
<button
type="button"
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
disabled={transactions.page <= 1}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
transactions.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{t('common.back')}
</button>
<div className="flex-1 text-center">
{t('balance.page', { current: transactions.page, total: transactions.pages })}
</div>
<button
type="button"
onClick={() =>
setTransactionsPage((prev) =>
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1
)
}
disabled={transactions.page >= transactions.pages}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[120px] ${
transactions.page >= transactions.pages ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{t('common.next')}
</button>
</div>
)}
</div>
)}
</div>

View File

@@ -86,8 +86,8 @@ export default function Contests() {
{/* Game Modal */}
{selectedContest && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="card max-w-lg w-full max-h-[80vh] overflow-y-auto">
<div className="fixed inset-0 z-[60] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bento-card max-w-lg w-full max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{selectedContest.name}</h2>
<button onClick={handleCloseGame} className="text-dark-400 hover:text-dark-200">

View File

@@ -32,6 +32,12 @@ const ChevronRightIcon = () => (
</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)
const isLowPerfDevice = (() => {
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
@@ -123,6 +129,47 @@ export default function Dashboard() {
},
})
// Traffic refresh state and mutation
const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0)
const [trafficData, setTrafficData] = useState<{
traffic_used_gb: number
traffic_used_percent: number
is_unlimited: boolean
} | null>(null)
const refreshTrafficMutation = useMutation({
mutationFn: subscriptionApi.refreshTraffic,
onSuccess: (data) => {
setTrafficData({
traffic_used_gb: data.traffic_used_gb,
traffic_used_percent: data.traffic_used_percent,
is_unlimited: data.is_unlimited,
})
if (data.rate_limited && data.retry_after_seconds) {
setTrafficRefreshCooldown(data.retry_after_seconds)
} else {
setTrafficRefreshCooldown(60)
}
// Also update subscription query cache
queryClient.invalidateQueries({ queryKey: ['subscription'] })
},
onError: (error: { response?: { status?: number; headers?: { get?: (key: string) => string } } }) => {
if (error.response?.status === 429) {
const retryAfter = error.response.headers?.get?.('Retry-After')
setTrafficRefreshCooldown(retryAfter ? parseInt(retryAfter, 10) : 60)
}
},
})
// Cooldown timer
useEffect(() => {
if (trafficRefreshCooldown <= 0) return
const timer = setInterval(() => {
setTrafficRefreshCooldown((prev) => Math.max(0, prev - 1))
}, 1000)
return () => clearInterval(timer)
}, [trafficRefreshCooldown])
const hasNoSubscription = !subscription && !subLoading && subError
// Show onboarding for new users after data loads
@@ -207,7 +254,7 @@ export default function Dashboard() {
{/* Subscription Status - Main Card */}
{subscription && (
<div className="card">
<div className="bento-card">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
<span className={subscription.is_active ? 'badge-success' : 'badge-error'}>
@@ -223,9 +270,19 @@ export default function Dashboard() {
</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">
{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>
@@ -248,12 +305,14 @@ export default function Dashboard() {
<div className="mt-6">
<div className="flex justify-between text-sm mb-2">
<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 className="progress-bar">
<div
className={`progress-fill ${getTrafficColor(subscription.traffic_used_percent)}`}
style={{ width: `${Math.min(subscription.traffic_used_percent, 100)}%` }}
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
style={{ width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%` }}
/>
</div>
</div>
@@ -277,9 +336,9 @@ export default function Dashboard() {
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bento-grid">
{/* Balance */}
<Link to="/balance" className="card-hover group" data-onboarding="balance">
<Link to="/balance" className="bento-card-hover group" data-onboarding="balance">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('balance.currentBalance')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
@@ -293,7 +352,7 @@ export default function Dashboard() {
</Link>
{/* Subscription */}
<Link to="/subscription" className="card-hover group" data-onboarding="subscription-status">
<Link to="/subscription" className="bento-card-hover group" data-onboarding="subscription-status">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('subscription.title')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
@@ -329,7 +388,7 @@ export default function Dashboard() {
</Link>
{/* Referrals */}
<Link to="/referral" className="card-hover group">
<Link to="/referral" className="bento-card-hover group">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('referral.stats.totalReferrals')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
@@ -344,7 +403,7 @@ export default function Dashboard() {
</Link>
{/* Earnings */}
<Link to="/referral" className="card-hover group">
<Link to="/referral" className="bento-card-hover group">
<div className="flex items-center justify-between mb-3">
<span className="text-dark-400 text-sm">{t('referral.stats.totalEarnings')}</span>
<span className="text-dark-600 group-hover:text-accent-400 transition-colors">
@@ -363,7 +422,7 @@ export default function Dashboard() {
{/* Trial Activation */}
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
<div className="bento-card-glow border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0">
<SparklesIcon />
@@ -424,7 +483,7 @@ export default function Dashboard() {
{wheelConfig?.is_enabled && (
<Link
to="/wheel"
className="group card-hover flex items-center justify-between"
className="group bento-card-hover flex items-center justify-between"
>
<div className="flex items-center gap-4">
{/* Emoji */}
@@ -445,7 +504,7 @@ export default function Dashboard() {
)}
{/* Quick Actions */}
<div className="card" data-onboarding="quick-actions">
<div className="bento-card" data-onboarding="quick-actions">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('dashboard.quickActions')}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Link to="/balance" className="btn-secondary justify-center text-center text-sm py-2.5">

View File

@@ -166,7 +166,7 @@ export default function Info() {
return (
<div className="space-y-2">
{faqPages.map((faq: FaqPage) => (
<div key={faq.id} className="card p-0 overflow-hidden">
<div key={faq.id} className="bento-card p-0 overflow-hidden">
<button
onClick={() => toggleFaq(faq.id)}
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-dark-800/50 transition-colors"
@@ -203,7 +203,7 @@ export default function Info() {
}
return (
<div className="card prose prose-invert max-w-none">
<div className="bento-card prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(rules.content) }} />
{rules.updated_at && (
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
@@ -232,7 +232,7 @@ export default function Info() {
}
return (
<div className="card prose prose-invert max-w-none">
<div className="bento-card prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(privacy.content) }} />
{privacy.updated_at && (
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">
@@ -261,7 +261,7 @@ export default function Info() {
}
return (
<div className="card prose prose-invert max-w-none">
<div className="bento-card prose prose-invert max-w-none">
<div dangerouslySetInnerHTML={{ __html: formatContent(offer.content) }} />
{offer.updated_at && (
<p className="text-sm text-dark-400 mt-6 pt-4 border-t border-dark-700">

View File

@@ -97,7 +97,7 @@ export default function Profile() {
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('profile.title')}</h1>
{/* User Info Card */}
<div className="card">
<div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.accountInfo')}</h2>
<div className="space-y-4">
<div className="flex justify-between items-center py-3 border-b border-dark-800/50">
@@ -126,7 +126,7 @@ export default function Profile() {
</div>
{/* Email Section */}
<div className="card">
<div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.emailAuth')}</h2>
{user?.email ? (
@@ -250,7 +250,7 @@ export default function Profile() {
</div>
{/* Notification Settings */}
<div className="card">
<div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.notifications.title')}</h2>
{notificationsLoading ? (

View File

@@ -68,6 +68,12 @@ export default function Referral() {
queryFn: referralApi.getReferralInfo,
})
// Build referral link using frontend env variable for correct bot username
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME
const referralLink = info?.referral_code && botUsername
? `https://t.me/${botUsername}?start=${info.referral_code}`
: info?.referral_link || ''
const { data: terms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
@@ -90,15 +96,15 @@ export default function Referral() {
})
const copyLink = () => {
if (info?.referral_link) {
navigator.clipboard.writeText(info.referral_link)
if (referralLink) {
navigator.clipboard.writeText(referralLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const shareLink = () => {
if (!info?.referral_link) return
if (!referralLink) return
const shareText = t('referral.shareMessage', {
percent: info?.commission_percent || 0,
botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet',
@@ -109,7 +115,7 @@ export default function Referral() {
.share({
title: t('referral.title'),
text: shareText,
url: info.referral_link,
url: referralLink,
})
.catch(() => {
// ignore cancellation errors
@@ -118,7 +124,7 @@ export default function Referral() {
}
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
info.referral_link
referralLink
)}&text=${encodeURIComponent(shareText)}`
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
}
@@ -138,8 +144,8 @@ export default function Referral() {
</h1>
{/* Stats Cards */}
<div className='grid grid-cols-2 sm:grid-cols-3 gap-4'>
<div className='card'>
<div className='bento-grid'>
<div className='bento-card-hover'>
<div className='text-sm text-dark-400'>
{t('referral.stats.totalReferrals')}
</div>
@@ -149,7 +155,7 @@ export default function Referral() {
{t('referral.stats.activeReferrals').toLowerCase()}
</div>
</div>
<div className='card'>
<div className='bento-card-hover'>
<div className='text-sm text-dark-400'>
{t('referral.stats.totalEarnings')}
</div>
@@ -157,7 +163,7 @@ export default function Referral() {
{formatPositive(info?.total_earnings_rubles || 0)}
</div>
</div>
<div className='card col-span-2 sm:col-span-1'>
<div className='bento-card-hover col-span-2 sm:col-span-1'>
<div className='text-sm text-dark-400'>
{t('referral.stats.commissionRate')}
</div>
@@ -168,7 +174,7 @@ export default function Referral() {
</div>
{/* Referral Link */}
<div className='card'>
<div className='bento-card'>
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
{t('referral.yourLink')}
</h2>
@@ -176,16 +182,16 @@ export default function Referral() {
<input
type='text'
readOnly
value={info?.referral_link || ''}
value={referralLink}
className='input flex-1'
/>
<div className='flex gap-2'>
<button
onClick={copyLink}
disabled={!info?.referral_link}
disabled={!referralLink}
className={`btn-primary px-5 ${
copied ? 'bg-success-500 hover:bg-success-500' : ''
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
} ${!referralLink ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
<span className='ml-2'>
@@ -194,9 +200,9 @@ export default function Referral() {
</button>
<button
onClick={shareLink}
disabled={!info?.referral_link}
disabled={!referralLink}
className={`btn-secondary px-5 flex items-center ${
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''
!referralLink ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<ShareIcon />
@@ -211,7 +217,7 @@ export default function Referral() {
{/* Program Terms */}
{terms && (
<div className='card'>
<div className='bento-card'>
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
{t('referral.terms.title')}
</h2>
@@ -253,7 +259,7 @@ export default function Referral() {
)}
{/* Referrals List */}
<div className='card'>
<div className='bento-card'>
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
{t('referral.yourReferrals')}
</h2>
@@ -308,7 +314,7 @@ export default function Referral() {
{/* Earnings History */}
{earnings?.items && earnings.items.length > 0 && (
<div className='card'>
<div className='bento-card'>
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
{t('referral.earningsHistory')}
</h2>

View File

@@ -97,6 +97,8 @@ export default function Subscription() {
const [devicesToAdd, setDevicesToAdd] = useState(1)
const [showTrafficTopup, setShowTrafficTopup] = useState(false)
const [selectedTrafficPackage, setSelectedTrafficPackage] = useState<number | null>(null)
const [showServerManagement, setShowServerManagement] = useState(false)
const [selectedServersToUpdate, setSelectedServersToUpdate] = useState<string[]>([])
const { data: subscription, isLoading } = useQuery({
queryKey: ['subscription'],
@@ -299,6 +301,31 @@ export default function Subscription() {
},
})
// Countries/servers query
const { data: countriesData, isLoading: countriesLoading } = useQuery({
queryKey: ['countries'],
queryFn: subscriptionApi.getCountries,
enabled: showServerManagement && !!subscription && !subscription.is_trial,
})
// Initialize selected servers when data loads
useEffect(() => {
if (countriesData && showServerManagement) {
const connected = countriesData.countries.filter(c => c.is_connected).map(c => c.uuid)
setSelectedServersToUpdate(connected)
}
}, [countriesData, showServerManagement])
// Countries update mutation
const updateCountriesMutation = useMutation({
mutationFn: (countries: string[]) => subscriptionApi.updateCountries(countries),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] })
queryClient.invalidateQueries({ queryKey: ['countries'] })
setShowServerManagement(false)
},
})
// Auto-scroll to switch tariff modal when it appears
useEffect(() => {
if (switchTariffId && switchModalRef.current) {
@@ -399,7 +426,7 @@ export default function Subscription() {
{/* Current Subscription */}
{subscription ? (
<div className="card">
<div className="bento-card">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.currentPlan')}</h2>
@@ -604,7 +631,7 @@ export default function Subscription() {
{/* Daily Subscription Pause */}
{subscription && subscription.is_daily && !subscription.is_trial && (
<div className="card">
<div className="bento-card">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.pause.title')}</h2>
@@ -703,7 +730,7 @@ export default function Subscription() {
{/* Additional Options (Buy Devices) */}
{subscription && subscription.is_active && !subscription.is_trial && (
<div className="card">
<div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">Дополнительные опции</h2>
{/* Buy Devices */}
@@ -912,12 +939,218 @@ export default function Subscription() {
)}
</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>
)}
{/* My Devices Section */}
{subscription && (
<div className="card">
<div className="bento-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.myDevices')}</h2>
{devicesData && devicesData.devices.length > 0 && (
@@ -987,7 +1220,7 @@ export default function Subscription() {
{/* Tariffs Section - Combined Purchase/Extend/Switch like MiniApp */}
{isTariffsMode && tariffs.length > 0 && (
<div ref={tariffsCardRef} className="card">
<div ref={tariffsCardRef} className="bento-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-dark-100">
{subscription?.is_daily && !subscription?.is_trial
@@ -1142,10 +1375,10 @@ export default function Subscription() {
return (
<div
key={tariff.id}
className={`p-5 rounded-xl border text-left transition-all ${
className={`bento-card-hover p-5 text-left transition-all ${
isCurrentTariff
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30'
? 'bento-card-glow border-accent-500'
: ''
}`}
>
<div className="flex items-start justify-between mb-3">
@@ -1689,7 +1922,7 @@ export default function Subscription() {
{/* Purchase/Extend Section - Classic Mode */}
{classicOptions && classicOptions.periods.length > 0 && (
<div ref={tariffsCardRef} className="card">
<div ref={tariffsCardRef} className="bento-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-dark-100">
{subscription && !subscription.is_trial ? t('subscription.extend') : t('subscription.getSubscription')}
@@ -1751,10 +1984,10 @@ export default function Subscription() {
setSelectedDevices(period.devices.current)
}
}}
className={`p-4 rounded-xl border text-left transition-all relative ${
className={`bento-card-hover p-4 text-left transition-all relative ${
selectedPeriod?.id === period.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
? 'bento-card-glow border-accent-500'
: ''
}`}
>
{displayDiscount && displayDiscount > 0 && (
@@ -1789,13 +2022,11 @@ export default function Subscription() {
key={option.value}
onClick={() => setSelectedTraffic(option.value)}
disabled={!option.is_available}
className={`p-4 rounded-xl border text-center transition-all relative ${
className={`bento-card-hover p-4 text-center transition-all relative ${
selectedTraffic === option.value
? 'border-accent-500 bg-accent-500/10'
: option.is_available
? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
}`}
? 'bento-card-glow border-accent-500'
: ''
} ${!option.is_available ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{promoTraffic.percent && (
<div className="absolute -top-2 -right-2 px-2 py-0.5 bg-orange-500 text-white text-xs font-medium rounded-full">

View File

@@ -385,7 +385,7 @@ export default function Support() {
return (
<div className="max-w-md mx-auto mt-12">
<div className="card text-center">
<div className="bento-card text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
<svg className="w-8 h-8 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
@@ -454,7 +454,7 @@ export default function Support() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Tickets List */}
<div className="lg:col-span-1 card">
<div className="lg:col-span-1 bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('support.yourTickets')}</h2>
{isLoading ? (
@@ -471,7 +471,7 @@ export default function Support() {
setShowCreateForm(false)
setReplyAttachment(null)
}}
className={`w-full text-left p-4 rounded-xl border transition-all ${
className={`w-full text-left p-4 rounded-bento border transition-all ${
selectedTicket?.id === ticket.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
@@ -502,7 +502,7 @@ export default function Support() {
</div>
{/* Ticket Detail / Create Form */}
<div className="lg:col-span-2 card">
<div className="lg:col-span-2 bento-card">
{showCreateForm ? (
<div>
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('support.createTicket')}</h2>

View File

@@ -692,7 +692,7 @@ export default function Wheel() {
{/* Result Modal */}
{showResultModal && spinResult && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
<div
className={`relative w-full max-w-md rounded-3xl p-8 text-center overflow-hidden ${
spinResult.success

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

@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap');
@tailwind base;
@tailwind components;
@@ -8,6 +8,14 @@
:root {
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
/* Bento Design System */
--bento-radius: 24px;
--bento-radius-lg: 32px;
--bento-gap: 16px;
--bento-gap-lg: 24px;
--bento-padding: 24px;
--bento-padding-lg: 32px;
/* Theme colors - RGB format for opacity support */
/* Dark palette (RGB values) */
--color-dark-50: 248, 250, 252;
@@ -102,6 +110,34 @@
html {
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgb(var(--color-dark-700)) transparent;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgb(var(--color-dark-700));
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--color-dark-600));
}
.light ::-webkit-scrollbar-thumb {
background: rgb(var(--color-champagne-400));
}
.light ::-webkit-scrollbar-thumb:hover {
background: rgb(var(--color-champagne-500));
}
/* Smooth scroll only on desktop - mobile uses native */
@@ -117,6 +153,19 @@
overscroll-behavior-y: contain;
/* iOS smooth scrolling */
-webkit-overflow-scrolling: touch;
position: relative;
}
/* Global Noise Texture */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
opacity: 0.03;
mix-blend-mode: overlay;
}
/* Optimize main scrollable content */
@@ -203,6 +252,10 @@
background-color: rgba(254, 249, 240, 0.5) !important; /* champagne-100/50 */
}
.light .bg-dark-900\/95 {
background-color: rgba(254, 249, 240, 0.95) !important; /* champagne-100/95 - mobile menu sticky header */
}
.light .bg-dark-950 {
background-color: #F7E7CE !important; /* champagne-200 */
}
@@ -323,6 +376,135 @@
}
@layer components {
/* ========== BENTO DESIGN SYSTEM ========== */
.bento-card {
@apply bg-dark-900/70 border border-dark-700/40;
border-radius: var(--bento-radius);
padding: var(--bento-padding);
transform: translateZ(0);
/* Glass Border - Inset Highlight */
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
/* Stagger animation support */
animation: bentoFadeIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: calc(var(--stagger, 0) * 50ms);
position: relative;
overflow: hidden;
}
@media (min-width: 1024px) {
.bento-card {
@apply bg-dark-900/50 backdrop-blur-sm;
}
}
/* Disable animation if user prefers reduced motion */
@media (prefers-reduced-motion: reduce) {
.bento-card {
animation: none;
}
}
.bento-card-hover {
@apply bento-card cursor-pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.bento-card-hover:hover {
@apply bg-dark-800/60 border-dark-600/50 shadow-lg;
transform: translateY(-4px);
/* Intensify Glass Border & Add Top Spotlight */
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.1),
0 10px 40px -10px rgba(0,0,0,0.5);
}
/* CSS Spotlight Effect via pseudo-element */
.bento-card-hover::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 10;
}
.bento-card-hover:hover::after {
opacity: 1;
}
.bento-card-hover:active {
transform: scale(0.98);
transition-duration: 0.15s;
}
.bento-card-glow {
@apply bento-card cursor-pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.bento-card-glow:hover {
@apply shadow-glow border-accent-500/30;
transform: translateY(-4px);
}
.bento-card-glow:active {
transform: scale(0.98);
transition-duration: 0.15s;
}
.bento-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--bento-gap);
}
@media (min-width: 375px) {
.bento-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 768px) {
.bento-grid {
grid-template-columns: repeat(4, 1fr);
gap: var(--bento-gap-lg);
}
}
.light .bento-card {
@apply bg-white/90 border-champagne-300/50 shadow-sm;
/* Light Theme Glass Border */
box-shadow: inset 0 1px 0 0 rgba(0, 0, 0, 0.03);
}
@media (min-width: 1024px) {
.light .bento-card {
@apply bg-white/80 backdrop-blur-sm;
}
}
.light .bento-card-hover:hover {
@apply bg-white border-champagne-400/50 shadow-md;
transform: translateY(-4px);
/* Intensify Light Theme Glass Border */
box-shadow:
inset 0 1px 0 0 rgba(0, 0, 0, 0.06),
0 10px 40px -10px rgba(160, 139, 94, 0.15);
}
/* Adjust spotlight for light theme to be a warm glow */
.light .bento-card-hover::after {
background: radial-gradient(circle at top, rgba(255, 253, 249, 0.8), transparent 70%);
}
.light .bento-card-glow:hover {
@apply shadow-lg border-accent-400/40;
transform: translateY(-4px);
}
/* ========== DARK THEME COMPONENTS (default) ========== */
/* Cards - Dark (optimized for mobile) */
@@ -489,12 +671,17 @@
@apply nav-item text-accent-400 bg-accent-500/10;
}
/* Bottom nav - Dark (optimized for mobile) */
.bottom-nav {
@apply fixed bottom-0 left-0 right-0 z-50
bg-dark-900/95 border-t border-dark-800/50
safe-area-pb;
@apply fixed z-50 bg-dark-900/95;
bottom: 16px;
left: 16px;
right: 16px;
border-radius: var(--bento-radius);
padding: 8px 4px;
padding-bottom: calc(8px + var(--safe-area-inset-bottom));
transform: translateZ(0);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
}
@media (min-width: 1024px) {
@@ -504,12 +691,17 @@
}
.bottom-nav-item {
@apply flex flex-col items-center justify-center py-2 px-2 flex-1 min-w-[60px]
text-dark-500 transition-colors duration-200 shrink-0;
@apply flex flex-col items-center justify-center py-2.5 px-3 flex-1 min-w-[56px]
text-dark-500 transition-all duration-200 shrink-0 rounded-2xl;
}
.bottom-nav-item:hover {
@apply text-dark-300;
}
.bottom-nav-item-active {
@apply bottom-nav-item text-accent-400;
@apply flex flex-col items-center justify-center py-2.5 px-3 flex-1 min-w-[56px]
text-accent-400 bg-accent-500/15 rounded-2xl transition-all duration-200 shrink-0;
}
/* Divider - Dark */
@@ -664,17 +856,28 @@
@apply text-champagne-800 bg-champagne-200/70;
}
/* Bottom nav - Light */
.light .bottom-nav {
@apply bg-white/90 backdrop-blur-xl border-t border-champagne-200;
@apply bg-white/95;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(0, 0, 0, 0.05);
}
@media (min-width: 1024px) {
.light .bottom-nav {
@apply bg-white/80 backdrop-blur-xl;
}
}
.light .bottom-nav-item {
@apply text-champagne-500;
}
.light .bottom-nav-item:hover {
@apply text-champagne-700;
}
.light .bottom-nav-item-active {
@apply text-champagne-800;
@apply text-champagne-800 bg-champagne-300/40;
}
/* Divider - Light */
@@ -920,6 +1123,17 @@
}
/* Animations */
@keyframes bentoFadeIn {
0% {
opacity: 0;
transform: translateY(16px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
@@ -1246,6 +1460,42 @@ input[type="range"]::-moz-range-thumb {
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.7) 0%, transparent 70%);
}
/* Mobile: brighter and faster animations */
@media (max-width: 768px) {
.wave-blob {
opacity: 0.7;
filter: blur(50px);
}
.wave-blob-1 {
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.8) 0%, transparent 70%);
animation: wave1-mobile 12s ease-in-out infinite;
}
.wave-blob-2 {
width: 280px;
height: 280px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.8) 0%, transparent 70%);
animation: wave2-mobile 15s ease-in-out infinite;
}
@keyframes wave1-mobile {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
25% { transform: translate3d(15%, 20%, 0) scale(1.1); }
50% { transform: translate3d(5%, 35%, 0) scale(1.15); }
75% { transform: translate3d(20%, 10%, 0) scale(1.05); }
}
@keyframes wave2-mobile {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
25% { transform: translate3d(-20%, -15%, 0) scale(1.15); }
50% { transform: translate3d(-10%, -30%, 0) scale(1.1); }
75% { transform: translate3d(-25%, -5%, 0) scale(1.05); }
}
}
/* Disable animations for reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.wave-blob {

View File

@@ -65,6 +65,7 @@ export interface Subscription {
autopay_enabled: boolean
autopay_days_before: number
subscription_url: string | null
hide_subscription_link: boolean
is_active: boolean
is_expired: boolean
traffic_purchases?: TrafficPurchase[]

View File

@@ -0,0 +1,102 @@
export interface HSLColor {
h: number
s: number
l: number
}
export interface RGBColor {
r: number
g: number
b: number
}
export function hexToRgb(hex: string): RGBColor {
if (hex.length === 4) {
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
}
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return { r, g, b }
}
export function rgbToHex(r: number, g: number, b: number): string {
return (
'#' +
[r, g, b]
.map((x) => {
const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16)
return hex.length === 1 ? '0' + hex : hex
})
.join('')
)
}
export function hexToHsl(hex: string): HSLColor {
const { r, g, b } = hexToRgb(hex)
const rNorm = r / 255
const gNorm = g / 255
const bNorm = b / 255
const max = Math.max(rNorm, gNorm, bNorm)
const min = Math.min(rNorm, gNorm, bNorm)
let h = 0
let s = 0
const l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case rNorm:
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6
break
case gNorm:
h = ((bNorm - rNorm) / d + 2) / 6
break
case bNorm:
h = ((rNorm - gNorm) / d + 4) / 6
break
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
}
}
export function hslToRgb(h: number, s: number, l: number): RGBColor {
const sNorm = s / 100
const lNorm = l / 100
const a = sNorm * Math.min(lNorm, 1 - lNorm)
const f = (n: number) => {
const k = (n + h / 30) % 12
const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
return Math.round(255 * color)
}
return { r: f(0), g: f(8), b: f(4) }
}
export function hslToHex(h: number, s: number, l: number): string {
const { r, g, b } = hslToRgb(h, s, l)
return rgbToHex(r, g, b)
}
export function isValidHex(hex: string): boolean {
return /^#[0-9A-Fa-f]{6}$/.test(hex)
}
export function normalizeHex(hex: string): string {
if (!hex.startsWith('#')) {
hex = '#' + hex
}
if (hex.length === 4) {
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
}
return hex.toLowerCase()
}

View File

@@ -106,7 +106,15 @@ export default {
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
sans: ['Manrope', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
},
borderRadius: {
'bento': '24px',
'4xl': '32px',
},
spacing: {
'bento': '16px',
'bento-lg': '24px',
},
fontSize: {
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],