mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -7,7 +7,6 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
# Use npm install if no lock file, npm ci if lock file exists
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
# Copy source code
|
||||
|
||||
@@ -153,6 +153,15 @@ export interface NodeStatus {
|
||||
users_online: number
|
||||
traffic_used_bytes?: number
|
||||
uptime?: string
|
||||
xray_version?: string
|
||||
node_version?: string
|
||||
last_status_message?: string
|
||||
xray_uptime?: string
|
||||
is_xray_running?: boolean
|
||||
cpu_count?: number
|
||||
cpu_model?: string
|
||||
total_ram?: string
|
||||
country_code?: string
|
||||
}
|
||||
|
||||
export interface NodesOverview {
|
||||
@@ -225,6 +234,75 @@ export interface DashboardStats {
|
||||
tariff_stats?: TariffStats
|
||||
}
|
||||
|
||||
// ============ Extended Stats Types ============
|
||||
|
||||
export interface TopReferrerItem {
|
||||
user_id: number
|
||||
telegram_id: number
|
||||
username?: string
|
||||
display_name: string
|
||||
invited_count: number
|
||||
invited_today: number
|
||||
invited_week: number
|
||||
invited_month: number
|
||||
earnings_today_kopeks: number
|
||||
earnings_week_kopeks: number
|
||||
earnings_month_kopeks: number
|
||||
earnings_total_kopeks: number
|
||||
}
|
||||
|
||||
export interface TopReferrersResponse {
|
||||
by_earnings: TopReferrerItem[]
|
||||
by_invited: TopReferrerItem[]
|
||||
total_referrers: number
|
||||
total_referrals: number
|
||||
total_earnings_kopeks: number
|
||||
}
|
||||
|
||||
export interface TopCampaignItem {
|
||||
id: number
|
||||
name: string
|
||||
start_parameter: string
|
||||
bonus_type: string
|
||||
is_active: boolean
|
||||
registrations: number
|
||||
conversions: number
|
||||
conversion_rate: number
|
||||
total_revenue_kopeks: number
|
||||
avg_revenue_per_user_kopeks: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface TopCampaignsResponse {
|
||||
campaigns: TopCampaignItem[]
|
||||
total_campaigns: number
|
||||
total_registrations: number
|
||||
total_revenue_kopeks: number
|
||||
}
|
||||
|
||||
export interface RecentPaymentItem {
|
||||
id: number
|
||||
user_id: number
|
||||
telegram_id: number
|
||||
username?: string
|
||||
display_name: string
|
||||
amount_kopeks: number
|
||||
amount_rubles: number
|
||||
type: string
|
||||
type_display: string
|
||||
payment_method?: string
|
||||
description?: string
|
||||
created_at: string
|
||||
is_completed: boolean
|
||||
}
|
||||
|
||||
export interface RecentPaymentsResponse {
|
||||
payments: RecentPaymentItem[]
|
||||
total_count: number
|
||||
total_today_kopeks: number
|
||||
total_week_kopeks: number
|
||||
}
|
||||
|
||||
// ============ Dashboard Stats API ============
|
||||
|
||||
export const statsApi = {
|
||||
@@ -251,4 +329,22 @@ export const statsApi = {
|
||||
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/toggle`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get top referrers
|
||||
getTopReferrers: async (limit: number = 20): Promise<TopReferrersResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/referrals/top', { params: { limit } })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get top campaigns
|
||||
getTopCampaigns: async (limit: number = 20): Promise<TopCampaignsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', { params: { limit } })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get recent payments
|
||||
getRecentPayments: async (limit: number = 50): Promise<RecentPaymentsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/payments/recent', { params: { limit } })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LocalizedText {
|
||||
}
|
||||
|
||||
export interface AppButton {
|
||||
id?: string // Unique identifier for React key (client-side only)
|
||||
buttonLink: string
|
||||
buttonText: LocalizedText
|
||||
}
|
||||
|
||||
@@ -7,6 +7,70 @@ export interface BrandingInfo {
|
||||
has_custom_logo: boolean
|
||||
}
|
||||
|
||||
export interface AnimationEnabled {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const BRANDING_CACHE_KEY = 'cabinet_branding'
|
||||
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
|
||||
|
||||
// Get cached branding from localStorage
|
||||
export const getCachedBranding = (): BrandingInfo | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(BRANDING_CACHE_KEY)
|
||||
if (cached) {
|
||||
return JSON.parse(cached)
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available or invalid JSON
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Update branding cache in localStorage
|
||||
export const setCachedBranding = (branding: BrandingInfo) => {
|
||||
try {
|
||||
localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding))
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
// Preload logo image for instant display
|
||||
export const preloadLogo = (branding: BrandingInfo): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!branding.has_custom_logo || !branding.logo_url) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`
|
||||
|
||||
// Check if already preloaded in this session
|
||||
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY)
|
||||
if (preloaded === logoUrl) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl)
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => resolve()
|
||||
img.src = logoUrl
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize logo preload from cache on page load
|
||||
export const initLogoPreload = () => {
|
||||
const cached = getCachedBranding()
|
||||
if (cached) {
|
||||
preloadLogo(cached)
|
||||
}
|
||||
}
|
||||
|
||||
export const brandingApi = {
|
||||
// Get current branding (public, no auth required)
|
||||
getBranding: async (): Promise<BrandingInfo> => {
|
||||
@@ -45,4 +109,16 @@ export const brandingApi = {
|
||||
}
|
||||
return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`
|
||||
},
|
||||
|
||||
// Get animation enabled (public, no auth required)
|
||||
getAnimationEnabled: async (): Promise<AnimationEnabled> => {
|
||||
const response = await apiClient.get<AnimationEnabled>('/cabinet/branding/animation')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update animation enabled (admin only)
|
||||
updateAnimationEnabled: async (enabled: boolean): Promise<AnimationEnabled> => {
|
||||
const response = await apiClient.patch<AnimationEnabled>('/cabinet/branding/animation', { enabled })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import apiClient from './client'
|
||||
|
||||
// ============== Types ==============
|
||||
|
||||
export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group'
|
||||
export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group' | 'discount'
|
||||
|
||||
export interface PromoCode {
|
||||
id: number
|
||||
|
||||
@@ -98,6 +98,22 @@ export interface WheelPrizeAdmin {
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
// Type for creating a new prize (excludes id, config_id which are auto-generated)
|
||||
export interface CreateWheelPrizeData {
|
||||
prize_type: string
|
||||
prize_value: number
|
||||
display_name: string
|
||||
emoji?: string
|
||||
color?: string
|
||||
prize_value_kopeks: number
|
||||
sort_order?: number
|
||||
manual_probability?: number | null
|
||||
is_active?: boolean
|
||||
promo_balance_bonus_kopeks?: number
|
||||
promo_subscription_days?: number
|
||||
promo_traffic_gb?: number
|
||||
}
|
||||
|
||||
export interface AdminWheelConfig {
|
||||
id: number
|
||||
is_enabled: boolean
|
||||
@@ -223,20 +239,7 @@ export const adminWheelApi = {
|
||||
},
|
||||
|
||||
// Create prize
|
||||
createPrize: async (data: {
|
||||
prize_type: string
|
||||
prize_value: number
|
||||
display_name: string
|
||||
emoji?: string
|
||||
color?: string
|
||||
prize_value_kopeks: number
|
||||
sort_order?: number
|
||||
manual_probability?: number | null
|
||||
is_active?: boolean
|
||||
promo_balance_bonus_kopeks?: number
|
||||
promo_subscription_days?: number
|
||||
promo_traffic_gb?: number
|
||||
}): Promise<WheelPrizeAdmin> => {
|
||||
createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => {
|
||||
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
86
src/components/AnimatedBackground.tsx
Normal file
86
src/components/AnimatedBackground.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState, memo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { brandingApi } from '../api/branding'
|
||||
|
||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled'
|
||||
|
||||
// Detect low-performance device (mobile in Telegram WebApp)
|
||||
const isLowPerformance = (): boolean => {
|
||||
// Check if running in Telegram WebApp
|
||||
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
|
||||
// Check if mobile
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
||||
// Check for reduced motion preference
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
return prefersReducedMotion || (isTelegramWebApp && isMobile)
|
||||
}
|
||||
|
||||
// Get cached value from localStorage
|
||||
const getCachedAnimationEnabled = (): boolean | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY)
|
||||
if (cached !== null) {
|
||||
return cached === 'true'
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Update cache in localStorage
|
||||
export const setCachedAnimationEnabled = (enabled: boolean) => {
|
||||
try {
|
||||
localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled))
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
// Memoized background component to prevent re-renders
|
||||
const AnimatedBackground = memo(function AnimatedBackground() {
|
||||
// Start with cached value (null means unknown yet)
|
||||
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled())
|
||||
const [isLowPerf] = useState(() => isLowPerformance())
|
||||
|
||||
const { data: animationSettings } = useQuery({
|
||||
queryKey: ['animation-enabled'],
|
||||
queryFn: brandingApi.getAnimationEnabled,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
|
||||
refetchOnWindowFocus: false, // Don't refetch on focus - save resources
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// Update state and cache when data arrives
|
||||
useEffect(() => {
|
||||
if (animationSettings !== undefined) {
|
||||
const enabled = animationSettings.enabled
|
||||
setIsEnabled(enabled)
|
||||
setCachedAnimationEnabled(enabled)
|
||||
}
|
||||
}, [animationSettings])
|
||||
|
||||
// Don't render if disabled or on low-performance devices
|
||||
if (isEnabled !== true || isLowPerf) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Render only 2 blobs on mobile for better performance
|
||||
const isMobile = window.innerWidth < 768
|
||||
|
||||
return (
|
||||
<div className="wave-bg-container">
|
||||
<div className="wave-blob wave-blob-1" />
|
||||
<div className="wave-blob wave-blob-2" />
|
||||
{!isMobile && (
|
||||
<>
|
||||
<div className="wave-blob wave-blob-3" />
|
||||
<div className="wave-blob wave-blob-4" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default AnimatedBackground
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: string
|
||||
@@ -8,6 +8,28 @@ interface ColorPickerProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Check if running in Telegram WebApp (native color picker causes crash)
|
||||
const isTelegramWebApp = (): boolean => {
|
||||
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
|
||||
}
|
||||
|
||||
// Convert hex to RGB
|
||||
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
|
||||
// Convert RGB to hex
|
||||
const rgbToHex = (r: number, g: number, b: number): string => {
|
||||
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#3b82f6', // Blue
|
||||
'#ef4444', // Red
|
||||
@@ -21,18 +43,36 @@ const PRESET_COLORS = [
|
||||
'#f97316', // Orange
|
||||
'#6366f1', // Indigo
|
||||
'#a855f7', // Purple
|
||||
'#ffffff', // White
|
||||
'#64748b', // Slate
|
||||
'#1e293b', // Dark
|
||||
'#000000', // Black
|
||||
]
|
||||
|
||||
export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const [rgb, setRgb] = useState(() => hexToRgb(value))
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Memoize Telegram check to avoid recalculating
|
||||
const isTelegram = useMemo(() => isTelegramWebApp(), [])
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
setRgb(hexToRgb(value))
|
||||
}, [value])
|
||||
|
||||
// Handle RGB slider change
|
||||
const handleRgbChange = useCallback((channel: 'r' | 'g' | 'b', val: number) => {
|
||||
const newRgb = { ...rgb, [channel]: val }
|
||||
setRgb(newRgb)
|
||||
const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b)
|
||||
setLocalValue(hex)
|
||||
onChange(hex)
|
||||
}, [rgb, onChange])
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -81,14 +121,15 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
{description && <p className="text-xs text-dark-500 mb-2">{description}</p>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Color preview button */}
|
||||
{/* Color preview button - min 44px for touch accessibility */}
|
||||
<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"
|
||||
className="w-11 h-11 rounded-lg border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
|
||||
style={{ backgroundColor: localValue || '#000000' }}
|
||||
title={localValue}
|
||||
aria-label={`Select color: ${localValue}`}
|
||||
/>
|
||||
|
||||
{/* Hex input */}
|
||||
@@ -102,48 +143,111 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
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"
|
||||
{/* Native color picker - hidden in Telegram WebApp (causes crash) */}
|
||||
{!isTelegram && (
|
||||
<>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
value={localValue || '#000000'}
|
||||
onChange={handleColorInputChange}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Native picker button - min 44px for touch accessibility */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="btn-secondary min-w-[44px] min-h-[44px] p-2.5 disabled:opacity-50"
|
||||
title="Open color picker"
|
||||
aria-label="Open color picker"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<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 */}
|
||||
{/* Dropdown with presets and RGB sliders */}
|
||||
{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="absolute z-50 mt-2 p-3 bg-dark-800 rounded-xl border border-dark-700 shadow-xl animate-fade-in w-72">
|
||||
{/* RGB Sliders - shown in Telegram instead of native picker */}
|
||||
{isTelegram && (
|
||||
<div className="mb-3 pb-3 border-b border-dark-700">
|
||||
<div className="text-xs text-dark-400 mb-2">RGB</div>
|
||||
<div className="space-y-2">
|
||||
{/* Red */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-red-400 w-4">R</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
value={rgb.r}
|
||||
onChange={(e) => handleRgbChange('r', parseInt(e.target.value))}
|
||||
className="flex-1 h-2 rounded-full appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, rgb(0,${rgb.g},${rgb.b}), rgb(255,${rgb.g},${rgb.b}))`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-dark-400 w-8 text-right">{rgb.r}</span>
|
||||
</div>
|
||||
{/* Green */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-green-400 w-4">G</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
value={rgb.g}
|
||||
onChange={(e) => handleRgbChange('g', parseInt(e.target.value))}
|
||||
className="flex-1 h-2 rounded-full appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, rgb(${rgb.r},0,${rgb.b}), rgb(${rgb.r},255,${rgb.b}))`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-dark-400 w-8 text-right">{rgb.g}</span>
|
||||
</div>
|
||||
{/* Blue */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-blue-400 w-4">B</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
value={rgb.b}
|
||||
onChange={(e) => handleRgbChange('b', parseInt(e.target.value))}
|
||||
className="flex-1 h-2 rounded-full appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, rgb(${rgb.r},${rgb.g},0), rgb(${rgb.r},${rgb.g},255))`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-dark-400 w-8 text-right">{rgb.b}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-dark-400 mb-2">Preset colors</div>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{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'
|
||||
className={`min-w-[44px] min-h-[44px] w-full aspect-square rounded-lg border-2 transition-all hover:scale-105 ${
|
||||
localValue.toLowerCase() === preset.toLowerCase() ? 'border-white ring-2 ring-white/30' : 'border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
style={{ backgroundColor: preset }}
|
||||
title={preset}
|
||||
aria-label={`Select color ${preset}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -61,12 +61,8 @@ const platformIconComponents: Record<string, React.FC> = {
|
||||
// Platform order for display
|
||||
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
|
||||
|
||||
// Allowed app schemes for deep links
|
||||
const allowedAppSchemes = [
|
||||
'happ://', 'flclash://', 'clash://', 'sing-box://', 'v2rayng://',
|
||||
'sub://', 'shadowrocket://', 'hiddify://', 'streisand://',
|
||||
'quantumult://', 'surge://', 'loon://', 'nekobox://', 'v2box://'
|
||||
]
|
||||
// Dangerous schemes that should never be allowed
|
||||
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
|
||||
|
||||
/**
|
||||
* Validate URL to prevent XSS via javascript: and other dangerous schemes
|
||||
@@ -87,20 +83,20 @@ function isValidExternalUrl(url: string | undefined): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate deep link URL - only allows known VPN app schemes
|
||||
* Validate deep link URL - blocks dangerous schemes only
|
||||
* Allows any custom app scheme (clash://, hiddify://, etc.)
|
||||
*/
|
||||
function isValidDeepLink(url: string | undefined): boolean {
|
||||
if (!url) return false
|
||||
const lowerUrl = url.toLowerCase().trim()
|
||||
|
||||
// Block dangerous schemes
|
||||
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
|
||||
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow known app schemes
|
||||
return allowedAppSchemes.some(scheme => lowerUrl.startsWith(scheme))
|
||||
// Allow any URL that has a scheme (contains ://)
|
||||
return lowerUrl.includes('://')
|
||||
}
|
||||
|
||||
// Detect user's platform from user agent
|
||||
@@ -227,8 +223,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
||||
|
||||
if (isCustomScheme && tg?.openLink) {
|
||||
// For custom URL schemes - open redirect page in external browser via Telegram
|
||||
// try_browser: true - показывает диалог для перехода во внешний браузер (важно для мобильных)
|
||||
try {
|
||||
tg.openLink(redirectUrl, { try_instant_view: false })
|
||||
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true })
|
||||
return
|
||||
} catch (e) {
|
||||
console.warn('tg.openLink failed:', e)
|
||||
|
||||
@@ -152,7 +152,7 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
|
||||
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400">
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
|
||||
@@ -179,9 +179,9 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
||||
>
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-1.5 mb-4">
|
||||
{steps.map((_, index) => (
|
||||
{steps.map((s, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={s.target}
|
||||
className={`h-1 rounded-full transition-all duration-300 ${
|
||||
index === currentStep
|
||||
? 'w-6 bg-accent-500'
|
||||
|
||||
@@ -88,19 +88,12 @@ export default function PromoDiscountBadge() {
|
||||
{/* Badge button */}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="relative flex items-center gap-1.5 px-3 py-2 rounded-xl bg-gradient-to-r from-success-500/20 to-accent-500/20 border border-success-500/30 hover:border-success-500/50 transition-all group"
|
||||
className="relative flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-success-500/15 hover:bg-success-500/25 transition-all group"
|
||||
title={t('promo.activeDiscount', 'Active discount')}
|
||||
>
|
||||
{/* Animated sparkle effect */}
|
||||
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-success-500/10 to-accent-500/10 animate-pulse" />
|
||||
|
||||
<SparklesIcon />
|
||||
<span className="relative font-bold text-success-400 text-sm">
|
||||
<span className="font-bold text-success-400 text-sm">
|
||||
-{activeDiscount.discount_percent}%
|
||||
</span>
|
||||
|
||||
{/* Notification dot */}
|
||||
<span className="absolute -top-1 -right-1 w-2 h-2 bg-success-400 rounded-full animate-pulse" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown - mobile: fixed centered, desktop: absolute right */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ticketNotificationsApi } from '../api/ticketNotifications'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { useToast } from './Toast'
|
||||
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
|
||||
import type { TicketNotification } from '../types'
|
||||
|
||||
const BellIcon = () => (
|
||||
@@ -32,6 +33,12 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
const { showToast } = useToast()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
|
||||
|
||||
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
|
||||
const dropdownTop = isFullscreen
|
||||
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header
|
||||
: 64 // default header height
|
||||
|
||||
// Show toast for WebSocket notification
|
||||
const showWSNotificationToast = useCallback((message: WSMessage) => {
|
||||
@@ -204,7 +211,12 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="fixed sm:absolute top-16 sm:top-auto right-4 sm:right-0 left-4 sm:left-auto mt-0 sm:mt-2 w-auto sm:w-96 bg-dark-900/95 backdrop-blur-xl border border-dark-700/50 rounded-2xl shadow-2xl shadow-black/30 overflow-hidden z-50 animate-scale-in">
|
||||
<div
|
||||
className={`fixed sm:absolute sm:top-auto right-4 sm:right-0 left-4 sm:left-auto mt-0 sm:mt-2 w-auto sm:w-96 bg-dark-900/95 backdrop-blur-xl border border-dark-700/50 rounded-2xl shadow-2xl shadow-black/30 overflow-hidden z-50 animate-scale-in ${
|
||||
!isFullscreen ? 'top-16' : ''
|
||||
}`}
|
||||
style={isFullscreen ? { top: `${dropdownTop}px` } : undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700/50 bg-dark-800/30">
|
||||
<h3 className="text-sm font-semibold text-dark-100">
|
||||
|
||||
@@ -17,7 +17,8 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
|
||||
try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) }
|
||||
}
|
||||
if (webApp?.openLink) {
|
||||
try { webApp.openLink(url, { try_instant_view: false }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) }
|
||||
// try_browser: true - открывает диалог для перехода во внешний браузер (важно для мобильных)
|
||||
try { webApp.openLink(url, { try_instant_view: false, try_browser: true }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) }
|
||||
}
|
||||
if (reservedWindow && !reservedWindow.closed) {
|
||||
try { reservedWindow.location.href = url; reservedWindow.focus?.() } catch (e) { console.warn('[TopUpModal] Failed to use reserved window:', e) }
|
||||
|
||||
@@ -6,12 +6,15 @@ import { useAuthStore } from '../../store/auth'
|
||||
import LanguageSwitcher from '../LanguageSwitcher'
|
||||
import PromoDiscountBadge from '../PromoDiscountBadge'
|
||||
import TicketNotificationBell from '../TicketNotificationBell'
|
||||
import AnimatedBackground from '../AnimatedBackground'
|
||||
import { contestsApi } from '../../api/contests'
|
||||
import { pollsApi } from '../../api/polls'
|
||||
import { brandingApi } from '../../api/branding'
|
||||
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo } from '../../api/branding'
|
||||
import { wheelApi } from '../../api/wheel'
|
||||
import { themeColorsApi } from '../../api/themeColors'
|
||||
import { promoApi } from '../../api/promo'
|
||||
import { useTheme } from '../../hooks/useTheme'
|
||||
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
|
||||
|
||||
// Fallback branding from environment variables
|
||||
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
|
||||
@@ -120,6 +123,18 @@ const WheelIcon = () => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FullscreenIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ExitFullscreenIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
@@ -127,6 +142,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const { toggleTheme, isDark } = useTheme()
|
||||
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
|
||||
const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
|
||||
|
||||
// Fetch enabled themes from API - same source of truth as AdminSettings
|
||||
const { data: enabledThemes } = useQuery({
|
||||
@@ -165,15 +181,26 @@ export default function Layout({ children }: LayoutProps) {
|
||||
}
|
||||
}, [mobileMenuOpen])
|
||||
|
||||
// Fetch branding settings
|
||||
// State to track if logo image has loaded
|
||||
const [logoLoaded, setLogoLoaded] = useState(false)
|
||||
|
||||
// Fetch branding settings with localStorage cache for instant load
|
||||
const { data: branding } = useQuery({
|
||||
queryKey: ['branding'],
|
||||
queryFn: brandingApi.getBranding,
|
||||
queryFn: async () => {
|
||||
const data = await brandingApi.getBranding()
|
||||
setCachedBranding(data) // Update cache
|
||||
// Preload logo in background
|
||||
preloadLogo(data)
|
||||
return data
|
||||
},
|
||||
initialData: getCachedBranding() ?? undefined, // Use cached data immediately
|
||||
staleTime: 60000, // 1 minute
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
// Computed branding values - use fallback only if branding not loaded yet
|
||||
// Computed branding values - use fallback only if no branding and no cache
|
||||
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
|
||||
@@ -210,6 +237,17 @@ export default function Layout({ children }: LayoutProps) {
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// Fetch active discount to determine mobile layout
|
||||
const { data: activeDiscount } = useQuery({
|
||||
queryKey: ['active-discount'],
|
||||
queryFn: promoApi.getActiveDiscount,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 30000,
|
||||
})
|
||||
|
||||
// Check if promo is active (to hide language switcher on mobile)
|
||||
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
const items = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||
@@ -258,17 +296,34 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Animated Background */}
|
||||
<AnimatedBackground />
|
||||
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 glass border-b border-dark-800/50">
|
||||
<header
|
||||
className="sticky top-0 z-50 glass border-b border-dark-800/50"
|
||||
style={{
|
||||
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
|
||||
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="w-full mx-auto px-4 sm:px-6">
|
||||
<div className="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 className="w-10 h-10 sm:w-12 sm:h-12 lg:w-14 lg:h-14 rounded-xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center overflow-hidden shadow-lg shadow-accent-500/20 flex-shrink-0 relative">
|
||||
{/* Always show letter as fallback */}
|
||||
<span className={`text-white font-bold text-lg sm:text-xl lg:text-2xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
|
||||
{logoLetter}
|
||||
</span>
|
||||
{/* Logo image with smooth fade-in */}
|
||||
{hasCustomLogo && logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={appName || 'Logo'}
|
||||
className={`w-full h-full object-contain absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
onLoad={() => setLogoLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{appName && (
|
||||
@@ -317,6 +372,20 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Fullscreen toggle - only show in Telegram WebApp */}
|
||||
{isTelegramWebApp && isFullscreenSupported && (
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95
|
||||
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800
|
||||
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Theme toggle button - only show if both themes are enabled */}
|
||||
{canToggle && (
|
||||
<button
|
||||
@@ -325,6 +394,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
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'}
|
||||
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to 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'}`}>
|
||||
@@ -339,7 +409,10 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
<PromoDiscountBadge />
|
||||
<TicketNotificationBell isAdmin={isAdminActive()} />
|
||||
<LanguageSwitcher />
|
||||
{/* Hide language switcher on mobile when promo is active */}
|
||||
<div className={isPromoActive ? 'hidden sm:block' : ''}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
{/* Profile - Desktop */}
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
@@ -358,6 +431,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
onClick={logout}
|
||||
className="btn-icon"
|
||||
title={t('nav.logout')}
|
||||
aria-label={t('nav.logout') || 'Logout'}
|
||||
>
|
||||
<LogoutIcon />
|
||||
</button>
|
||||
@@ -367,6 +441,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="lg:hidden btn-icon"
|
||||
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
</button>
|
||||
@@ -389,29 +465,33 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<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 className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
{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 className="text-xs text-dark-500">
|
||||
@{user?.username || `ID: ${user?.telegram_id}`}
|
||||
<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>
|
||||
{/* Language switcher in mobile menu when promo is active */}
|
||||
{isPromoActive && <LanguageSwitcher />}
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
@@ -480,6 +560,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<div className="animate-fade-in">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Navigation - only core items */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useMemo, memo } from 'react'
|
||||
import type { WheelPrize } from '../../api/wheel'
|
||||
|
||||
interface FortuneWheelProps {
|
||||
@@ -8,7 +8,14 @@ interface FortuneWheelProps {
|
||||
onSpinComplete: () => void
|
||||
}
|
||||
|
||||
export default function FortuneWheel({
|
||||
// Pre-generate sparkle positions to avoid recalculating on each render
|
||||
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({
|
||||
top: `${20 + (i * 10) % 60}%`,
|
||||
left: `${15 + (i * 13) % 70}%`,
|
||||
delay: `${i * 0.15}s`,
|
||||
}))
|
||||
|
||||
const FortuneWheel = memo(function FortuneWheel({
|
||||
prizes,
|
||||
isSpinning,
|
||||
targetRotation,
|
||||
@@ -16,17 +23,17 @@ export default function FortuneWheel({
|
||||
}: FortuneWheelProps) {
|
||||
const wheelRef = useRef<SVGGElement>(null)
|
||||
const [currentRotation, setCurrentRotation] = useState(0)
|
||||
const [lightPattern, setLightPattern] = useState<boolean[]>([])
|
||||
const [lightPhase, setLightPhase] = useState(0)
|
||||
|
||||
// Animated lights effect
|
||||
// Animated lights effect - use phase instead of random array (less re-renders)
|
||||
useEffect(() => {
|
||||
if (isSpinning) {
|
||||
const interval = setInterval(() => {
|
||||
setLightPattern(Array.from({ length: 20 }, () => Math.random() > 0.4))
|
||||
}, 100)
|
||||
setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2
|
||||
}, 250) // Slower interval = better performance
|
||||
return () => clearInterval(interval)
|
||||
} else {
|
||||
setLightPattern(Array.from({ length: 20 }, (_, i) => i % 2 === 0))
|
||||
setLightPhase(0)
|
||||
}
|
||||
}, [isSpinning])
|
||||
|
||||
@@ -42,6 +49,14 @@ export default function FortuneWheel({
|
||||
}
|
||||
}, [isSpinning, targetRotation, onSpinComplete])
|
||||
|
||||
// Memoize light pattern calculation
|
||||
const lightPattern = useMemo(() => {
|
||||
return Array.from({ length: 20 }, (_, i) => {
|
||||
if (!isSpinning) return i % 2 === 0
|
||||
return (i + lightPhase) % 3 !== 0
|
||||
})
|
||||
}, [isSpinning, lightPhase])
|
||||
|
||||
if (prizes.length === 0) {
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center">
|
||||
@@ -416,19 +431,19 @@ export default function FortuneWheel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sparkle effects when spinning */}
|
||||
{/* Sparkle effects when spinning - optimized with pre-calculated positions */}
|
||||
{isSpinning && (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
{SPARKLE_POSITIONS.map((pos, i) => (
|
||||
<div
|
||||
key={`sparkle-${i}`}
|
||||
className="absolute w-2 h-2 bg-yellow-300 rounded-full"
|
||||
className="absolute w-2 h-2 bg-yellow-300 rounded-full animate-ping"
|
||||
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,
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
animationDelay: pos.delay,
|
||||
animationDuration: '1.5s',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -436,4 +451,6 @@ export default function FortuneWheel({
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default FortuneWheel
|
||||
|
||||
127
src/hooks/useTelegramWebApp.ts
Normal file
127
src/hooks/useTelegramWebApp.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Hook for Telegram WebApp API integration
|
||||
* Provides fullscreen mode, safe area insets, and other WebApp features
|
||||
*/
|
||||
export function useTelegramWebApp() {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
|
||||
const [safeAreaInset, setSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||
const [contentSafeAreaInset, setContentSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||
|
||||
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!webApp) {
|
||||
setIsTelegramWebApp(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsTelegramWebApp(true)
|
||||
setIsFullscreen(webApp.isFullscreen || false)
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
|
||||
// Expand WebApp to full height
|
||||
webApp.expand()
|
||||
webApp.ready()
|
||||
|
||||
// Listen for fullscreen changes
|
||||
const handleFullscreenChanged = () => {
|
||||
setIsFullscreen(webApp.isFullscreen || false)
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}
|
||||
|
||||
// Listen for safe area changes
|
||||
const handleSafeAreaChanged = () => {
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}
|
||||
|
||||
webApp.onEvent('fullscreenChanged', handleFullscreenChanged)
|
||||
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged)
|
||||
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged)
|
||||
|
||||
return () => {
|
||||
webApp.offEvent('fullscreenChanged', handleFullscreenChanged)
|
||||
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged)
|
||||
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged)
|
||||
}
|
||||
}, [webApp])
|
||||
|
||||
const requestFullscreen = useCallback(() => {
|
||||
if (webApp?.requestFullscreen) {
|
||||
try {
|
||||
webApp.requestFullscreen()
|
||||
} catch (e) {
|
||||
console.warn('Fullscreen not supported:', e)
|
||||
}
|
||||
}
|
||||
}, [webApp])
|
||||
|
||||
const exitFullscreen = useCallback(() => {
|
||||
if (webApp?.exitFullscreen) {
|
||||
try {
|
||||
webApp.exitFullscreen()
|
||||
} catch (e) {
|
||||
console.warn('Exit fullscreen failed:', e)
|
||||
}
|
||||
}
|
||||
}, [webApp])
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
exitFullscreen()
|
||||
} else {
|
||||
requestFullscreen()
|
||||
}
|
||||
}, [isFullscreen, requestFullscreen, exitFullscreen])
|
||||
|
||||
const disableVerticalSwipes = useCallback(() => {
|
||||
if (webApp?.disableVerticalSwipes) {
|
||||
webApp.disableVerticalSwipes()
|
||||
}
|
||||
}, [webApp])
|
||||
|
||||
const enableVerticalSwipes = useCallback(() => {
|
||||
if (webApp?.enableVerticalSwipes) {
|
||||
webApp.enableVerticalSwipes()
|
||||
}
|
||||
}, [webApp])
|
||||
|
||||
// Check if fullscreen is supported (Bot API 8.0+)
|
||||
const isFullscreenSupported = Boolean(webApp?.requestFullscreen)
|
||||
|
||||
return {
|
||||
isTelegramWebApp,
|
||||
isFullscreen,
|
||||
isFullscreenSupported,
|
||||
safeAreaInset,
|
||||
contentSafeAreaInset,
|
||||
requestFullscreen,
|
||||
exitFullscreen,
|
||||
toggleFullscreen,
|
||||
disableVerticalSwipes,
|
||||
enableVerticalSwipes,
|
||||
webApp,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Telegram WebApp on app start
|
||||
* Call this in main.tsx or App.tsx
|
||||
*/
|
||||
export function initTelegramWebApp() {
|
||||
const webApp = window.Telegram?.WebApp
|
||||
if (webApp) {
|
||||
webApp.ready()
|
||||
webApp.expand()
|
||||
|
||||
// Disable vertical swipes to prevent accidental closing
|
||||
if (webApp.disableVerticalSwipes) {
|
||||
webApp.disableVerticalSwipes()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,117 +100,90 @@ function generatePalette(baseHex: string): ColorPalette {
|
||||
return palette as ColorPalette
|
||||
}
|
||||
|
||||
// Generate neutral palette from dark background color (for dark theme)
|
||||
function generateDarkPalette(darkBgHex: string): ColorPalette {
|
||||
const { h, s } = hexToHsl(darkBgHex)
|
||||
|
||||
// Use very low saturation for neutral colors
|
||||
const neutralS = Math.min(s, 15)
|
||||
|
||||
// Lightness values - from very light (50) to very dark (950)
|
||||
const lightnessMap: Record<number, number> = {
|
||||
50: 97,
|
||||
100: 96,
|
||||
200: 89,
|
||||
300: 80,
|
||||
400: 58,
|
||||
500: 40,
|
||||
600: 28,
|
||||
700: 20,
|
||||
800: 12,
|
||||
850: 10,
|
||||
900: 7,
|
||||
950: 4,
|
||||
}
|
||||
|
||||
const palette: Partial<ColorPalette> = {}
|
||||
|
||||
for (const shade of [...SHADE_LEVELS, 850] as const) {
|
||||
const lightness = lightnessMap[shade as keyof typeof lightnessMap] || 50
|
||||
const { r, g, b } = hslToRgb(h, neutralS, lightness)
|
||||
palette[shade as keyof ColorPalette] = rgbToString(r, g, b)
|
||||
}
|
||||
|
||||
return palette as ColorPalette
|
||||
}
|
||||
|
||||
// Generate light theme palette (champagne-like)
|
||||
function generateLightPalette(lightBgHex: string): ColorPalette {
|
||||
const { h, s } = hexToHsl(lightBgHex)
|
||||
|
||||
// Lightness values for light theme - inverse of dark
|
||||
const lightnessMap: Record<number, number> = {
|
||||
50: 100,
|
||||
100: 98,
|
||||
200: 91,
|
||||
300: 83,
|
||||
400: 74,
|
||||
500: 64,
|
||||
600: 55,
|
||||
700: 42,
|
||||
800: 31,
|
||||
900: 21,
|
||||
950: 10,
|
||||
}
|
||||
|
||||
const palette: Partial<ColorPalette> = {}
|
||||
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
const lightness = lightnessMap[shade]
|
||||
const { r, g, b } = hslToRgb(h, s, lightness)
|
||||
palette[shade] = rgbToString(r, g, b)
|
||||
}
|
||||
|
||||
return palette as ColorPalette
|
||||
// Interpolate between two RGB colors
|
||||
function interpolateRgb(
|
||||
rgb1: { r: number; g: number; b: number },
|
||||
rgb2: { r: number; g: number; b: number },
|
||||
factor: number
|
||||
): string {
|
||||
return rgbToString(
|
||||
Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
|
||||
Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
|
||||
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor)
|
||||
)
|
||||
}
|
||||
|
||||
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
|
||||
export function applyThemeColors(colors: ThemeColors): void {
|
||||
const root = document.documentElement
|
||||
|
||||
// Generate palettes from base colors
|
||||
// Generate palettes from status colors
|
||||
const accentPalette = generatePalette(colors.accent)
|
||||
const successPalette = generatePalette(colors.success)
|
||||
const warningPalette = generatePalette(colors.warning)
|
||||
const errorPalette = generatePalette(colors.error)
|
||||
|
||||
// Generate dark/light palettes from background colors
|
||||
const darkPalette = generateDarkPalette(colors.darkBackground)
|
||||
const champagnePalette = generateLightPalette(colors.lightBackground)
|
||||
// === DARK THEME PALETTE ===
|
||||
// Convert hex colors to RGB
|
||||
const darkBgRgb = hexToRgb(colors.darkBackground)
|
||||
const darkSurfaceRgb = hexToRgb(colors.darkSurface)
|
||||
const darkTextRgb = hexToRgb(colors.darkText)
|
||||
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary)
|
||||
|
||||
// Apply dark palette
|
||||
for (const shade of [...SHADE_LEVELS, 850] as const) {
|
||||
if (darkPalette[shade as keyof ColorPalette]) {
|
||||
root.style.setProperty(`--color-dark-${shade}`, darkPalette[shade as keyof ColorPalette])
|
||||
}
|
||||
}
|
||||
// Apply dark palette with actual user colors:
|
||||
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
|
||||
root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
|
||||
root.style.setProperty('--color-dark-100', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
|
||||
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33))
|
||||
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66))
|
||||
root.style.setProperty('--color-dark-400', rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b))
|
||||
|
||||
// Apply champagne/light palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-champagne-${shade}`, champagnePalette[shade])
|
||||
}
|
||||
// Transition colors (500-700): interpolate between secondary text and surface
|
||||
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4))
|
||||
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6))
|
||||
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8))
|
||||
|
||||
// Apply accent palette
|
||||
// Surface/card colors (800-850): surface color
|
||||
root.style.setProperty('--color-dark-800', rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b))
|
||||
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5))
|
||||
|
||||
// Background colors (900-950): background color
|
||||
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7))
|
||||
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b))
|
||||
|
||||
// === LIGHT THEME PALETTE ===
|
||||
const lightBgRgb = hexToRgb(colors.lightBackground)
|
||||
const lightSurfaceRgb = hexToRgb(colors.lightSurface)
|
||||
const lightTextRgb = hexToRgb(colors.lightText)
|
||||
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary)
|
||||
|
||||
// Apply champagne palette with actual user colors:
|
||||
// Background colors (light shades): 50-100 = surface, 200-400 = background tones
|
||||
root.style.setProperty('--color-champagne-50', rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b))
|
||||
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3))
|
||||
root.style.setProperty('--color-champagne-200', rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b))
|
||||
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2))
|
||||
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4))
|
||||
|
||||
// Transition colors (500-600): between bg and text
|
||||
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6))
|
||||
root.style.setProperty('--color-champagne-600', rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b))
|
||||
|
||||
// Text colors (700-950): secondary to primary text
|
||||
root.style.setProperty('--color-champagne-700', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33))
|
||||
root.style.setProperty('--color-champagne-800', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66))
|
||||
root.style.setProperty('--color-champagne-900', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
|
||||
root.style.setProperty('--color-champagne-950', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
|
||||
|
||||
// === STATUS COLOR PALETTES ===
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade])
|
||||
}
|
||||
|
||||
// Apply success palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-success-${shade}`, successPalette[shade])
|
||||
}
|
||||
|
||||
// Apply warning palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade])
|
||||
}
|
||||
|
||||
// Apply error palette
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade])
|
||||
}
|
||||
|
||||
// Apply semantic colors (hex for direct use in some places)
|
||||
// Apply semantic colors (hex for direct use)
|
||||
root.style.setProperty('--color-dark-bg', colors.darkBackground)
|
||||
root.style.setProperty('--color-dark-surface', colors.darkSurface)
|
||||
root.style.setProperty('--color-dark-text', colors.darkText)
|
||||
|
||||
@@ -5,9 +5,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import { ThemeColorsProvider } from './providers/ThemeColorsProvider'
|
||||
import { ToastProvider } from './components/Toast'
|
||||
import { initLogoPreload } from './api/branding'
|
||||
import { initTelegramWebApp } from './hooks/useTelegramWebApp'
|
||||
import './i18n'
|
||||
import './styles/globals.css'
|
||||
|
||||
// Initialize Telegram WebApp (expand, disable swipes)
|
||||
initTelegramWebApp()
|
||||
|
||||
// Preload logo from cache immediately on page load
|
||||
initLogoPreload()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -189,7 +189,13 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
|
||||
const addButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep') => {
|
||||
const step = editedApp[stepKey] || emptyAppStep()
|
||||
const buttons = step.buttons || []
|
||||
updateButtons(stepKey, [...buttons, { buttonLink: '', buttonText: emptyLocalizedText() }])
|
||||
// Generate unique id for React key
|
||||
const newButton: AppButton = {
|
||||
id: `btn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
buttonLink: '',
|
||||
buttonText: emptyLocalizedText()
|
||||
}
|
||||
updateButtons(stepKey, [...buttons, newButton])
|
||||
}
|
||||
|
||||
const removeButton = (stepKey: 'installationStep' | 'additionalBeforeAddSubscriptionStep' | 'additionalAfterAddSubscriptionStep', index: number) => {
|
||||
@@ -253,7 +259,7 @@ function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModa
|
||||
</button>
|
||||
</div>
|
||||
{buttons.map((button, index) => (
|
||||
<div key={index} className="p-3 bg-dark-800/50 rounded-lg space-y-2">
|
||||
<div key={button.id || `fallback-${index}`} className="p-3 bg-dark-800/50 rounded-lg space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-dark-400">{t('admin.apps.button')} #{index + 1}</span>
|
||||
<button
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { statsApi, type DashboardStats, type NodeStatus } from '../api/admin'
|
||||
import {
|
||||
statsApi,
|
||||
type DashboardStats,
|
||||
type NodeStatus,
|
||||
type TopReferrersResponse,
|
||||
type TopCampaignsResponse,
|
||||
type RecentPaymentsResponse
|
||||
} from '../api/admin'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
|
||||
// Icons
|
||||
// Icons - styled like main navigation
|
||||
const BackIcon = () => (
|
||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
@@ -12,26 +19,39 @@ const BackIcon = () => (
|
||||
)
|
||||
|
||||
const ServerIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
<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.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="w-6 h-6" 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" />
|
||||
const UsersOnlineIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CurrencyIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
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 SubscriptionIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
const ChartBarIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<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 TagIcon = () => (
|
||||
<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.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -53,6 +73,36 @@ const RestartIcon = () => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChevronDownIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const 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 MegaphoneIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BanknotesIcon = () => (
|
||||
<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 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ExclamationIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
@@ -122,6 +172,8 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
|
||||
return `${gb.toFixed(1)} GB`
|
||||
}
|
||||
|
||||
const hasError = node.last_status_message && !node.is_connected
|
||||
|
||||
return (
|
||||
<div className={`bg-dark-800/50 backdrop-blur rounded-xl border ${node.is_disabled ? 'border-dark-700' : node.is_connected ? 'border-success-500/30' : 'border-error-500/30'} p-4 hover:border-dark-600 transition-colors`}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
@@ -137,6 +189,32 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Xray Version & Uptime */}
|
||||
{(node.xray_version || node.xray_uptime) && (
|
||||
<div className="flex items-center gap-3 mb-3 text-xs">
|
||||
{node.xray_version && (
|
||||
<span className="px-2 py-1 rounded bg-dark-700/50 text-dark-300">
|
||||
Xray {node.xray_version}
|
||||
</span>
|
||||
)}
|
||||
{node.xray_uptime && (
|
||||
<span className="text-dark-500">
|
||||
Uptime: {node.xray_uptime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{hasError && (
|
||||
<div className="mb-3 p-2 rounded-lg bg-error-500/10 border border-error-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<ExclamationIcon />
|
||||
<span className="text-xs text-error-400 break-all">{node.last_status_message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div className="bg-dark-900/50 rounded-lg p-2.5">
|
||||
<div className="text-xs text-dark-500 mb-0.5">{t('adminDashboard.nodes.usersOnline')}</div>
|
||||
@@ -190,14 +268,14 @@ function RevenueChart({ data }: { data: { date: string; amount_rubles: number }[
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{last7Days.map((item, index) => {
|
||||
{last7Days.map((item) => {
|
||||
const percentage = (item.amount_rubles / maxValue) * 100
|
||||
const date = new Date(item.date)
|
||||
const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' })
|
||||
const dayNum = date.getDate()
|
||||
|
||||
return (
|
||||
<div key={index} className="group">
|
||||
<div key={item.date} className="group">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-dark-300 font-medium capitalize">{dayName}, {dayNum}</span>
|
||||
<span className="text-sm font-semibold text-dark-100">{formatAmount(item.amount_rubles)} {currencySymbol}</span>
|
||||
@@ -222,6 +300,13 @@ export default function AdminDashboard() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [showAllNodes, setShowAllNodes] = useState(false)
|
||||
|
||||
// Extended stats
|
||||
const [referrers, setReferrers] = useState<TopReferrersResponse | null>(null)
|
||||
const [campaigns, setCampaigns] = useState<TopCampaignsResponse | null>(null)
|
||||
const [payments, setPayments] = useState<RecentPaymentsResponse | null>(null)
|
||||
const [referrersTab, setReferrersTab] = useState<'earnings' | 'invited'>('earnings')
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
@@ -237,10 +322,29 @@ export default function AdminDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchExtendedStats = async () => {
|
||||
try {
|
||||
const [referrersData, campaignsData, paymentsData] = await Promise.all([
|
||||
statsApi.getTopReferrers(10),
|
||||
statsApi.getTopCampaigns(10),
|
||||
statsApi.getRecentPayments(20),
|
||||
])
|
||||
setReferrers(referrersData)
|
||||
setCampaigns(campaignsData)
|
||||
setPayments(paymentsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load extended stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
fetchExtendedStats()
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchStats, 30000)
|
||||
const interval = setInterval(() => {
|
||||
fetchStats()
|
||||
fetchExtendedStats()
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
@@ -319,26 +423,26 @@ export default function AdminDashboard() {
|
||||
<StatCard
|
||||
title={t('adminDashboard.stats.usersOnline')}
|
||||
value={stats?.nodes.total_users_online || 0}
|
||||
icon={<UsersIcon />}
|
||||
icon={<UsersOnlineIcon />}
|
||||
color="success"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('adminDashboard.stats.activeSubscriptions')}
|
||||
value={stats?.subscriptions.active || 0}
|
||||
subtitle={`${t('adminDashboard.stats.total')}: ${stats?.subscriptions.total || 0}`}
|
||||
icon={<SubscriptionIcon />}
|
||||
icon={<SparklesIcon />}
|
||||
color="accent"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('adminDashboard.stats.incomeToday')}
|
||||
value={`${formatAmount(stats?.financial.income_today_rubles || 0)} ${currencySymbol}`}
|
||||
icon={<CurrencyIcon />}
|
||||
icon={<WalletIcon />}
|
||||
color="warning"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('adminDashboard.stats.incomeMonth')}
|
||||
value={`${formatAmount(stats?.financial.income_month_rubles || 0)} ${currencySymbol}`}
|
||||
icon={<CurrencyIcon />}
|
||||
icon={<ChartBarIcon />}
|
||||
color="info"
|
||||
/>
|
||||
</div>
|
||||
@@ -347,7 +451,9 @@ export default function AdminDashboard() {
|
||||
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ServerIcon />
|
||||
<div className="p-2.5 rounded-lg bg-accent-500/20 text-accent-400">
|
||||
<ServerIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.nodes.title')}</h2>
|
||||
<p className="text-sm text-dark-400">
|
||||
@@ -372,17 +478,33 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
|
||||
{stats?.nodes.nodes && stats.nodes.nodes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{stats.nodes.nodes.map((node) => (
|
||||
<NodeCard
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
onRestart={handleRestartNode}
|
||||
onToggle={handleToggleNode}
|
||||
isLoading={actionLoading === node.uuid}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{(showAllNodes ? stats.nodes.nodes : stats.nodes.nodes.slice(0, 3)).map((node) => (
|
||||
<NodeCard
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
onRestart={handleRestartNode}
|
||||
onToggle={handleToggleNode}
|
||||
isLoading={actionLoading === node.uuid}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{stats.nodes.nodes.length > 3 && (
|
||||
<button
|
||||
onClick={() => setShowAllNodes(!showAllNodes)}
|
||||
className="mt-4 w-full flex items-center justify-center gap-2 py-3 px-4 rounded-lg bg-dark-700/50 text-dark-300 hover:text-dark-100 hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<span className={`transform transition-transform ${showAllNodes ? 'rotate-180' : ''}`}>
|
||||
<ChevronDownIcon />
|
||||
</span>
|
||||
{showAllNodes
|
||||
? `Скрыть (${stats.nodes.nodes.length - 3})`
|
||||
: `Показать еще ${stats.nodes.nodes.length - 3} нод`
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-dark-500">
|
||||
{t('adminDashboard.nodes.noNodes')}
|
||||
@@ -395,7 +517,9 @@ export default function AdminDashboard() {
|
||||
{/* Revenue Chart */}
|
||||
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<CurrencyIcon />
|
||||
<div className="p-2.5 rounded-lg bg-warning-500/20 text-warning-400">
|
||||
<ChartBarIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.revenue.title')}</h2>
|
||||
<p className="text-sm text-dark-400">{t('adminDashboard.revenue.last7Days')}</p>
|
||||
@@ -417,7 +541,9 @@ export default function AdminDashboard() {
|
||||
{/* Subscription Stats */}
|
||||
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<SubscriptionIcon />
|
||||
<div className="p-2.5 rounded-lg bg-accent-500/20 text-accent-400">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.subscriptions.title')}</h2>
|
||||
<p className="text-sm text-dark-400">{t('adminDashboard.subscriptions.subtitle')}</p>
|
||||
@@ -474,39 +600,13 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Stats */}
|
||||
{stats?.servers && (
|
||||
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<ServerIcon />
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.servers.title')}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-dark-900/50 rounded-lg p-4">
|
||||
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.total')}</div>
|
||||
<div className="text-2xl font-bold text-dark-100">{stats.servers.total_servers}</div>
|
||||
</div>
|
||||
<div className="bg-dark-900/50 rounded-lg p-4">
|
||||
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.available')}</div>
|
||||
<div className="text-2xl font-bold text-success-400">{stats.servers.available_servers}</div>
|
||||
</div>
|
||||
<div className="bg-dark-900/50 rounded-lg p-4">
|
||||
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.withConnections')}</div>
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.servers.servers_with_connections}</div>
|
||||
</div>
|
||||
<div className="bg-dark-900/50 rounded-lg p-4">
|
||||
<div className="text-xs text-dark-500 mb-1">{t('adminDashboard.servers.revenue')}</div>
|
||||
<div className="text-2xl font-bold text-warning-400">{formatAmount(stats.servers.total_revenue_rubles)} {currencySymbol}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tariff Stats */}
|
||||
{stats?.tariff_stats && stats.tariff_stats.tariffs.length > 0 && (
|
||||
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<SubscriptionIcon />
|
||||
<div className="p-2.5 rounded-lg bg-success-500/20 text-success-400">
|
||||
<TagIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.tariffs.title')}</h2>
|
||||
<p className="text-sm text-dark-400">{t('adminDashboard.tariffs.subtitle')}</p>
|
||||
@@ -553,6 +653,262 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extended Stats Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Referrers */}
|
||||
{referrers && (referrers.by_earnings.length > 0 || referrers.by_invited.length > 0) && (
|
||||
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="p-2 sm:p-2.5 rounded-lg bg-accent-500/20 text-accent-400">
|
||||
<UsersIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-dark-100">Топ рефералов</h2>
|
||||
<p className="text-xs sm:text-sm text-dark-400">
|
||||
{referrers.total_referrers} реф., {referrers.total_referrals} пригл.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setReferrersTab('earnings')}
|
||||
className={`px-2 sm:px-3 py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors ${
|
||||
referrersTab === 'earnings'
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'bg-dark-700/50 text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
По доходу
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setReferrersTab('invited')}
|
||||
className={`px-2 sm:px-3 py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors ${
|
||||
referrersTab === 'invited'
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'bg-dark-700/50 text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
По пригл.
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited).slice(0, 5).map((ref, idx) => (
|
||||
<div key={ref.user_id} className="flex items-center justify-between p-2 sm:p-3 rounded-lg bg-dark-900/50 hover:bg-dark-800/50 transition-colors gap-2">
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
||||
<span className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0 flex items-center justify-center rounded-full bg-dark-700 text-[10px] sm:text-xs font-bold text-dark-300">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-dark-100 text-xs sm:text-sm truncate">{ref.display_name}</div>
|
||||
{ref.username && <div className="text-[10px] sm:text-xs text-dark-500 truncate">@{ref.username}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
{referrersTab === 'earnings' ? (
|
||||
<>
|
||||
<div className="font-semibold text-success-400 text-xs sm:text-sm">{formatAmount(ref.earnings_total_kopeks / 100)} {currencySymbol}</div>
|
||||
<div className="text-[10px] sm:text-xs text-dark-500">{ref.invited_count} пригл.</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-semibold text-accent-400 text-xs sm:text-sm">{ref.invited_count} чел.</div>
|
||||
<div className="text-[10px] sm:text-xs text-dark-500">{formatAmount(ref.earnings_total_kopeks / 100)} {currencySymbol}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Period Stats */}
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-3 mt-4 pt-4 border-t border-dark-700">
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] sm:text-xs text-dark-500 mb-1">Сегодня</div>
|
||||
<div className="font-semibold text-dark-200 text-xs sm:text-base truncate">
|
||||
{formatAmount(
|
||||
(referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited)
|
||||
.reduce((sum, r) => sum + r.earnings_today_kopeks, 0) / 100
|
||||
)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] sm:text-xs text-dark-500 mb-1">Неделя</div>
|
||||
<div className="font-semibold text-dark-200 text-xs sm:text-base truncate">
|
||||
{formatAmount(
|
||||
(referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited)
|
||||
.reduce((sum, r) => sum + r.earnings_week_kopeks, 0) / 100
|
||||
)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] sm:text-xs text-dark-500 mb-1">Месяц</div>
|
||||
<div className="font-semibold text-dark-200 text-xs sm:text-base truncate">
|
||||
{formatAmount(
|
||||
(referrersTab === 'earnings' ? referrers.by_earnings : referrers.by_invited)
|
||||
.reduce((sum, r) => sum + r.earnings_month_kopeks, 0) / 100
|
||||
)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Campaigns */}
|
||||
{campaigns && campaigns.campaigns.length > 0 && (
|
||||
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-4 sm:p-5">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-4">
|
||||
<div className="p-2 sm:p-2.5 rounded-lg bg-warning-500/20 text-warning-400">
|
||||
<MegaphoneIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-dark-100">Топ РК ссылок</h2>
|
||||
<p className="text-xs sm:text-sm text-dark-400">
|
||||
{campaigns.total_campaigns} камп., {campaigns.total_registrations} рег.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{campaigns.campaigns.slice(0, 5).map((campaign, idx) => (
|
||||
<div key={campaign.id} className="flex items-center justify-between p-2 sm:p-3 rounded-lg bg-dark-900/50 hover:bg-dark-800/50 transition-colors gap-2">
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
||||
<span className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0 flex items-center justify-center rounded-full bg-dark-700 text-[10px] sm:text-xs font-bold text-dark-300">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-dark-100 text-xs sm:text-sm truncate">{campaign.name}</div>
|
||||
<div className="text-[10px] sm:text-xs text-dark-500 truncate">?start={campaign.start_parameter}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="font-semibold text-warning-400 text-xs sm:text-sm">{formatAmount(campaign.total_revenue_kopeks / 100)} {currencySymbol}</div>
|
||||
<div className="text-[10px] sm:text-xs text-dark-500">
|
||||
{campaign.registrations} · {campaign.conversion_rate.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-dark-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs sm:text-sm text-dark-400">Всего от РК</span>
|
||||
<span className="font-bold text-warning-400 text-sm sm:text-base">{formatAmount(campaigns.total_revenue_kopeks / 100)} {currencySymbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Payments */}
|
||||
{payments && payments.payments.length > 0 && (
|
||||
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 sm:p-2.5 rounded-lg bg-success-500/20 text-success-400">
|
||||
<BanknotesIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-dark-100">Последние платежи</h2>
|
||||
<p className="text-xs sm:text-sm text-dark-400">
|
||||
Сегодня: {formatAmount(payments.total_today_kopeks / 100)} {currencySymbol}
|
||||
<span className="hidden sm:inline"> · За неделю: {formatAmount(payments.total_week_kopeks / 100)} {currencySymbol}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-700">
|
||||
<th className="text-left text-xs text-dark-500 font-medium py-3 px-2">Пользователь</th>
|
||||
<th className="text-left text-xs text-dark-500 font-medium py-3 px-2">Тип</th>
|
||||
<th className="text-right text-xs text-dark-500 font-medium py-3 px-2">Сумма</th>
|
||||
<th className="text-left text-xs text-dark-500 font-medium py-3 px-2">Метод</th>
|
||||
<th className="text-right text-xs text-dark-500 font-medium py-3 px-2">Дата</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.payments.slice(0, 10).map((payment) => (
|
||||
<tr key={payment.id} className="border-b border-dark-700/50 hover:bg-dark-800/50 transition-colors">
|
||||
<td className="py-3 px-2">
|
||||
<div className="font-medium text-dark-100 text-sm">{payment.display_name}</div>
|
||||
{payment.username && <div className="text-xs text-dark-500">@{payment.username}</div>}
|
||||
</td>
|
||||
<td className="py-3 px-2">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
payment.type === 'deposit'
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-accent-500/20 text-accent-400'
|
||||
}`}>
|
||||
{payment.type_display}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<span className="font-semibold text-dark-100">{formatAmount(payment.amount_rubles)} {currencySymbol}</span>
|
||||
</td>
|
||||
<td className="py-3 px-2">
|
||||
<span className="text-xs text-dark-400">{payment.payment_method || '-'}</span>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
<span className="text-xs text-dark-400">
|
||||
{new Date(payment.created_at).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{payments.payments.slice(0, 10).map((payment) => (
|
||||
<div key={payment.id} className="p-3 rounded-lg bg-dark-900/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full whitespace-nowrap ${
|
||||
payment.type === 'deposit'
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-accent-500/20 text-accent-400'
|
||||
}`}>
|
||||
{payment.type_display}
|
||||
</span>
|
||||
<span className="font-medium text-dark-100 text-sm truncate">{payment.display_name}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-dark-100 text-sm whitespace-nowrap ml-2">
|
||||
{formatAmount(payment.amount_rubles)} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-dark-500">
|
||||
<span>{payment.payment_method || '-'}</span>
|
||||
<span>
|
||||
{new Date(payment.created_at).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,339 +1,365 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Icons - smaller versions for mobile
|
||||
const TicketIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
|
||||
// Group header icons
|
||||
const AnalyticsGroupIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CogIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const UsersGroupIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const TariffsGroupIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const MarketingGroupIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SystemGroupIcon = () => (
|
||||
<svg className="w-4 h-4" 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 PhoneIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const WheelIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} 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 TariffIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ServerIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const AdminIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChartIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
// Modern icons with consistent styling
|
||||
const ChartBarIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BanSystemIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BroadcastIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PromocodeIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CampaignIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} 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 PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const BanknotesIcon = () => (
|
||||
<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 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
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 ChatBubbleIcon = () => (
|
||||
<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 NoSymbolIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CreditCardIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const TicketIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const GiftIcon = () => (
|
||||
<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 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const RemnawaveIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const MegaphoneIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PaperAirplaneIcon = () => (
|
||||
<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 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<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 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 DevicePhoneMobileIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ServerStackIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CubeTransparentIcon = () => (
|
||||
<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 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg className="w-4 h-4 text-dark-500 group-hover:text-dark-300 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
interface AdminSection {
|
||||
interface AdminItem {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
mobileIcon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
color: string
|
||||
bgColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
// Mobile compact card
|
||||
function MobileAdminCard({ to, mobileIcon, title, bgColor, textColor }: AdminSection) {
|
||||
interface AdminGroup {
|
||||
id: string
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
gradient: string
|
||||
borderColor: string
|
||||
iconBg: string
|
||||
iconColor: string
|
||||
items: AdminItem[]
|
||||
}
|
||||
|
||||
function AdminCard({ to, icon, title, description, iconBg, iconColor }: AdminItem & { iconBg: string; iconColor: string }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex flex-col items-center justify-center p-3 bg-dark-800/80 rounded-2xl border border-dark-700/50 hover:border-dark-600 active:scale-95 transition-all duration-150"
|
||||
className="group flex items-center gap-3 p-3 rounded-xl bg-dark-800/40 border border-dark-700/50 hover:bg-dark-800/80 hover:border-dark-600 transition-all duration-200"
|
||||
>
|
||||
<div className={`w-12 h-12 rounded-xl ${bgColor} flex items-center justify-center mb-2`}>
|
||||
<div className={textColor}>
|
||||
{mobileIcon}
|
||||
</div>
|
||||
<div className={`w-10 h-10 rounded-lg ${iconBg} ${iconColor} flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-dark-100 group-hover:text-white transition-colors">{title}</h3>
|
||||
<p className="text-xs text-dark-500 truncate">{description}</p>
|
||||
</div>
|
||||
<div className="text-dark-600 group-hover:text-dark-400 transition-colors">
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-dark-200 text-center leading-tight">{title}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop card
|
||||
function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }: AdminSection) {
|
||||
function GroupSection({ group }: { group: AdminGroup }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group flex items-center gap-4 p-4 bg-dark-800/80 rounded-2xl border border-dark-700/50 hover:border-dark-600 hover:bg-dark-800 transition-all duration-200"
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-xl ${bgColor} flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform`}>
|
||||
<div className={textColor}>
|
||||
{icon}
|
||||
<div className="rounded-2xl border border-dark-700/50 overflow-hidden bg-dark-900/30 backdrop-blur">
|
||||
{/* Group Header */}
|
||||
<div className={`px-4 py-3 bg-gradient-to-r ${group.gradient} border-b ${group.borderColor}`}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`p-1.5 rounded-lg ${group.iconBg} ${group.iconColor}`}>
|
||||
{group.icon}
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-dark-100">{group.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-dark-100 mb-0.5">{title}</h3>
|
||||
<p className="text-sm text-dark-400 truncate">{description}</p>
|
||||
|
||||
{/* Group Items */}
|
||||
<div className="p-2 space-y-1.5">
|
||||
{group.items.map((item) => (
|
||||
<AdminCard
|
||||
key={item.to}
|
||||
{...item}
|
||||
iconBg={group.iconBg}
|
||||
iconColor={group.iconColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ChevronRightIcon />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const adminSections: AdminSection[] = [
|
||||
const groups: AdminGroup[] = [
|
||||
{
|
||||
to: '/admin/dashboard',
|
||||
icon: <ChartIcon />,
|
||||
mobileIcon: <ChartIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.dashboard'),
|
||||
description: t('admin.panel.dashboardDesc'),
|
||||
color: 'success',
|
||||
bgColor: 'bg-emerald-500/20',
|
||||
textColor: 'text-emerald-400'
|
||||
id: 'analytics',
|
||||
title: t('admin.groups.analytics', 'Аналитика'),
|
||||
icon: <AnalyticsGroupIcon />,
|
||||
gradient: 'from-emerald-500/10 to-emerald-500/5',
|
||||
borderColor: 'border-emerald-500/20',
|
||||
iconBg: 'bg-emerald-500/20',
|
||||
iconColor: 'text-emerald-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/dashboard',
|
||||
icon: <ChartBarIcon />,
|
||||
title: t('admin.nav.dashboard'),
|
||||
description: t('admin.panel.dashboardDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/payments',
|
||||
icon: <BanknotesIcon />,
|
||||
title: t('admin.nav.payments', 'Платежи'),
|
||||
description: t('admin.panel.paymentsDesc', 'История и проверка платежей'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
to: '/admin/tickets',
|
||||
icon: <TicketIcon />,
|
||||
mobileIcon: <TicketIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.tickets'),
|
||||
description: t('admin.panel.ticketsDesc'),
|
||||
color: 'warning',
|
||||
bgColor: 'bg-amber-500/20',
|
||||
textColor: 'text-amber-400'
|
||||
id: 'users',
|
||||
title: t('admin.groups.users', 'Пользователи'),
|
||||
icon: <UsersGroupIcon />,
|
||||
gradient: 'from-blue-500/10 to-blue-500/5',
|
||||
borderColor: 'border-blue-500/20',
|
||||
iconBg: 'bg-blue-500/20',
|
||||
iconColor: 'text-blue-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/users',
|
||||
icon: <UsersIcon />,
|
||||
title: t('admin.nav.users', 'Пользователи'),
|
||||
description: t('admin.panel.usersDesc', 'Управление пользователями'),
|
||||
},
|
||||
{
|
||||
to: '/admin/tickets',
|
||||
icon: <ChatBubbleIcon />,
|
||||
title: t('admin.nav.tickets'),
|
||||
description: t('admin.panel.ticketsDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/ban-system',
|
||||
icon: <NoSymbolIcon />,
|
||||
title: t('admin.nav.banSystem'),
|
||||
description: t('admin.panel.banSystemDesc'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
to: '/admin/settings',
|
||||
icon: <CogIcon />,
|
||||
mobileIcon: <CogIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.settings'),
|
||||
description: t('admin.panel.settingsDesc'),
|
||||
color: 'accent',
|
||||
bgColor: 'bg-blue-500/20',
|
||||
textColor: 'text-blue-400'
|
||||
id: 'tariffs',
|
||||
title: t('admin.groups.tariffs', 'Тарифы и продажи'),
|
||||
icon: <TariffsGroupIcon />,
|
||||
gradient: 'from-amber-500/10 to-amber-500/5',
|
||||
borderColor: 'border-amber-500/20',
|
||||
iconBg: 'bg-amber-500/20',
|
||||
iconColor: 'text-amber-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/tariffs',
|
||||
icon: <CreditCardIcon />,
|
||||
title: t('admin.nav.tariffs'),
|
||||
description: t('admin.panel.tariffsDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/promocodes',
|
||||
icon: <TicketIcon />,
|
||||
title: t('admin.nav.promocodes', 'Промокоды'),
|
||||
description: t('admin.panel.promocodesDesc', 'Управление промокодами'),
|
||||
},
|
||||
{
|
||||
to: '/admin/promo-offers',
|
||||
icon: <GiftIcon />,
|
||||
title: t('admin.nav.promoOffers', 'Промопредложения'),
|
||||
description: t('admin.panel.promoOffersDesc', 'Персональные скидки'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
to: '/admin/apps',
|
||||
icon: <PhoneIcon />,
|
||||
mobileIcon: <PhoneIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.apps'),
|
||||
description: t('admin.panel.appsDesc'),
|
||||
color: 'success',
|
||||
bgColor: 'bg-teal-500/20',
|
||||
textColor: 'text-teal-400'
|
||||
id: 'marketing',
|
||||
title: t('admin.groups.marketing', 'Маркетинг'),
|
||||
icon: <MarketingGroupIcon />,
|
||||
gradient: 'from-rose-500/10 to-rose-500/5',
|
||||
borderColor: 'border-rose-500/20',
|
||||
iconBg: 'bg-rose-500/20',
|
||||
iconColor: 'text-rose-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/campaigns',
|
||||
icon: <MegaphoneIcon />,
|
||||
title: t('admin.nav.campaigns', 'Кампании'),
|
||||
description: t('admin.panel.campaignsDesc', 'Рекламные кампании'),
|
||||
},
|
||||
{
|
||||
to: '/admin/broadcasts',
|
||||
icon: <PaperAirplaneIcon />,
|
||||
title: t('admin.nav.broadcasts'),
|
||||
description: t('admin.panel.broadcastsDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/wheel',
|
||||
icon: <SparklesIcon />,
|
||||
title: t('admin.nav.wheel'),
|
||||
description: t('admin.panel.wheelDesc'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
to: '/admin/wheel',
|
||||
icon: <WheelIcon />,
|
||||
mobileIcon: <WheelIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.wheel'),
|
||||
description: t('admin.panel.wheelDesc'),
|
||||
color: 'error',
|
||||
bgColor: 'bg-rose-500/20',
|
||||
textColor: 'text-rose-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/tariffs',
|
||||
icon: <TariffIcon />,
|
||||
mobileIcon: <TariffIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.tariffs'),
|
||||
description: t('admin.panel.tariffsDesc'),
|
||||
color: 'info',
|
||||
bgColor: 'bg-cyan-500/20',
|
||||
textColor: 'text-cyan-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/servers',
|
||||
icon: <ServerIcon />,
|
||||
mobileIcon: <ServerIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.servers'),
|
||||
description: t('admin.panel.serversDesc'),
|
||||
color: 'purple',
|
||||
bgColor: 'bg-purple-500/20',
|
||||
textColor: 'text-purple-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/broadcasts',
|
||||
icon: <BroadcastIcon />,
|
||||
mobileIcon: <BroadcastIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.broadcasts'),
|
||||
description: t('admin.panel.broadcastsDesc'),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/20',
|
||||
textColor: 'text-orange-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/promocodes',
|
||||
icon: <PromocodeIcon />,
|
||||
mobileIcon: <PromocodeIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.promocodes', 'Промокоды'),
|
||||
description: t('admin.panel.promocodesDesc', 'Управление промокодами'),
|
||||
color: 'violet',
|
||||
bgColor: 'bg-violet-500/20',
|
||||
textColor: 'text-violet-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/promo-offers',
|
||||
icon: <PromoOffersIcon />,
|
||||
mobileIcon: <PromoOffersIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.promoOffers', 'Промопредложения'),
|
||||
description: t('admin.panel.promoOffersDesc', 'Персональные скидки и предложения'),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/20',
|
||||
textColor: 'text-orange-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/campaigns',
|
||||
icon: <CampaignIcon />,
|
||||
mobileIcon: <CampaignIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.campaigns', 'Кампании'),
|
||||
description: t('admin.panel.campaignsDesc', 'Рекламные кампании'),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/20',
|
||||
textColor: 'text-orange-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/users',
|
||||
icon: <UsersIcon />,
|
||||
mobileIcon: <UsersIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.users', 'Пользователи'),
|
||||
description: t('admin.panel.usersDesc', 'Управление пользователями'),
|
||||
color: 'indigo',
|
||||
bgColor: 'bg-indigo-500/20',
|
||||
textColor: 'text-indigo-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/ban-system',
|
||||
icon: <BanSystemIcon />,
|
||||
mobileIcon: <BanSystemIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.banSystem'),
|
||||
description: t('admin.panel.banSystemDesc'),
|
||||
color: 'error',
|
||||
bgColor: 'bg-red-500/20',
|
||||
textColor: 'text-red-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/payments',
|
||||
icon: <PaymentsIcon />,
|
||||
mobileIcon: <PaymentsIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.payments', 'Платежи'),
|
||||
description: t('admin.panel.paymentsDesc', 'Проверка платежей'),
|
||||
color: 'lime',
|
||||
bgColor: 'bg-lime-500/20',
|
||||
textColor: 'text-lime-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/remnawave',
|
||||
icon: <RemnawaveIcon />,
|
||||
mobileIcon: <RemnawaveIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.remnawave', 'RemnaWave'),
|
||||
description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'),
|
||||
color: 'purple',
|
||||
bgColor: 'bg-purple-500/20',
|
||||
textColor: 'text-purple-400'
|
||||
id: 'system',
|
||||
title: t('admin.groups.system', 'Система'),
|
||||
icon: <SystemGroupIcon />,
|
||||
gradient: 'from-violet-500/10 to-violet-500/5',
|
||||
borderColor: 'border-violet-500/20',
|
||||
iconBg: 'bg-violet-500/20',
|
||||
iconColor: 'text-violet-400',
|
||||
items: [
|
||||
{
|
||||
to: '/admin/settings',
|
||||
icon: <CogIcon />,
|
||||
title: t('admin.nav.settings'),
|
||||
description: t('admin.panel.settingsDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/apps',
|
||||
icon: <DevicePhoneMobileIcon />,
|
||||
title: t('admin.nav.apps'),
|
||||
description: t('admin.panel.appsDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/servers',
|
||||
icon: <ServerStackIcon />,
|
||||
title: t('admin.nav.servers'),
|
||||
description: t('admin.panel.serversDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/remnawave',
|
||||
icon: <CubeTransparentIcon />,
|
||||
title: t('admin.nav.remnawave', 'RemnaWave'),
|
||||
description: t('admin.panel.remnawaveDesc', 'Управление панелью'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header - compact on mobile */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2.5 sm:p-3 bg-amber-500/20 rounded-xl">
|
||||
<AdminIcon className="w-6 h-6 sm:w-8 sm:h-8 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
|
||||
<p className="text-sm text-dark-400 hidden sm:block">{t('admin.panel.subtitle')}</p>
|
||||
</div>
|
||||
<div className="animate-fade-in space-y-4">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
|
||||
<p className="text-sm text-dark-400 mt-1">{t('admin.panel.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Compact 2-column grid */}
|
||||
<div className="grid grid-cols-3 gap-3 sm:hidden">
|
||||
{adminSections.map((section) => (
|
||||
<MobileAdminCard key={section.to} {...section} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tablet/Desktop: List style */}
|
||||
<div className="hidden sm:grid sm:grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{adminSections.map((section) => (
|
||||
<DesktopAdminCard key={section.to} {...section} />
|
||||
{/* Groups Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{groups.map((group) => (
|
||||
<GroupSection key={group.id} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,7 @@ const getTypeLabel = (type: PromoCodeType): string => {
|
||||
subscription_days: 'Дни подписки',
|
||||
trial_subscription: 'Тестовая подписка',
|
||||
promo_group: 'Группа скидок',
|
||||
discount: 'Скидка %',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
@@ -97,6 +98,7 @@ const getTypeColor = (type: PromoCodeType): string => {
|
||||
subscription_days: 'bg-blue-500/20 text-blue-400',
|
||||
trial_subscription: 'bg-purple-500/20 text-purple-400',
|
||||
promo_group: 'bg-amber-500/20 text-amber-400',
|
||||
discount: 'bg-pink-500/20 text-pink-400',
|
||||
}
|
||||
return colors[type] || 'bg-dark-600 text-dark-300'
|
||||
}
|
||||
@@ -146,10 +148,14 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }:
|
||||
const [promoGroupId, setPromoGroupId] = useState<number | null>(promocode?.promo_group_id || null)
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Для discount: balance_bonus_kopeks = процент (целое число), subscription_days = часы
|
||||
// Для balance: balance_bonus_kopeks = рубли * 100
|
||||
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
|
||||
code: code.trim().toUpperCase(),
|
||||
type,
|
||||
balance_bonus_kopeks: Math.round(balanceBonusRubles * 100),
|
||||
balance_bonus_kopeks: type === 'discount'
|
||||
? Math.round(balanceBonusRubles) // процент как целое число
|
||||
: Math.round(balanceBonusRubles * 100), // рубли в копейки
|
||||
subscription_days: subscriptionDays,
|
||||
max_uses: maxUses,
|
||||
is_active: isActive,
|
||||
@@ -165,6 +171,7 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }:
|
||||
if (type === 'balance' && balanceBonusRubles <= 0) return false
|
||||
if ((type === 'subscription_days' || type === 'trial_subscription') && subscriptionDays <= 0) return false
|
||||
if (type === 'promo_group' && !promoGroupId) return false
|
||||
if (type === 'discount' && (balanceBonusRubles <= 0 || balanceBonusRubles > 100 || subscriptionDays <= 0)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -207,6 +214,7 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }:
|
||||
<option value="subscription_days">Дни подписки</option>
|
||||
<option value="trial_subscription">Тестовая подписка</option>
|
||||
<option value="promo_group">Группа скидок</option>
|
||||
<option value="discount">Процентная скидка</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -262,6 +270,40 @@ function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'discount' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Процент скидки</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={balanceBonusRubles}
|
||||
onChange={e => setBalanceBonusRubles(Math.min(100, Math.max(0, parseFloat(e.target.value) || 0)))}
|
||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
<span className="text-dark-400">%</span>
|
||||
</div>
|
||||
<p className="text-xs text-dark-500 mt-1">Скидка применяется один раз при оплате</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Время действия</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={subscriptionDays}
|
||||
onChange={e => setSubscriptionDays(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
/>
|
||||
<span className="text-dark-400">часов</span>
|
||||
</div>
|
||||
<p className="text-xs text-dark-500 mt-1">Сколько часов после активации скидка действует</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Max Uses */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Макс. использований</label>
|
||||
@@ -578,6 +620,18 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
<span className="text-blue-400">+{promocode.subscription_days}</span>
|
||||
</div>
|
||||
)}
|
||||
{promocode.type === 'discount' && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Скидка:</span>
|
||||
<span className="text-pink-400">-{promocode.balance_bonus_kopeks}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Действует:</span>
|
||||
<span className="text-pink-400">{promocode.subscription_days} часов</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Лимит:</span>
|
||||
<span className="text-dark-200">
|
||||
@@ -936,6 +990,9 @@ export default function AdminPromocodes() {
|
||||
{(promo.type === 'subscription_days' || promo.type === 'trial_subscription') && (
|
||||
<span className="text-blue-400">+{promo.subscription_days} дней</span>
|
||||
)}
|
||||
{promo.type === 'discount' && (
|
||||
<span className="text-pink-400">-{promo.balance_bonus_kopeks}% на {promo.subscription_days}ч</span>
|
||||
)}
|
||||
<span>
|
||||
Использовано: {promo.current_uses}/{promo.max_uses === 0 ? '∞' : promo.max_uses}
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { adminSettingsApi, SettingDefinition, SettingCategorySummary } from '../api/adminSettings'
|
||||
import { brandingApi } from '../api/branding'
|
||||
import { brandingApi, setCachedBranding } from '../api/branding'
|
||||
import { setCachedAnimationEnabled } from '../components/AnimatedBackground'
|
||||
import { themeColorsApi } from '../api/themeColors'
|
||||
import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme'
|
||||
import { ColorPicker } from '../components/ColorPicker'
|
||||
@@ -864,9 +865,25 @@ export default function AdminSettings() {
|
||||
queryFn: brandingApi.getBranding,
|
||||
})
|
||||
|
||||
// Animation toggle query and mutation
|
||||
const { data: animationSettings } = useQuery({
|
||||
queryKey: ['animation-enabled'],
|
||||
queryFn: brandingApi.getAnimationEnabled,
|
||||
})
|
||||
|
||||
const updateAnimationMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled),
|
||||
onSuccess: (data) => {
|
||||
// Update local cache immediately for instant effect
|
||||
setCachedAnimationEnabled(data.enabled)
|
||||
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] })
|
||||
},
|
||||
})
|
||||
|
||||
const updateNameMutation = useMutation({
|
||||
mutationFn: (name: string) => brandingApi.updateName(name),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data) // Update cache immediately
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
setEditingName(false)
|
||||
},
|
||||
@@ -874,14 +891,16 @@ export default function AdminSettings() {
|
||||
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: (file: File) => brandingApi.uploadLogo(file),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data) // Update cache immediately
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteLogoMutation = useMutation({
|
||||
mutationFn: () => brandingApi.deleteLogo(),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data) // Update cache immediately
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
},
|
||||
})
|
||||
@@ -1187,6 +1206,29 @@ export default function AdminSettings() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animation Toggle */}
|
||||
<div className="mt-6 pt-6 border-t border-dark-700/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-dark-200">Анимированный фон</h3>
|
||||
<p className="text-xs text-dark-500 mt-0.5">
|
||||
Волновая анимация на фоне для всех пользователей
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateAnimationMutation.mutate(!(animationSettings?.enabled ?? true))}
|
||||
disabled={updateAnimationMutation.isPending}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
(animationSettings?.enabled ?? true) ? 'bg-accent-500' : 'bg-dark-600'
|
||||
} ${updateAnimationMutation.isPending ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
(animationSettings?.enabled ?? true) ? 'left-7' : 'left-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Colors Card */}
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
TariffCreateRequest,
|
||||
TariffUpdateRequest,
|
||||
PeriodPrice,
|
||||
ServerInfo,
|
||||
ServerTrafficLimit
|
||||
ServerInfo
|
||||
} from '../api/tariffs'
|
||||
|
||||
// Icons
|
||||
@@ -146,9 +145,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
tariff?.period_prices?.length ? tariff.period_prices : []
|
||||
)
|
||||
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || [])
|
||||
const [serverTrafficLimits, setServerTrafficLimits] = useState<Record<string, ServerTrafficLimit>>(
|
||||
tariff?.server_traffic_limits || {}
|
||||
)
|
||||
// Докупка трафика
|
||||
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
|
||||
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0)
|
||||
@@ -159,6 +155,18 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
// Режим сброса трафика
|
||||
const [trafficResetMode, setTrafficResetMode] = useState<string | null>(tariff?.traffic_reset_mode || null)
|
||||
|
||||
// Плавающий тариф - произвольное количество дней
|
||||
const [customDaysEnabled, setCustomDaysEnabled] = useState(tariff?.custom_days_enabled || false)
|
||||
const [pricePerDayKopeks, setPricePerDayKopeks] = useState(tariff?.price_per_day_kopeks || 0)
|
||||
const [minDays, setMinDays] = useState(tariff?.min_days || 1)
|
||||
const [maxDays, setMaxDays] = useState(tariff?.max_days || 365)
|
||||
|
||||
// Плавающий тариф - произвольный трафик
|
||||
const [customTrafficEnabled, setCustomTrafficEnabled] = useState(tariff?.custom_traffic_enabled || false)
|
||||
const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(tariff?.traffic_price_per_gb_kopeks || 0)
|
||||
const [minTrafficGb, setMinTrafficGb] = useState(tariff?.min_traffic_gb || 1)
|
||||
const [maxTrafficGb, setMaxTrafficGb] = useState(tariff?.max_traffic_gb || 1000)
|
||||
|
||||
// Новый период для добавления
|
||||
const [newPeriodDays, setNewPeriodDays] = useState(30)
|
||||
const [newPeriodPrice, setNewPeriodPrice] = useState(300)
|
||||
@@ -166,13 +174,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const filteredLimits: Record<string, ServerTrafficLimit> = {}
|
||||
for (const uuid of selectedSquads) {
|
||||
if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) {
|
||||
filteredLimits[uuid] = serverTrafficLimits[uuid]
|
||||
}
|
||||
}
|
||||
|
||||
const data: TariffCreateRequest | TariffUpdateRequest = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
@@ -183,24 +184,25 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
tier_level: tierLevel,
|
||||
period_prices: periodPrices.filter(p => p.price_kopeks > 0),
|
||||
allowed_squads: selectedSquads,
|
||||
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
|
||||
traffic_topup_enabled: trafficTopupEnabled,
|
||||
traffic_topup_packages: trafficTopupPackages,
|
||||
max_topup_traffic_gb: maxTopupTrafficGb,
|
||||
is_daily: false,
|
||||
daily_price_kopeks: 0,
|
||||
traffic_reset_mode: trafficResetMode,
|
||||
// Плавающий тариф
|
||||
custom_days_enabled: customDaysEnabled,
|
||||
price_per_day_kopeks: pricePerDayKopeks,
|
||||
min_days: minDays,
|
||||
max_days: maxDays,
|
||||
custom_traffic_enabled: customTrafficEnabled,
|
||||
traffic_price_per_gb_kopeks: trafficPricePerGbKopeks,
|
||||
min_traffic_gb: minTrafficGb,
|
||||
max_traffic_gb: maxTrafficGb,
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
|
||||
setServerTrafficLimits(prev => ({
|
||||
...prev,
|
||||
[uuid]: { traffic_limit_gb: limitGb }
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleServer = (uuid: string) => {
|
||||
setSelectedSquads(prev =>
|
||||
prev.includes(uuid)
|
||||
@@ -430,27 +432,24 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
{activeTab === 'servers' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-dark-400 mb-4">
|
||||
Выберите серверы, доступные на этом тарифе. Можно задать индивидуальный лимит трафика для каждого.
|
||||
Выберите серверы, доступные на этом тарифе.
|
||||
</p>
|
||||
{servers.length === 0 ? (
|
||||
<p className="text-dark-500 text-center py-4">Нет доступных серверов</p>
|
||||
) : (
|
||||
servers.map(server => {
|
||||
const isSelected = selectedSquads.includes(server.squad_uuid)
|
||||
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
|
||||
return (
|
||||
<div
|
||||
key={server.id}
|
||||
className={`p-3 rounded-lg transition-colors ${
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className={`p-3 rounded-lg transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-accent-500/20 border border-accent-500/50'
|
||||
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-5 h-5 rounded flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-accent-500 text-white'
|
||||
@@ -463,27 +462,6 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
<span className="text-xs text-dark-500">{server.country_code}</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="mt-2 ml-8 flex items-center gap-2">
|
||||
<span className="text-xs text-dark-400">Лимит трафика:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={serverLimit}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e => {
|
||||
e.stopPropagation()
|
||||
updateServerTrafficLimit(server.squad_uuid, Math.max(0, parseInt(e.target.value) || 0))
|
||||
}}
|
||||
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="text-xs text-dark-400">ГБ</span>
|
||||
{serverLimit === 0 && (
|
||||
<span className="text-xs text-dark-500">(использовать общий)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -585,6 +563,124 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Плавающий тариф - произвольное количество дней */}
|
||||
<div className="p-4 bg-dark-700/50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-200">Произвольное количество дней</h4>
|
||||
<p className="text-xs text-dark-500 mt-1">Пользователь сам выбирает срок подписки</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomDaysEnabled(!customDaysEnabled)}
|
||||
className={`w-10 h-6 rounded-full transition-colors relative ${
|
||||
customDaysEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
customDaysEnabled ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{customDaysEnabled && (
|
||||
<div className="space-y-3 pt-2 border-t border-dark-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Цена за день:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={pricePerDayKopeks / 100}
|
||||
onChange={e => setPricePerDayKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
<span className="text-dark-400">₽</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Мин. дней:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={minDays}
|
||||
onChange={e => setMinDays(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Макс. дней:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxDays}
|
||||
onChange={e => setMaxDays(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Плавающий тариф - произвольный трафик */}
|
||||
<div className="p-4 bg-dark-700/50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-200">Произвольный объём трафика</h4>
|
||||
<p className="text-xs text-dark-500 mt-1">Пользователь сам выбирает объём трафика при покупке</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomTrafficEnabled(!customTrafficEnabled)}
|
||||
className={`w-10 h-6 rounded-full transition-colors relative ${
|
||||
customTrafficEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
customTrafficEnabled ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{customTrafficEnabled && (
|
||||
<div className="space-y-3 pt-2 border-t border-dark-600">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Цена за 1 ГБ:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={trafficPricePerGbKopeks / 100}
|
||||
onChange={e => setTrafficPricePerGbKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
<span className="text-dark-400">₽</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Мин. ГБ:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={minTrafficGb}
|
||||
onChange={e => setMinTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-dark-400 w-32">Макс. ГБ:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxTrafficGb}
|
||||
onChange={e => setMaxTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Режим сброса трафика */}
|
||||
<div className="p-4 bg-dark-700/50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-dark-200 mb-3">Режим сброса трафика</h4>
|
||||
@@ -636,7 +732,7 @@ function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: Peri
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name || periodPrices.length === 0 || isLoading}
|
||||
disabled={!name || (periodPrices.length === 0 && !customDaysEnabled) || isLoading}
|
||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
@@ -668,9 +764,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
const [tierLevel, setTierLevel] = useState(tariff?.tier_level || 1)
|
||||
const [dailyPriceKopeks, setDailyPriceKopeks] = useState(tariff?.daily_price_kopeks || 0)
|
||||
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || [])
|
||||
const [serverTrafficLimits, setServerTrafficLimits] = useState<Record<string, ServerTrafficLimit>>(
|
||||
tariff?.server_traffic_limits || {}
|
||||
)
|
||||
// Докупка трафика
|
||||
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
|
||||
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0)
|
||||
@@ -684,13 +777,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'servers' | 'extra'>('basic')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const filteredLimits: Record<string, ServerTrafficLimit> = {}
|
||||
for (const uuid of selectedSquads) {
|
||||
if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) {
|
||||
filteredLimits[uuid] = serverTrafficLimits[uuid]
|
||||
}
|
||||
}
|
||||
|
||||
const data: TariffCreateRequest | TariffUpdateRequest = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
@@ -701,7 +787,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
tier_level: tierLevel,
|
||||
period_prices: [],
|
||||
allowed_squads: selectedSquads,
|
||||
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
|
||||
traffic_topup_enabled: trafficTopupEnabled,
|
||||
traffic_topup_packages: trafficTopupPackages,
|
||||
max_topup_traffic_gb: maxTopupTrafficGb,
|
||||
@@ -712,13 +797,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
|
||||
setServerTrafficLimits(prev => ({
|
||||
...prev,
|
||||
[uuid]: { traffic_limit_gb: limitGb }
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleServer = (uuid: string) => {
|
||||
setSelectedSquads(prev =>
|
||||
prev.includes(uuid)
|
||||
@@ -870,20 +948,17 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
) : (
|
||||
servers.map(server => {
|
||||
const isSelected = selectedSquads.includes(server.squad_uuid)
|
||||
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
|
||||
return (
|
||||
<div
|
||||
key={server.id}
|
||||
className={`p-3 rounded-lg transition-colors ${
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className={`p-3 rounded-lg transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-amber-500/20 border border-amber-500/50'
|
||||
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-5 h-5 rounded flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-amber-500 text-white'
|
||||
@@ -896,26 +971,6 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
<span className="text-xs text-dark-500">{server.country_code}</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="mt-2 ml-8 flex items-center gap-2">
|
||||
<span className="text-xs text-dark-400">Лимит трафика:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={serverLimit}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e => {
|
||||
e.stopPropagation()
|
||||
updateServerTrafficLimit(server.squad_uuid, Math.max(0, parseInt(e.target.value) || 0))
|
||||
}}
|
||||
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-amber-500"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-xs text-dark-400">ГБ</span>
|
||||
{serverLimit === 0 && (
|
||||
<span className="text-xs text-dark-500">(использовать общий)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { adminWheelApi, type WheelPrizeAdmin } from '../api/wheel'
|
||||
import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel'
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
@@ -468,7 +468,7 @@ export default function AdminWheel() {
|
||||
if (editingPrize) {
|
||||
updatePrizeMutation.mutate({ id: editingPrize.id, data })
|
||||
} else {
|
||||
createPrizeMutation.mutate(data as any)
|
||||
createPrizeMutation.mutate(data as CreateWheelPrizeData)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -32,15 +32,33 @@ const ChevronRightIcon = () => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SupportLottieIcon = () => (
|
||||
<div className="w-6 h-6">
|
||||
<DotLottieReact
|
||||
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
|
||||
loop
|
||||
autoplay
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
// Check if device might be low-performance (Telegram WebApp on mobile)
|
||||
const isLowPerfDevice = (() => {
|
||||
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
||||
return isTelegramWebApp && isMobile
|
||||
})()
|
||||
|
||||
const SupportLottieIcon = () => {
|
||||
// Use static icon on low-performance devices
|
||||
if (isLowPerfDevice) {
|
||||
return (
|
||||
<svg className="w-6 h-6" 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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-6 h-6">
|
||||
<DotLottieReact
|
||||
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
|
||||
loop
|
||||
autoplay
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -46,6 +46,8 @@ export default function DeepLinkRedirect() {
|
||||
const [status, setStatus] = useState<Status>('countdown')
|
||||
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const fallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Get branding
|
||||
const { data: branding } = useQuery({
|
||||
@@ -135,23 +137,34 @@ export default function DeepLinkRedirect() {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
openDeepLink()
|
||||
// Show fallback after a delay
|
||||
setTimeout(() => setStatus('fallback'), 2000)
|
||||
// Show fallback after a delay - store ref for cleanup
|
||||
fallbackTimeoutRef.current = setTimeout(() => setStatus('fallback'), 2000)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
// Cleanup fallback timeout on unmount
|
||||
if (fallbackTimeoutRef.current) {
|
||||
clearTimeout(fallbackTimeoutRef.current)
|
||||
fallbackTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [deepLink, status, openDeepLink])
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
const linkToCopy = subscriptionUrl || deepLink
|
||||
// Clear previous timeout to prevent stacking
|
||||
if (copiedTimeoutRef.current) {
|
||||
clearTimeout(copiedTimeoutRef.current)
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(linkToCopy)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = linkToCopy
|
||||
@@ -160,10 +173,19 @@ export default function DeepLinkRedirect() {
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup copied timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copiedTimeoutRef.current) {
|
||||
clearTimeout(copiedTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Progress percentage
|
||||
const progress = ((COUNTDOWN_SECONDS - countdown) / COUNTDOWN_SECONDS) * 100
|
||||
|
||||
|
||||
@@ -3,47 +3,11 @@ import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { brandingApi, type BrandingInfo } from '../api/branding'
|
||||
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, type BrandingInfo } from '../api/branding'
|
||||
import { getAndClearReturnUrl } from '../utils/token'
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher'
|
||||
import TelegramLoginButton from '../components/TelegramLoginButton'
|
||||
|
||||
const BRANDING_CACHE_KEY = 'cabinet-branding-cache'
|
||||
const BRANDING_CACHE_TTL = 1000 * 60 * 60 // 1 hour
|
||||
|
||||
const getCachedBranding = (): BrandingInfo | undefined => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(BRANDING_CACHE_KEY)
|
||||
if (!raw) return undefined
|
||||
const parsed = JSON.parse(raw) as { data?: BrandingInfo; timestamp?: number }
|
||||
if (!parsed?.data || !parsed.timestamp) return undefined
|
||||
if (Date.now() - parsed.timestamp > BRANDING_CACHE_TTL) {
|
||||
localStorage.removeItem(BRANDING_CACHE_KEY)
|
||||
return undefined
|
||||
}
|
||||
return parsed.data
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const cacheBranding = (data: BrandingInfo) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(
|
||||
BRANDING_CACHE_KEY,
|
||||
JSON.stringify({ data, timestamp: Date.now() })
|
||||
)
|
||||
} catch {
|
||||
// Ignore storage errors (e.g., private mode)
|
||||
}
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -55,6 +19,7 @@ export default function Login() {
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
|
||||
const [logoLoaded, setLogoLoaded] = useState(false)
|
||||
|
||||
// Получаем URL для возврата после авторизации
|
||||
const getReturnUrl = useCallback(() => {
|
||||
@@ -72,22 +37,21 @@ export default function Login() {
|
||||
return '/'
|
||||
}, [location.state])
|
||||
|
||||
// Fetch branding
|
||||
// Fetch branding with unified cache
|
||||
const cachedBranding = useMemo(() => getCachedBranding(), [])
|
||||
|
||||
const { data: branding } = useQuery<BrandingInfo>({
|
||||
queryKey: ['branding'],
|
||||
queryFn: brandingApi.getBranding,
|
||||
queryFn: async () => {
|
||||
const data = await brandingApi.getBranding()
|
||||
setCachedBranding(data)
|
||||
preloadLogo(data)
|
||||
return data
|
||||
},
|
||||
staleTime: 60000,
|
||||
placeholderData: cachedBranding,
|
||||
initialData: cachedBranding ?? undefined,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (branding) {
|
||||
cacheBranding(branding)
|
||||
}
|
||||
}, [branding])
|
||||
|
||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''
|
||||
const appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
|
||||
const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
|
||||
@@ -117,7 +81,9 @@ export default function Login() {
|
||||
await loginWithTelegram(tg.initData)
|
||||
navigate(getReturnUrl(), { replace: true })
|
||||
} catch (err) {
|
||||
console.error('Telegram auth failed:', err)
|
||||
// Log only status code to avoid leaking sensitive data
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
console.warn('Telegram auth failed with status:', status)
|
||||
setError(t('auth.telegramRequired'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -137,8 +103,16 @@ export default function Login() {
|
||||
await loginWithEmail(email, password)
|
||||
navigate(getReturnUrl(), { replace: true })
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
setError(error.response?.data?.detail || t('common.error'))
|
||||
const error = err as { response?: { status?: number; data?: { detail?: string } } }
|
||||
// Show user-friendly error messages without exposing sensitive server details
|
||||
const status = error.response?.status
|
||||
if (status === 401 || status === 403) {
|
||||
setError(t('auth.invalidCredentials', 'Неверный email или пароль'))
|
||||
} else if (status === 429) {
|
||||
setError(t('auth.tooManyAttempts', 'Слишком много попыток. Попробуйте позже'))
|
||||
} else {
|
||||
setError(t('common.error'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -158,11 +132,19 @@ export default function Login() {
|
||||
<div className="relative max-w-md w-full space-y-8">
|
||||
{/* Logo */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden">
|
||||
{branding?.has_custom_logo && logoUrl ? (
|
||||
<img src={logoUrl} alt={appName || 'Logo'} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-white font-bold text-2xl">{appLogo}</span>
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center mb-6 shadow-lg shadow-accent-500/30 overflow-hidden relative">
|
||||
{/* Always show letter as fallback */}
|
||||
<span className={`text-white font-bold text-2xl absolute transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
|
||||
{appLogo}
|
||||
</span>
|
||||
{/* Logo image with smooth fade-in */}
|
||||
{branding?.has_custom_logo && logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={appName || 'Logo'}
|
||||
className={`w-full h-full object-cover absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
onLoad={() => setLogoLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{appName && (
|
||||
|
||||
@@ -318,7 +318,7 @@ export default function Support() {
|
||||
if (webApp?.openLink) {
|
||||
log.debug('Using openLink')
|
||||
try {
|
||||
webApp.openLink(webUrl)
|
||||
webApp.openLink(webUrl, { try_browser: true })
|
||||
return
|
||||
} catch (e) {
|
||||
log.error('openLink failed:', e)
|
||||
@@ -340,7 +340,7 @@ export default function Support() {
|
||||
buttonAction: () => {
|
||||
const webApp = window.Telegram?.WebApp
|
||||
if (webApp?.openLink) {
|
||||
webApp.openLink(supportConfig.support_url!)
|
||||
webApp.openLink(supportConfig.support_url!, { try_browser: true })
|
||||
} else {
|
||||
window.open(supportConfig.support_url!, '_blank')
|
||||
}
|
||||
@@ -372,7 +372,7 @@ export default function Support() {
|
||||
webApp.openTelegramLink(webUrl)
|
||||
} else if (webApp?.openLink) {
|
||||
log.debug('Fallback using openLink')
|
||||
webApp.openLink(webUrl)
|
||||
webApp.openLink(webUrl, { try_browser: true })
|
||||
} else {
|
||||
log.debug('Fallback using window.open')
|
||||
window.open(webUrl, '_blank')
|
||||
|
||||
@@ -6,6 +6,15 @@ import FortuneWheel from '../components/wheel/FortuneWheel'
|
||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
|
||||
// Pre-calculated confetti positions (stable across re-renders)
|
||||
const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({
|
||||
color: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
|
||||
left: `${(i * 17 + 5) % 95}%`,
|
||||
top: `${(i * 23 + 3) % 90}%`,
|
||||
delay: `${(i * 0.1) % 2}s`,
|
||||
duration: `${1 + (i % 3) * 0.3}s`,
|
||||
}))
|
||||
|
||||
// Icons
|
||||
const StarIcon = () => (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -98,10 +107,12 @@ export default function Wheel() {
|
||||
}, [config, isTelegramMiniApp])
|
||||
|
||||
// Function to poll for new spin result after Stars payment
|
||||
const pollForSpinResult = useCallback(async (maxAttempts = 15, delayMs = 800) => {
|
||||
const pollForSpinResult = useCallback(async (signal: AbortSignal, maxAttempts = 15, delayMs = 800) => {
|
||||
// Wait a bit before first poll to give the bot time to process the payment
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
if (signal.aborted) return null
|
||||
|
||||
// Get current history to find the latest spin ID
|
||||
let historyBefore
|
||||
try {
|
||||
@@ -112,8 +123,12 @@ export default function Wheel() {
|
||||
const lastSpinIdBefore = historyBefore.items.length > 0 ? historyBefore.items[0].id : 0
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (signal.aborted) return null
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||
|
||||
if (signal.aborted) return null
|
||||
|
||||
try {
|
||||
const historyAfter = await wheelApi.getHistory(1, 1)
|
||||
|
||||
@@ -152,6 +167,16 @@ export default function Wheel() {
|
||||
// Ref to store pending Stars payment result
|
||||
const pendingStarsResultRef = useRef<SpinResult | null>(null)
|
||||
const isStarsSpinRef = useRef(false)
|
||||
const pollingAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Cleanup polling on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingAbortRef.current) {
|
||||
pollingAbortRef.current.abort()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const starsInvoiceMutation = useMutation({
|
||||
mutationFn: wheelApi.createStarsInvoice,
|
||||
@@ -163,6 +188,12 @@ export default function Wheel() {
|
||||
isStarsSpinRef.current = true
|
||||
pendingStarsResultRef.current = null
|
||||
|
||||
// Cancel any existing polling
|
||||
if (pollingAbortRef.current) {
|
||||
pollingAbortRef.current.abort()
|
||||
}
|
||||
pollingAbortRef.current = new AbortController()
|
||||
|
||||
// Payment done - reset paying state immediately
|
||||
setIsPayingStars(false)
|
||||
|
||||
@@ -172,7 +203,10 @@ export default function Wheel() {
|
||||
|
||||
// Poll for the result in the background - don't await here!
|
||||
// The result will be stored and shown when animation completes
|
||||
pollForSpinResult().then((result) => {
|
||||
const abortSignal = pollingAbortRef.current.signal
|
||||
pollForSpinResult(abortSignal).then((result) => {
|
||||
if (abortSignal.aborted) return
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
|
||||
|
||||
@@ -195,6 +229,8 @@ export default function Wheel() {
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
if (abortSignal.aborted) return
|
||||
|
||||
// Error polling, show generic success
|
||||
pendingStarsResultRef.current = {
|
||||
success: true,
|
||||
@@ -551,7 +587,7 @@ export default function Wheel() {
|
||||
}`}
|
||||
style={{
|
||||
backgroundSize: '200% 100%',
|
||||
animation: !isSpinning && config.can_spin ? 'shimmer 3s linear infinite' : 'none',
|
||||
animation: !isSpinning && config.can_spin ? 'wheel-shimmer 3s linear infinite' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Button glow effect */}
|
||||
@@ -669,18 +705,18 @@ export default function Wheel() {
|
||||
<>
|
||||
<div className="absolute top-0 left-0 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full blur-3xl" />
|
||||
{/* Confetti effect */}
|
||||
{/* Confetti effect - using pre-calculated positions */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
{CONFETTI_POSITIONS.map((pos, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-2 h-2 rounded-full animate-bounce"
|
||||
style={{
|
||||
background: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
animationDelay: `${Math.random() * 2}s`,
|
||||
animationDuration: `${1 + Math.random()}s`,
|
||||
background: pos.color,
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
animationDelay: pos.delay,
|
||||
animationDuration: pos.duration,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -747,12 +783,6 @@ export default function Wheel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 50%; }
|
||||
100% { background-position: -200% 50%; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -102,19 +102,34 @@
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Smooth scroll only on desktop - mobile uses native */
|
||||
@media (min-width: 1024px) {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme (default) */
|
||||
body, .dark body {
|
||||
@apply bg-dark-950 text-dark-100 font-sans antialiased;
|
||||
overscroll-behavior-y: contain;
|
||||
/* iOS smooth scrolling */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Optimize main scrollable content */
|
||||
main {
|
||||
/* Prevent layout shifts during scroll */
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* Light theme - Champagne */
|
||||
.light body {
|
||||
@apply bg-champagne-200 text-champagne-900 font-sans antialiased;
|
||||
overscroll-behavior-y: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Light theme text color overrides - convert dark-* text colors to readable colors */
|
||||
@@ -310,10 +325,19 @@
|
||||
@layer components {
|
||||
/* ========== DARK THEME COMPONENTS (default) ========== */
|
||||
|
||||
/* Cards - Dark */
|
||||
/* Cards - Dark (optimized for mobile) */
|
||||
.card {
|
||||
@apply bg-dark-900/50 backdrop-blur-sm rounded-2xl border border-dark-800/50 p-5 sm:p-6
|
||||
@apply bg-dark-900/70 rounded-2xl border border-dark-800/50 p-5 sm:p-6
|
||||
transition-all duration-300 ease-smooth;
|
||||
/* GPU acceleration for smooth scroll */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Enable backdrop-blur only on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.card {
|
||||
@apply bg-dark-900/50 backdrop-blur-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@@ -329,9 +353,19 @@
|
||||
@apply card animate-slide-up;
|
||||
}
|
||||
|
||||
/* Glass effect - Dark */
|
||||
/* Glass effect - Dark (optimized for mobile) */
|
||||
.glass {
|
||||
@apply bg-dark-900/30 backdrop-blur-xl border border-dark-700/30;
|
||||
@apply bg-dark-900/80 border border-dark-700/30;
|
||||
/* GPU acceleration */
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Enable backdrop-blur only on desktop where it's performant */
|
||||
@media (min-width: 1024px) {
|
||||
.glass {
|
||||
@apply bg-dark-900/30 backdrop-blur-xl;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons - Dark */
|
||||
@@ -455,11 +489,18 @@
|
||||
@apply nav-item text-accent-400 bg-accent-500/10;
|
||||
}
|
||||
|
||||
/* Bottom nav - Dark */
|
||||
/* Bottom nav - Dark (optimized for mobile) */
|
||||
.bottom-nav {
|
||||
@apply fixed bottom-0 left-0 right-0 z-50
|
||||
bg-dark-900/80 backdrop-blur-xl border-t border-dark-800/50
|
||||
bg-dark-900/95 border-t border-dark-800/50
|
||||
safe-area-pb;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.bottom-nav {
|
||||
@apply bg-dark-900/80 backdrop-blur-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
@@ -476,12 +517,34 @@
|
||||
@apply border-t border-dark-800/50;
|
||||
}
|
||||
|
||||
/* ========== MOBILE PERFORMANCE: Disable backdrop-blur ========== */
|
||||
@media (max-width: 1023px) {
|
||||
/* Disable all backdrop-blur on mobile for better scroll performance */
|
||||
.backdrop-blur,
|
||||
.backdrop-blur-sm,
|
||||
.backdrop-blur-md,
|
||||
.backdrop-blur-lg,
|
||||
.backdrop-blur-xl,
|
||||
.backdrop-blur-2xl,
|
||||
.backdrop-blur-3xl {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== LIGHT THEME COMPONENTS (Champagne) ========== */
|
||||
|
||||
/* Cards - Light */
|
||||
.light .card {
|
||||
@apply bg-white/80 backdrop-blur-sm rounded-2xl border border-champagne-300/50 p-5 sm:p-6
|
||||
@apply bg-white/90 rounded-2xl border border-champagne-300/50 p-5 sm:p-6
|
||||
shadow-sm;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.light .card {
|
||||
@apply bg-white/80 backdrop-blur-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.light .card-hover {
|
||||
@@ -492,9 +555,16 @@
|
||||
@apply hover:shadow-lg hover:border-accent-400/40;
|
||||
}
|
||||
|
||||
/* Glass effect - Light */
|
||||
/* Glass effect - Light (optimized for mobile) */
|
||||
.light .glass {
|
||||
@apply bg-white/60 backdrop-blur-xl border border-champagne-300/40;
|
||||
@apply bg-white/90 border border-champagne-300/40;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.light .glass {
|
||||
@apply bg-white/60 backdrop-blur-xl;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons - Light */
|
||||
@@ -884,6 +954,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Color picker range input styling */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
@apply bg-accent-500/30 text-dark-50;
|
||||
@@ -1003,6 +1104,11 @@
|
||||
}
|
||||
|
||||
/* Fortune Wheel animations */
|
||||
@keyframes wheel-shimmer {
|
||||
0% { background-position: 200% 50%; }
|
||||
100% { background-position: -200% 50%; }
|
||||
}
|
||||
|
||||
@keyframes wheel-glow {
|
||||
0%, 100% { filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.3)); }
|
||||
50% { filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.6)); }
|
||||
@@ -1050,3 +1156,99 @@
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
|
||||
/* ========== ANIMATED BACKGROUND (GPU Optimized) ========== */
|
||||
.wave-bg-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
contain: strict; /* Performance: isolate repaints */
|
||||
}
|
||||
|
||||
.wave-blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px); /* Reduced from 80px for better performance */
|
||||
opacity: 0.5;
|
||||
/* GPU acceleration */
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Smaller sizes for better performance */
|
||||
.wave-blob-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.6) 0%, transparent 70%);
|
||||
top: -15%;
|
||||
left: -10%;
|
||||
animation: wave1 20s ease-in-out infinite; /* Slower = less CPU */
|
||||
}
|
||||
|
||||
.wave-blob-2 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.6) 0%, transparent 70%);
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
animation: wave2 25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wave-blob-3 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, rgba(168, 85, 247, 0.4) 0%, transparent 70%);
|
||||
top: 40%;
|
||||
left: 40%;
|
||||
animation: wave3 30s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wave-blob-4 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: radial-gradient(circle, rgba(236, 72, 153, 0.35) 0%, transparent 70%);
|
||||
bottom: 25%;
|
||||
left: 15%;
|
||||
animation: wave4 22s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Simplified keyframes - fewer steps = better performance */
|
||||
@keyframes wave1 {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(8%, 12%, 0) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes wave2 {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(-10%, -8%, 0) scale(1.08); }
|
||||
}
|
||||
|
||||
@keyframes wave3 {
|
||||
0%, 100% { transform: translate3d(-50%, -50%, 0) scale(1); }
|
||||
50% { transform: translate3d(-45%, -55%, 0) scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes wave4 {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(12%, -8%, 0) scale(1.12); }
|
||||
}
|
||||
|
||||
/* Light theme adjustments */
|
||||
.light .wave-blob {
|
||||
opacity: 0.6;
|
||||
filter: blur(70px);
|
||||
}
|
||||
|
||||
.light .wave-blob-1 {
|
||||
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.7) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* Disable animations for reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wave-blob {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,6 +453,7 @@ export interface LocalizedText {
|
||||
}
|
||||
|
||||
export interface AppButton {
|
||||
id?: string // Unique identifier for React key (client-side only)
|
||||
buttonLink: string
|
||||
buttonText: LocalizedText
|
||||
}
|
||||
|
||||
25
src/vite-env.d.ts
vendored
25
src/vite-env.d.ts
vendored
@@ -25,12 +25,35 @@ interface TelegramWebApp {
|
||||
auth_date: number
|
||||
hash: string
|
||||
}
|
||||
version: string
|
||||
platform: string
|
||||
isExpanded: boolean
|
||||
isClosingConfirmationEnabled: boolean
|
||||
isVerticalSwipesEnabled: boolean
|
||||
isFullscreen: boolean
|
||||
isOrientationLocked: boolean
|
||||
safeAreaInset: { top: number; bottom: number; left: number; right: number }
|
||||
contentSafeAreaInset: { top: number; bottom: number; left: number; right: number }
|
||||
ready: () => void
|
||||
expand: () => void
|
||||
close: () => void
|
||||
openLink: (url: string, options?: { try_instant_view?: boolean }) => void
|
||||
openLink: (url: string, options?: { try_instant_view?: boolean; try_browser?: boolean }) => void
|
||||
openTelegramLink: (url: string) => void
|
||||
openInvoice: (url: string, callback?: (status: 'paid' | 'cancelled' | 'failed' | 'pending') => void) => void
|
||||
// Fullscreen API (Bot API 8.0+)
|
||||
requestFullscreen: () => void
|
||||
exitFullscreen: () => void
|
||||
lockOrientation: () => void
|
||||
unlockOrientation: () => void
|
||||
// Vertical swipes control
|
||||
disableVerticalSwipes: () => void
|
||||
enableVerticalSwipes: () => void
|
||||
// Closing confirmation
|
||||
enableClosingConfirmation: () => void
|
||||
disableClosingConfirmation: () => void
|
||||
// Event handlers
|
||||
onEvent: (eventType: string, callback: () => void) => void
|
||||
offEvent: (eventType: string, callback: () => void) => void
|
||||
MainButton: {
|
||||
text: string
|
||||
color: string
|
||||
|
||||
Reference in New Issue
Block a user