mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Add files via upload
This commit is contained in:
154
src/components/ColorPicker.tsx
Normal file
154
src/components/ColorPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
562
src/components/ConnectionModal.tsx
Normal file
562
src/components/ConnectionModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
src/components/LanguageSwitcher.tsx
Normal file
79
src/components/LanguageSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
240
src/components/Onboarding.tsx
Normal file
240
src/components/Onboarding.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
67
src/components/TelegramLoginButton.tsx
Normal file
67
src/components/TelegramLoginButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
src/components/TopUpModal.tsx
Normal file
242
src/components/TopUpModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
21
src/components/common/PageLoader.tsx
Normal file
21
src/components/common/PageLoader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
498
src/components/layout/Layout.tsx
Normal file
498
src/components/layout/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
439
src/components/wheel/FortuneWheel.tsx
Normal file
439
src/components/wheel/FortuneWheel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user