Add files via upload

This commit is contained in:
Egor
2026-01-15 19:18:17 +03:00
committed by GitHub
parent b118008cdc
commit 7be6b5c0ae
70 changed files with 21835 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
import { useState, useRef, useEffect } from 'react'
interface ColorPickerProps {
value: string
onChange: (color: string) => void
label: string
description?: string
disabled?: boolean
}
const PRESET_COLORS = [
'#3b82f6', // Blue
'#ef4444', // Red
'#22c55e', // Green
'#f59e0b', // Amber
'#8b5cf6', // Violet
'#ec4899', // Pink
'#06b6d4', // Cyan
'#14b8a6', // Teal
'#84cc16', // Lime
'#f97316', // Orange
'#6366f1', // Indigo
'#a855f7', // Purple
]
export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [localValue, setLocalValue] = useState(value)
const containerRef = useRef<HTMLDivElement>(null)
const colorInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
// Close on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleColorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newColor = e.target.value
setLocalValue(newColor)
onChange(newColor)
}
const handleHexInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value
// Add # if not present
if (newValue && !newValue.startsWith('#')) {
newValue = '#' + newValue
}
// Validate hex format
if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) {
setLocalValue(newValue)
// Only trigger onChange for valid complete hex
if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) {
onChange(newValue)
}
}
}
const handlePresetClick = (color: string) => {
setLocalValue(color)
onChange(color)
setIsOpen(false)
}
return (
<div ref={containerRef} className="relative">
<label className="block text-sm font-medium text-dark-200 mb-1">{label}</label>
{description && <p className="text-xs text-dark-500 mb-2">{description}</p>}
<div className="flex items-center gap-3">
{/* Color preview button */}
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className="w-10 h-10 rounded-lg border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
style={{ backgroundColor: localValue || '#000000' }}
title={localValue}
/>
{/* Hex input */}
<input
type="text"
value={localValue}
onChange={handleHexInputChange}
disabled={disabled}
className="input w-28 py-2 font-mono text-sm uppercase"
placeholder="#000000"
maxLength={7}
/>
{/* Hidden native color picker for direct access */}
<input
ref={colorInputRef}
type="color"
value={localValue || '#000000'}
onChange={handleColorInputChange}
disabled={disabled}
className="sr-only"
/>
{/* Native picker button */}
<button
type="button"
onClick={() => colorInputRef.current?.click()}
disabled={disabled}
className="btn-secondary p-2 disabled:opacity-50"
title="Open color picker"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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>
</button>
</div>
{/* Dropdown with presets */}
{isOpen && (
<div className="absolute z-50 mt-2 p-3 bg-dark-800 rounded-xl border border-dark-700 shadow-xl animate-fade-in">
<div className="text-xs text-dark-400 mb-2">Preset colors</div>
<div className="grid grid-cols-6 gap-2">
{PRESET_COLORS.map((preset) => (
<button
key={preset}
onClick={() => handlePresetClick(preset)}
className={`w-7 h-7 rounded-lg border-2 transition-all hover:scale-110 ${
localValue === preset ? 'border-white' : 'border-dark-600 hover:border-dark-500'
}`}
style={{ backgroundColor: preset }}
title={preset}
/>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,562 @@
import { useState, useMemo, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { subscriptionApi } from '../api/subscription'
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"/>
</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"/>
</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"/>
</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"/>
</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"/>
</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"/>
</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,
}
// Platform order for display
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
// Detect user's platform from user agent
function detectPlatform(): string | null {
if (typeof window === 'undefined' || !navigator?.userAgent) {
return null
}
const ua = navigator.userAgent.toLowerCase()
// Check for mobile devices first
if (/iphone|ipad|ipod/.test(ua)) {
return 'ios'
}
if (/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'
}
return null
}
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 { data: appConfig, isLoading, error } = useQuery<AppConfig>({
queryKey: ['appConfig'],
queryFn: () => subscriptionApi.getAppConfig(),
})
// Auto-detect platform on mount
useEffect(() => {
const detected = detectPlatform()
setDetectedPlatform(detected)
}, [])
// 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
if (detectedPlatform && available.includes(detectedPlatform)) {
const filtered = available.filter(p => p !== detectedPlatform)
return [detectedPlatform, ...filtered]
}
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 {
await navigator.clipboard.writeText(appConfig.subscriptionUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = appConfig.subscriptionUrl
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
// Handle deep link click - use miniapp redirect page like in miniapp index.html
const handleConnect = (app: AppInfo) => {
if (!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 {
tg.openLink(redirectUrl, { try_instant_view: false })
return
} catch (e) {
console.warn('tg.openLink failed:', e)
}
}
// Fallback - direct navigation
window.location.href = redirectUrl
}
// Modal wrapper classes - bottom sheet on mobile, centered on desktop
const modalOverlayClass = "fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center sm:justify-center"
const modalCardClass = "w-full sm:max-w-lg sm:mx-4 bg-dark-850 sm:rounded-2xl rounded-t-2xl rounded-b-none border-t border-x sm:border border-dark-700/50 shadow-2xl overflow-hidden"
const modalContentClass = "p-4 sm:p-6 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6"
// Allow the sheet to almost fill the viewport height on phones
const modalScrollableClass = "h-[calc(100vh-1rem)] sm:h-auto sm:max-h-[85vh]"
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>
</div>
</div>
)
}
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>
</div>
</div>
)
}
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>
</div>
</div>
)
}
// Step 1: Select platform
if (!selectedPlatform) {
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>
{/* 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>
<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 />}
</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>
)}
</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>
</div>
</div>
)
}
// 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>
</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"
>
<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>
))}
</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>
{/* 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.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 */}
<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>
)}
{/* 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>
)}
</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>
</div>
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { useTranslation } from 'react-i18next'
import { useState, useRef, useEffect } from 'react'
const languages = [
{ code: 'ru', name: 'RU', flag: '🇷🇺', fullName: 'Русский' },
{ code: 'en', name: 'EN', flag: '🇬🇧', fullName: 'English' },
{ code: 'zh', name: 'ZH', flag: '🇨🇳', fullName: '中文' },
{ code: 'fa', name: 'FA', flag: '🇮🇷', fullName: 'فارسی' },
]
export default function LanguageSwitcher() {
const { i18n } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const currentLang = languages.find((l) => l.code === i18n.language) || languages[0]
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const changeLanguage = (code: string) => {
i18n.changeLanguage(code)
// Set document direction for RTL languages
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr'
setIsOpen(false)
}
// Set initial direction on mount
useEffect(() => {
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr'
}, [i18n.language])
return (
<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"
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' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-40 bg-dark-800 rounded-xl shadow-lg border border-dark-700/50 py-1 z-50 animate-fade-in">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
lang.code === i18n.language
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-300 hover:bg-dark-700/50'
}`}
>
<span>{lang.flag}</span>
<span>{lang.fullName}</span>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,240 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
interface OnboardingStep {
target: string // data-onboarding attribute value
title: string
description: string
placement: 'top' | 'bottom' | 'left' | 'right'
}
interface OnboardingProps {
steps: OnboardingStep[]
onComplete: () => void
onSkip: () => void
}
const STORAGE_KEY = 'onboarding_completed'
export function useOnboarding() {
const [isCompleted, setIsCompleted] = useState(() => {
return localStorage.getItem(STORAGE_KEY) === 'true'
})
const complete = useCallback(() => {
localStorage.setItem(STORAGE_KEY, 'true')
setIsCompleted(true)
}, [])
const reset = useCallback(() => {
localStorage.removeItem(STORAGE_KEY)
setIsCompleted(false)
}, [])
return { isCompleted, complete, reset }
}
export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProps) {
const { t } = useTranslation()
const [currentStep, setCurrentStep] = useState(0)
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
const [isVisible, setIsVisible] = useState(false)
const tooltipRef = useRef<HTMLDivElement>(null)
const step = steps[currentStep]
// Find and highlight target element
useEffect(() => {
const findTarget = () => {
const target = document.querySelector(`[data-onboarding="${step.target}"]`)
if (target) {
const rect = target.getBoundingClientRect()
setTargetRect(rect)
// Scroll element into view if needed
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Delay visibility for smooth animation
setTimeout(() => setIsVisible(true), 100)
}
}
setIsVisible(false)
const timer = setTimeout(findTarget, 300)
return () => clearTimeout(timer)
}, [step.target])
// Recalculate position on resize/scroll
useEffect(() => {
const updatePosition = () => {
const target = document.querySelector(`[data-onboarding="${step.target}"]`)
if (target) {
setTargetRect(target.getBoundingClientRect())
}
}
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
return () => {
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
}
}, [step.target])
const handleNext = () => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1)
} else {
onComplete()
}
}
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleSkip = () => {
onSkip()
}
// Calculate tooltip position
const getTooltipStyle = (): React.CSSProperties => {
if (!targetRect) return { opacity: 0 }
const padding = 16
const tooltipWidth = 320
const tooltipHeight = tooltipRef.current?.offsetHeight || 150
let top = 0
let left = 0
switch (step.placement) {
case 'bottom':
top = targetRect.bottom + padding
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2
break
case 'top':
top = targetRect.top - tooltipHeight - padding
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2
break
case 'left':
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2
left = targetRect.left - tooltipWidth - padding
break
case 'right':
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2
left = targetRect.right + padding
break
}
// Keep within viewport
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
if (left < padding) left = padding
if (left + tooltipWidth > viewportWidth - padding) {
left = viewportWidth - tooltipWidth - padding
}
if (top < padding) top = padding
if (top + tooltipHeight > viewportHeight - padding) {
top = viewportHeight - tooltipHeight - padding
}
return {
top,
left,
width: tooltipWidth,
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'scale(1)' : 'scale(0.95)',
}
}
// Spotlight style
const getSpotlightStyle = (): React.CSSProperties => {
if (!targetRect) return { opacity: 0 }
const padding = 8
return {
top: targetRect.top - padding,
left: targetRect.left - padding,
width: targetRect.width + padding * 2,
height: targetRect.height + padding * 2,
opacity: isVisible ? 1 : 0,
}
}
return createPortal(
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
{/* Spotlight */}
<div className="onboarding-spotlight" style={getSpotlightStyle()} />
{/* Tooltip */}
<div
ref={tooltipRef}
className={`onboarding-tooltip tooltip-${step.placement}`}
style={getTooltipStyle()}
>
{/* Progress indicator */}
<div className="flex items-center gap-1.5 mb-4">
{steps.map((_, index) => (
<div
key={index}
className={`h-1 rounded-full transition-all duration-300 ${
index === currentStep
? 'w-6 bg-accent-500'
: index < currentStep
? 'w-2 bg-accent-500/50'
: 'w-2 bg-dark-700'
}`}
/>
))}
</div>
{/* Content */}
<h3 className="text-lg font-semibold text-dark-50 mb-2">{step.title}</h3>
<p className="text-dark-400 text-sm mb-5">{step.description}</p>
{/* Actions */}
<div className="flex items-center justify-between">
<button
onClick={handleSkip}
className="text-dark-500 hover:text-dark-300 text-sm transition-colors"
>
{t('onboarding.skip', 'Skip')}
</button>
<div className="flex gap-2">
{currentStep > 0 && (
<button onClick={handlePrev} className="btn-ghost text-sm px-3 py-1.5">
{t('common.back', 'Back')}
</button>
)}
<button onClick={handleNext} className="btn-primary text-sm px-4 py-1.5">
{currentStep === steps.length - 1
? t('onboarding.finish', 'Finish')
: t('common.next', 'Next')}
</button>
</div>
</div>
</div>
{/* Click handler to advance on target click */}
{targetRect && (
<div
className="absolute cursor-pointer"
style={{
top: targetRect.top,
left: targetRect.left,
width: targetRect.width,
height: targetRect.height,
}}
onClick={handleNext}
/>
)}
</div>,
document.body
)
}

View File

@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
interface TelegramLoginButtonProps {
botUsername: string
}
export default function TelegramLoginButton({
botUsername,
}: TelegramLoginButtonProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
// Load widget script
useEffect(() => {
if (!containerRef.current || !botUsername) return
// Clear previous widget
containerRef.current.innerHTML = ''
// Get current URL for redirect
const redirectUrl = `${window.location.origin}/auth/telegram/callback`
// Create script element for Telegram Login Widget
const script = document.createElement('script')
script.src = 'https://telegram.org/js/telegram-widget.js?22'
script.setAttribute('data-telegram-login', botUsername)
script.setAttribute('data-size', 'large')
script.setAttribute('data-radius', '8')
script.setAttribute('data-auth-url', redirectUrl)
script.setAttribute('data-request-access', 'write')
script.async = true
containerRef.current.appendChild(script)
}, [botUsername])
if (!botUsername || botUsername === 'your_bot') {
return (
<div className="text-center text-gray-500 text-sm py-4">
{t('auth.telegramNotConfigured')}
</div>
)
}
return (
<div className="flex flex-col items-center space-y-4">
{/* Telegram Widget will be inserted here */}
<div ref={containerRef} className="flex justify-center" />
{/* Fallback link for mobile */}
<div className="text-center">
<p className="text-xs text-gray-500 mb-2">{t('auth.orOpenInApp')}</p>
<a
href={`https://t.me/${botUsername}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-telegram-blue hover:underline"
>
<svg className="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
<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>
@{botUsername}
</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,242 @@
import { useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import { balanceApi } from '../api/balance'
import { useCurrency } from '../hooks/useCurrency'
import type { PaymentMethod } from '../types'
const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i
const CRYPTOBOT_INVOICE_REGEX = /^(?:https?:\/\/)?(?:app\.cr\.bot|cr\.bot)\/invoices\/([A-Za-z0-9_-]+)/i
const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url)
const buildCryptoBotDeepLink = (url: string): string | null => {
try {
const m = url.match(CRYPTOBOT_INVOICE_REGEX)
if (m && m[1]) return `tg://resolve?domain=CryptoBot&start=${m[1]}`
const parsed = new URL(url)
if (/^(?:www\.)?t\.me$/i.test(parsed.hostname) && /\/CryptoBot/i.test(parsed.pathname)) {
return `tg://resolve?domain=CryptoBot${parsed.search || ''}`
}
return null
} catch { return null }
}
const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
if (typeof window === 'undefined' || !url) return
const webApp = window.Telegram?.WebApp
// If inside Telegram Mini App, let Telegram handle t.me links
if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) {
try { webApp.openTelegramLink(url); return } catch {}
}
// Prefer Telegram deep link specifically for CryptoBot invoices, but only when
// the backend didn't already return a direct t.me link (those work fine).
const cb = buildCryptoBotDeepLink(url)
const target = cb && !isTelegramPaymentLink(url) ? cb : url
if (reservedWindow && !reservedWindow.closed) {
try { reservedWindow.location.href = target; reservedWindow.focus?.() } catch {}
return
}
const w2 = window.open(target, '_blank', 'noopener,noreferrer')
if (w2) { w2.opener = null; return }
window.location.href = target
}
interface TopUpModalProps { method: PaymentMethod; onClose: () => void }
export default function TopUpModal({ method, onClose }: TopUpModalProps) {
const { t } = useTranslation()
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency()
const [amount, setAmount] = useState('')
const [error, setError] = useState<string | null>(null)
const popupRef = useRef<Window | null>(null)
const minRubles = method.min_amount_kopeks / 100
const maxRubles = method.max_amount_kopeks / 100
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
const isStarsMethod = methodKey.includes('stars')
const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name
const isTelegramMiniApp = typeof window !== 'undefined' && Boolean(window.Telegram?.WebApp?.initData)
// Stars payment using the same approach as Wheel.tsx
const starsPaymentMutation = useMutation({
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
onSuccess: (data) => {
console.log('[Stars] API response:', data)
console.log('[Stars] invoice_url:', data.invoice_url)
const webApp = window.Telegram?.WebApp
console.log('[Stars] webApp:', webApp)
console.log('[Stars] openInvoice available:', !!webApp?.openInvoice)
if (!data.invoice_url) {
console.error('[Stars] No invoice_url in response!')
setError('Сервер не вернул ссылку на оплату')
return
}
if (!webApp?.openInvoice) {
console.error('[Stars] openInvoice not available - not in Telegram Mini App?')
setError('Оплата Stars доступна только в Telegram Mini App')
return
}
console.log('[Stars] Calling openInvoice with:', data.invoice_url)
try {
webApp.openInvoice(data.invoice_url, (status) => {
console.log('[Stars] Invoice callback status:', status)
if (status === 'paid') {
setError(null)
onClose()
} else if (status === 'failed') {
setError(t('wheel.starsPaymentFailed'))
} else if (status === 'cancelled') {
setError(null)
}
})
} catch (e) {
console.error('[Stars] openInvoice error:', e)
setError('Ошибка открытия окна оплаты: ' + String(e))
}
},
onError: (error: unknown) => {
console.error('[Stars] API error:', error)
const axiosError = error as { response?: { data?: { detail?: string }, status?: number } }
const detail = axiosError?.response?.data?.detail
const status = axiosError?.response?.status
setError(`Ошибка API (${status || 'network'}): ${detail || 'Не удалось создать счёт'}`)
},
})
const topUpMutation = useMutation<{
payment_id: string
payment_url?: string
invoice_url?: string
amount_kopeks: number
amount_rubles: number
status: string
expires_at: string | null
}, unknown, number>({
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id),
onSuccess: (data) => {
const redirectUrl = data.payment_url || (data as any).invoice_url
if (redirectUrl) {
openPaymentLink(redirectUrl, popupRef.current)
}
popupRef.current = null
onClose()
},
onError: (error: unknown) => {
try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch {}
popupRef.current = null
const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''
if (detail.includes('not yet implemented')) setError(t('balance.useBot'))
else setError(detail || t('common.error'))
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); setError(null)
const amountCurrency = parseFloat(amount)
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Invalid amount')); return }
const amountRubles = convertToRub(amountCurrency)
if (amountRubles < minRubles) { setError(t('balance.minAmountError', { amount: minRubles })); return }
if (amountRubles > maxRubles) { setError(t('balance.maxAmountError', { amount: maxRubles })); return }
const amountKopeks = Math.round(amountRubles * 100)
// Pre-open popup window to avoid browser blocking (must happen in user click context)
if (!isTelegramMiniApp) {
try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null }
}
if (isStarsMethod) {
starsPaymentMutation.mutate(amountKopeks); return
}
topUpMutation.mutate(amountKopeks)
}
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles)
const currencyDecimals = targetCurrency === 'IRR' || targetCurrency === 'RUB' ? 0 : 2
const getQuickAmountValue = (rubAmount: number): string => {
if (targetCurrency === 'IRR') return Math.round(convertAmount(rubAmount)).toString()
return convertAmount(rubAmount).toFixed(currencyDecimals)
}
const inputStep = currencyDecimals === 0 ? 1 : 0.01
const minInputValue = targetCurrency === 'RUB' ? minRubles : targetCurrency === 'IRR' ? Math.round(convertAmount(minRubles)) : Number(convertAmount(minRubles).toFixed(currencyDecimals))
const maxInputValue = targetCurrency === 'RUB' ? maxRubles : targetCurrency === 'IRR' ? Math.round(convertAmount(maxRubles)) : Number(convertAmount(maxRubles).toFixed(currencyDecimals))
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="card max-w-md w-full animate-slide-up">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-dark-100">{t('balance.topUp')} - {methodName}</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>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">{t('balance.amount')} ({currencySymbol})</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={`${formatAmount(minRubles, currencyDecimals)} - ${formatAmount(maxRubles, currencyDecimals)}`}
min={minInputValue}
max={maxInputValue}
step={inputStep}
className="input"
/>
<div className="text-xs text-dark-500 mt-2">
{t('balance.minAmount')}: {formatAmount(minRubles, currencyDecimals)} {currencySymbol} | {t('balance.maxAmount')}: {formatAmount(maxRubles, currencyDecimals)} {currencySymbol}
</div>
</div>
{quickAmounts.length > 0 && (
<div className="flex flex-wrap gap-2">
{quickAmounts.map((a) => {
const quickValue = getQuickAmountValue(a)
return (
<button
key={a}
type="button"
onClick={() => setAmount(quickValue)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
amount === quickValue ? 'bg-accent-500 text-white' : 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{formatAmount(a, currencyDecimals)} {currencySymbol}
</button>
)
})}
</div>
)}
{error && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-3 rounded-xl text-sm">{error}</div>
)}
<div className="flex gap-3 pt-2">
<button type="submit" disabled={topUpMutation.isPending || starsPaymentMutation.isPending || !amount} className="btn-primary flex-1">
{topUpMutation.isPending || starsPaymentMutation.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" />
{t('common.loading')}
</span>
) : (
t('balance.topUp')
)}
</button>
<button type="button" onClick={onClose} className="btn-secondary">{t('common.cancel')}</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { DotLottieReact } from '@lottiefiles/dotlottie-react'
interface PageLoaderProps {
variant?: 'dark' | 'light'
}
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50'
return (
<div className={`min-h-screen flex items-center justify-center ${bgClass}`}>
<div className="w-48 max-w-full">
<DotLottieReact
src="https://lottie.host/14b9dc34-cdaf-408c-87ea-291c1b01e343/r2rZZVuahg.lottie"
loop
autoplay
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,498 @@
import { Link, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useState, useEffect, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/auth'
import LanguageSwitcher from '../LanguageSwitcher'
import { contestsApi } from '../../api/contests'
import { pollsApi } from '../../api/polls'
import { brandingApi } from '../../api/branding'
import { wheelApi } from '../../api/wheel'
import { themeColorsApi } from '../../api/themeColors'
import { useTheme } from '../../hooks/useTheme'
// Fallback branding from environment variables
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'
interface LayoutProps {
children: React.ReactNode
}
// Icons as simple SVG components
const HomeIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
)
const SubscriptionIcon = () => (
<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.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</svg>
)
const WalletIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
</svg>
)
const UsersIcon = () => (
<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 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
const ChatIcon = () => (
<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.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" />
</svg>
)
const UserIcon = () => (
<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 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
)
const LogoutIcon = () => (
<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 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
)
// Theme toggle icons
const SunIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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 MoonIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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 MenuIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
)
const CloseIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const GamepadIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869" />
</svg>
)
const ClipboardIcon = () => (
<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 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
)
const InfoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
)
const CogIcon = () => (
<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.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
const WheelIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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>
)
export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation()
const location = useLocation()
const { user, logout, isAdmin, isAuthenticated } = useAuthStore()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const { toggleTheme, isDark } = useTheme()
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
// Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'],
queryFn: themeColorsApi.getEnabledThemes,
staleTime: 1000 * 60 * 5, // 5 minutes
})
// Only show theme toggle if both themes are enabled
const canToggle = enabledThemes?.dark && enabledThemes?.light
// Get user photo from Telegram WebApp
useEffect(() => {
try {
const tg = (window as any).Telegram?.WebApp
const photoUrl = tg?.initDataUnsafe?.user?.photo_url
if (photoUrl) {
setUserPhotoUrl(photoUrl)
}
} catch (e) {
console.warn('Failed to get Telegram user photo:', e)
}
}, [])
// Lock body scroll when mobile menu is open
useEffect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [mobileMenuOpen])
// Fetch branding settings
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
staleTime: 60000, // 1 minute
retry: 1,
})
// Computed branding values - use fallback only if branding not loaded yet
const appName = branding ? branding.name : FALLBACK_NAME // Empty string is valid (logo-only mode)
const logoLetter = branding?.logo_letter || FALLBACK_LOGO
const hasCustomLogo = branding?.has_custom_logo || false
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
// Set document title
useEffect(() => {
document.title = appName || 'VPN' // Fallback title if name is empty
}, [appName])
// Fetch contests and polls counts to determine if they should be shown
const { data: contestsCount } = useQuery({
queryKey: ['contests-count'],
queryFn: contestsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
})
const { data: pollsCount } = useQuery({
queryKey: ['polls-count'],
queryFn: pollsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
})
// Fetch wheel config to check if enabled
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
})
const navItems = useMemo(() => {
const items = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
{ path: '/referral', label: t('nav.referral'), icon: UsersIcon },
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
]
// Only show contests if there are available contests
if (contestsCount && contestsCount.count > 0) {
items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon })
}
// Only show polls if there are available polls
if (pollsCount && pollsCount.count > 0) {
items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon })
}
items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon })
return items
}, [t, contestsCount, pollsCount])
// Separate navItems for desktop that includes wheel (if enabled)
const desktopNavItems = useMemo(() => {
const items = [...navItems]
// Add wheel before info if enabled
if (wheelConfig?.is_enabled) {
const infoIndex = items.findIndex(item => item.path === '/info')
if (infoIndex !== -1) {
items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon })
} else {
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon })
}
}
return items
}, [navItems, wheelConfig, t])
const adminNavItems = [
{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon },
]
const isActive = (path: string) => location.pathname === path
const isAdminActive = () => location.pathname.startsWith('/admin')
return (
<div className="min-h-screen flex flex-col">
{/* Header */}
<header className="sticky top-0 z-50 glass border-b border-dark-800/50">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<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">
{hasCustomLogo && logoUrl ? (
<img src={logoUrl} alt={appName || 'Logo'} className="w-full h-full object-contain" />
) : (
<span className="text-white font-bold text-lg sm:text-xl lg:text-2xl">{logoLetter}</span>
)}
</div>
{appName && (
<span className="text-base lg:text-lg font-semibold text-dark-100 whitespace-nowrap">
{appName}
</span>
)}
</Link>
{/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-1">
{desktopNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
isActive(item.path)
? 'text-accent-400 bg-accent-500/10'
: 'text-dark-400 hover:text-dark-100 hover:bg-dark-800/50'
}`}
>
<item.icon />
{item.label}
</Link>
))}
{isAdmin && (
<>
<div className="w-px h-6 bg-dark-700 mx-2" />
{adminNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
isAdminActive()
? 'text-warning-400 bg-warning-500/10'
: 'text-warning-500/70 hover:text-warning-400 hover:bg-warning-500/10'
}`}
>
<item.icon />
{item.label}
</Link>
))}
</>
)}
</nav>
{/* Right side */}
<div className="flex items-center gap-2 sm:gap-3">
{/* 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"
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
>
<div className="relative w-5 h-5">
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'}`}>
<MoonIcon />
</div>
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-0 -rotate-90' : 'opacity-100 rotate-0'}`}>
<SunIcon />
</div>
</div>
</button>
)}
<LanguageSwitcher />
{/* Profile - Desktop */}
<div className="hidden sm:flex items-center gap-3">
<Link
to="/profile"
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-dark-800/50 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center">
<UserIcon />
</div>
<span className="text-sm text-dark-300">
{user?.first_name || user?.username || `#${user?.telegram_id}`}
</span>
</Link>
<button
onClick={logout}
className="btn-icon"
title={t('nav.logout')}
>
<LogoutIcon />
</button>
</div>
{/* Mobile menu button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="lg:hidden btn-icon"
>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
</button>
</div>
</div>
</div>
</header>
{/* Mobile menu - fixed overlay */}
{mobileMenuOpen && (
<div className="lg:hidden fixed inset-0 z-40 animate-fade-in" style={{ top: '64px' }}>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileMenuOpen(false)}
/>
{/* 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="max-w-6xl mx-auto px-4 py-4">
{/* User info */}
<div className="flex items-center gap-3 pb-4 mb-4 border-b border-dark-800/50">
{userPhotoUrl ? (
<img
src={userPhotoUrl}
alt="Avatar"
className="w-10 h-10 rounded-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.nextElementSibling?.classList.remove('hidden')
}}
/>
) : null}
<div className={`w-10 h-10 rounded-full bg-dark-700 flex items-center justify-center ${userPhotoUrl ? 'hidden' : ''}`}>
<UserIcon />
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user?.first_name || user?.username}
</div>
<div className="text-xs text-dark-500">
@{user?.username || `ID: ${user?.telegram_id}`}
</div>
</div>
</div>
{/* Nav items */}
<nav className="space-y-1">
{desktopNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={isActive(item.path) ? 'nav-item-active' : 'nav-item'}
>
<item.icon />
{item.label}
</Link>
))}
{isAdmin && (
<>
<div className="divider my-3" />
<div className="px-4 py-1 text-xs font-medium text-dark-500 uppercase tracking-wider">
{t('admin.nav.title')}
</div>
{adminNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileMenuOpen(false)}
className={`nav-item ${isAdminActive() ? 'text-warning-400 bg-warning-500/10' : 'text-warning-500/70'}`}
>
<item.icon />
{item.label}
</Link>
))}
</>
)}
<div className="divider my-3" />
<Link
to="/profile"
onClick={() => setMobileMenuOpen(false)}
className={isActive('/profile') ? 'nav-item-active' : 'nav-item'}
>
<UserIcon />
{t('nav.profile')}
</Link>
<button
onClick={() => {
setMobileMenuOpen(false)
logout()
}}
className="nav-item w-full text-error-400"
>
<LogoutIcon />
{t('nav.logout')}
</button>
</nav>
</div>
</div>
</div>
)}
{/* Main Content */}
<main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 pb-24 lg:pb-8">
<div className="animate-fade-in">
{children}
</div>
</main>
{/* Mobile Bottom Navigation - only core items */}
<nav className="bottom-nav lg:hidden">
<div className="flex justify-around">
{navItems.filter(item =>
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path)
).map((item) => (
<Link
key={item.path}
to={item.path}
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
>
<item.icon />
<span className="text-2xs mt-1 whitespace-nowrap">{item.label}</span>
</Link>
))}
</div>
</nav>
</div>
)
}

View File

@@ -0,0 +1,439 @@
import { useEffect, useRef, useState } from 'react'
import type { WheelPrize } from '../../api/wheel'
interface FortuneWheelProps {
prizes: WheelPrize[]
isSpinning: boolean
targetRotation: number | null
onSpinComplete: () => void
}
export default function FortuneWheel({
prizes,
isSpinning,
targetRotation,
onSpinComplete,
}: FortuneWheelProps) {
const wheelRef = useRef<SVGGElement>(null)
const [currentRotation, setCurrentRotation] = useState(0)
const [lightPattern, setLightPattern] = useState<boolean[]>([])
// Animated lights effect
useEffect(() => {
if (isSpinning) {
const interval = setInterval(() => {
setLightPattern(Array.from({ length: 20 }, () => Math.random() > 0.4))
}, 100)
return () => clearInterval(interval)
} else {
setLightPattern(Array.from({ length: 20 }, (_, i) => i % 2 === 0))
}
}, [isSpinning])
useEffect(() => {
if (isSpinning && targetRotation !== null && wheelRef.current) {
setCurrentRotation(targetRotation)
const timeout = setTimeout(() => {
onSpinComplete()
}, 5000)
return () => clearTimeout(timeout)
}
}, [isSpinning, targetRotation, onSpinComplete])
if (prizes.length === 0) {
return (
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center">
<p className="text-dark-400">No prizes configured</p>
</div>
)
}
const size = 400
const center = size / 2
const outerRadius = size / 2 - 20
const innerRadius = outerRadius - 15
const prizeRadius = innerRadius - 5
const sectorAngle = 360 / prizes.length
const hubRadius = 45
const createSectorPath = (index: number) => {
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180)
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180)
const x1 = center + prizeRadius * Math.cos(startAngle)
const y1 = center + prizeRadius * Math.sin(startAngle)
const x2 = center + prizeRadius * Math.cos(endAngle)
const y2 = center + prizeRadius * Math.sin(endAngle)
const x1Inner = center + hubRadius * Math.cos(startAngle)
const y1Inner = center + hubRadius * Math.sin(startAngle)
const x2Inner = center + hubRadius * Math.cos(endAngle)
const y2Inner = center + hubRadius * Math.sin(endAngle)
const largeArc = sectorAngle > 180 ? 1 : 0
return `M ${x1Inner} ${y1Inner}
L ${x1} ${y1}
A ${prizeRadius} ${prizeRadius} 0 ${largeArc} 1 ${x2} ${y2}
L ${x2Inner} ${y2Inner}
A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z`
}
// Position for emoji - closer to outer edge
const getEmojiPosition = (index: number) => {
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180)
const emojiRadius = prizeRadius * 0.75
return {
x: center + emojiRadius * Math.cos(angle),
y: center + emojiRadius * Math.sin(angle),
rotation: index * sectorAngle + sectorAngle / 2,
}
}
// Position for text - between hub and emoji
const getTextPosition = (index: number) => {
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180)
const textRadius = prizeRadius * 0.45
return {
x: center + textRadius * Math.cos(angle),
y: center + textRadius * Math.sin(angle),
rotation: index * sectorAngle + sectorAngle / 2,
}
}
// Alternate colors for sectors
const getSectorColors = (index: number, baseColor?: string) => {
if (baseColor) return baseColor
const colors = [
'#8B5CF6', '#EC4899', '#3B82F6', '#10B981',
'#F59E0B', '#EF4444', '#6366F1', '#14B8A6'
]
return colors[index % colors.length]
}
// Truncate text intelligently
const truncateText = (text: string, maxLen: number) => {
if (text.length <= maxLen) return text
return text.substring(0, maxLen - 1) + '..'
}
// Calculate max text length based on number of sectors
const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8
return (
<div className="relative w-full max-w-[380px] mx-auto select-none">
{/* Outer glow effect */}
<div
className={`absolute inset-[-30px] rounded-full transition-all duration-500 ${
isSpinning ? 'opacity-100 scale-105' : 'opacity-60'
}`}
style={{
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)',
filter: 'blur(25px)',
}}
/>
{/* Pointer */}
<div className="absolute top-[-12px] left-1/2 -translate-x-1/2 z-20">
<div className="relative">
<div
className={`absolute inset-[-10px] blur-lg transition-opacity ${isSpinning ? 'opacity-100' : 'opacity-70'}`}
style={{ background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)' }}
/>
<svg width="44" height="56" viewBox="0 0 44 56" className="relative drop-shadow-2xl">
<defs>
<linearGradient id="pointerGold" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FDE68A" />
<stop offset="30%" stopColor="#FBBF24" />
<stop offset="70%" stopColor="#F59E0B" />
<stop offset="100%" stopColor="#D97706" />
</linearGradient>
<filter id="pointerGlow">
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#F59E0B" floodOpacity="0.6"/>
</filter>
</defs>
<polygon
points="22,56 2,14 22,0 42,14"
fill="url(#pointerGold)"
filter="url(#pointerGlow)"
/>
<polygon
points="22,50 6,16 22,4"
fill="rgba(255,255,255,0.3)"
/>
<circle cx="22" cy="24" r="8" fill="#FEF3C7"/>
<circle cx="22" cy="24" r="5" fill="#FBBF24"/>
<circle cx="19" cy="21" r="2" fill="white" opacity="0.8"/>
</svg>
</div>
</div>
{/* Main Wheel */}
<div className="relative aspect-square">
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-full">
<defs>
{/* Sector gradients */}
{prizes.map((prize, index) => {
const color = getSectorColors(index, prize.color)
return (
<linearGradient
key={`grad-${index}`}
id={`sectorGrad-${index}`}
x1="0%" y1="0%" x2="100%" y2="100%"
>
<stop offset="0%" stopColor={color} stopOpacity="1" />
<stop offset="50%" stopColor={color} stopOpacity="0.85" />
<stop offset="100%" stopColor={color} stopOpacity="0.7" />
</linearGradient>
)
})}
{/* Outer ring gradient */}
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#C084FC" />
<stop offset="25%" stopColor="#A855F7" />
<stop offset="50%" stopColor="#7C3AED" />
<stop offset="75%" stopColor="#A855F7" />
<stop offset="100%" stopColor="#C084FC" />
</linearGradient>
{/* Hub gradient */}
<radialGradient id="hubGrad" cx="30%" cy="30%" r="70%">
<stop offset="0%" stopColor="#818CF8" />
<stop offset="50%" stopColor="#6366F1" />
<stop offset="100%" stopColor="#4338CA" />
</radialGradient>
{/* Text shadow filter */}
<filter id="textShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="#000" floodOpacity="0.7"/>
</filter>
</defs>
{/* Background shadow */}
<circle cx={center} cy={center + 6} r={outerRadius + 5} fill="rgba(0,0,0,0.3)" />
{/* Outer decorative ring */}
<circle
cx={center}
cy={center}
r={outerRadius}
fill="none"
stroke="url(#ringGrad)"
strokeWidth="15"
/>
{/* Inner ring border */}
<circle
cx={center}
cy={center}
r={innerRadius}
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="2"
/>
{/* LED lights on outer ring */}
{Array.from({ length: 20 }).map((_, i) => {
const angle = (i * 18 - 90) * (Math.PI / 180)
const dotX = center + outerRadius * Math.cos(angle)
const dotY = center + outerRadius * Math.sin(angle)
const isLit = lightPattern[i] ?? (i % 2 === 0)
return (
<g key={`led-${i}`}>
{isLit && (
<circle
cx={dotX}
cy={dotY}
r={6}
fill="#FEF08A"
opacity={0.5}
style={{ filter: 'blur(3px)' }}
/>
)}
<circle
cx={dotX}
cy={dotY}
r={4}
fill={isLit ? '#FEF08A' : '#374151'}
stroke={isLit ? '#FDE047' : '#1F2937'}
strokeWidth="1"
/>
</g>
)
})}
{/* Rotating wheel group */}
<g
ref={wheelRef}
style={{
transformOrigin: `${center}px ${center}px`,
transform: `rotate(${currentRotation}deg)`,
transition: isSpinning
? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)'
: 'none',
}}
>
{/* Sectors */}
{prizes.map((prize, index) => (
<path
key={`sector-${prize.id}`}
d={createSectorPath(index)}
fill={`url(#sectorGrad-${index})`}
stroke="rgba(255,255,255,0.15)"
strokeWidth="2"
/>
))}
{/* Sector dividers */}
{prizes.map((_, index) => {
const angle = (index * sectorAngle - 90) * (Math.PI / 180)
const x1 = center + hubRadius * Math.cos(angle)
const y1 = center + hubRadius * Math.sin(angle)
const x2 = center + prizeRadius * Math.cos(angle)
const y2 = center + prizeRadius * Math.sin(angle)
return (
<line
key={`divider-${index}`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="rgba(255,255,255,0.25)"
strokeWidth="2"
/>
)
})}
{/* Prize content - Emoji */}
{prizes.map((prize, index) => {
const pos = getEmojiPosition(index)
return (
<text
key={`emoji-${prize.id}`}
x={pos.x}
y={pos.y}
textAnchor="middle"
dominantBaseline="middle"
fontSize={prizes.length <= 6 ? "32" : "26"}
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }}
>
{prize.emoji}
</text>
)
})}
{/* Prize content - Text */}
{prizes.map((prize, index) => {
const pos = getTextPosition(index)
const displayText = truncateText(prize.display_name, maxTextLength)
return (
<text
key={`text-${prize.id}`}
x={pos.x}
y={pos.y}
textAnchor="middle"
dominantBaseline="middle"
fontSize={prizes.length <= 4 ? "13" : prizes.length <= 6 ? "11" : "9"}
fontWeight="700"
fill="#FFFFFF"
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
filter="url(#textShadow)"
style={{ letterSpacing: '0.03em' }}
>
{displayText}
</text>
)
})}
</g>
{/* Center hub */}
<circle
cx={center}
cy={center}
r={hubRadius}
fill="url(#hubGrad)"
stroke="#A5B4FC"
strokeWidth="3"
/>
{/* Hub inner decoration */}
<circle
cx={center}
cy={center}
r={hubRadius - 8}
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="1"
/>
{/* Hub shine */}
<ellipse
cx={center - 10}
cy={center - 12}
rx={15}
ry={10}
fill="rgba(255,255,255,0.2)"
/>
{/* Center button */}
<circle
cx={center}
cy={center}
r={hubRadius - 12}
fill="#312E81"
stroke="#6366F1"
strokeWidth="2"
/>
{/* Center text */}
<text
x={center}
y={center + 1}
textAnchor="middle"
dominantBaseline="middle"
fontSize="11"
fontWeight="bold"
fill="#C7D2FE"
letterSpacing="0.15em"
>
{isSpinning ? '...' : 'SPIN'}
</text>
</svg>
{/* Spinning overlay glow */}
{isSpinning && (
<div
className="absolute inset-0 rounded-full pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(168, 85, 247, 0.25) 0%, transparent 50%)',
animation: 'pulse 0.5s ease-in-out infinite',
}}
/>
)}
</div>
{/* Sparkle effects when spinning */}
{isSpinning && (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={`sparkle-${i}`}
className="absolute w-2 h-2 bg-yellow-300 rounded-full"
style={{
top: `${15 + Math.random() * 70}%`,
left: `${15 + Math.random() * 70}%`,
animation: `ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite`,
animationDelay: `${i * 0.12}s`,
opacity: 0.8,
}}
/>
))}
</div>
)}
</div>
)
}