Merge pull request #98 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-01-27 14:17:34 +03:00
committed by GitHub
16 changed files with 587 additions and 120 deletions

View File

@@ -5,7 +5,7 @@
<link rel="icon" href="data:," />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0a0f1a" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no" />
<title>Loading...</title>

View File

@@ -6,6 +6,7 @@ import Layout from './components/layout/Layout'
import PageLoader from './components/common/PageLoader'
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking'
import { saveReturnUrl } from './utils/token'
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'
// Auth pages - load immediately (small)
import Login from './pages/Login'
@@ -109,6 +110,8 @@ function BlockingOverlay() {
}
function App() {
useAnalyticsCounters()
return (
<>
<BlockingOverlay />

View File

@@ -19,6 +19,12 @@ export interface EmailAuthEnabled {
enabled: boolean
}
export interface AnalyticsCounters {
yandex_metrika_id: string
google_ads_id: string
google_ads_label: string
}
const BRANDING_CACHE_KEY = 'cabinet_branding'
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
@@ -178,4 +184,20 @@ export const brandingApi = {
const response = await apiClient.patch<EmailAuthEnabled>('/cabinet/branding/email-auth', { enabled })
return response.data
},
// Get analytics counters (public, no auth required)
getAnalyticsCounters: async (): Promise<AnalyticsCounters> => {
try {
const response = await apiClient.get<AnalyticsCounters>('/cabinet/branding/analytics')
return response.data
} catch {
return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' }
}
},
// Update analytics counters (admin only)
updateAnalyticsCounters: async (data: Partial<AnalyticsCounters>): Promise<AnalyticsCounters> => {
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data)
return response.data
},
}

View File

@@ -0,0 +1,294 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { brandingApi } from '../../api/branding'
import { CheckIcon, CloseIcon } from './icons'
export function AnalyticsTab() {
const { t } = useTranslation()
const queryClient = useQueryClient()
// Editing states
const [editingYandex, setEditingYandex] = useState(false)
const [editingGoogleId, setEditingGoogleId] = useState(false)
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false)
const [yandexValue, setYandexValue] = useState('')
const [googleIdValue, setGoogleIdValue] = useState('')
const [googleLabelValue, setGoogleLabelValue] = useState('')
const [error, setError] = useState<string | null>(null)
// Query
const { data: analytics } = useQuery({
queryKey: ['analytics-counters'],
queryFn: brandingApi.getAnalyticsCounters,
})
// Mutation
const updateMutation = useMutation({
mutationFn: brandingApi.updateAnalyticsCounters,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] })
setError(null)
},
onError: (err: unknown) => {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setError(detail || t('common.error'))
},
})
const handleSaveYandex = () => {
updateMutation.mutate(
{ yandex_metrika_id: yandexValue.trim() },
{ onSuccess: () => setEditingYandex(false) },
)
}
const handleSaveGoogleId = () => {
updateMutation.mutate(
{ google_ads_id: googleIdValue.trim() },
{ onSuccess: () => setEditingGoogleId(false) },
)
}
const handleSaveGoogleLabel = () => {
updateMutation.mutate(
{ google_ads_label: googleLabelValue.trim() },
{ onSuccess: () => setEditingGoogleLabel(false) },
)
}
const yandexActive = Boolean(analytics?.yandex_metrika_id)
const googleActive = Boolean(analytics?.google_ads_id)
return (
<div className="space-y-6">
{/* Error message */}
{error && (
<div className="p-4 rounded-2xl bg-error-500/10 border border-error-500/30 text-error-400 text-sm">
{error}
</div>
)}
{/* Yandex Metrika */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
</div>
<h3 className="text-lg font-semibold text-dark-100">
{t('admin.settings.yandexMetrika')}
</h3>
</div>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
yandexActive
? 'bg-success-500/15 text-success-400'
: 'bg-dark-700/50 text-dark-500'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`} />
{yandexActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
</span>
</div>
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
{t('admin.settings.yandexMetrikaDesc')}
</p>
<div className="space-y-3">
<label className="block text-sm font-medium text-dark-300">
{t('admin.settings.counterId')}
</label>
{editingYandex ? (
<div className="flex gap-2">
<input
type="text"
value={yandexValue}
onChange={(e) => setYandexValue(e.target.value.replace(/\D/g, ''))}
placeholder={t('admin.settings.yandexIdPlaceholder')}
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
autoFocus
/>
<button
onClick={handleSaveYandex}
disabled={updateMutation.isPending}
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
>
<CheckIcon />
</button>
<button
onClick={() => { setEditingYandex(false); setError(null) }}
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
>
<CloseIcon />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<span className={`text-base ${analytics?.yandex_metrika_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
{analytics?.yandex_metrika_id || t('admin.settings.notConfigured')}
</span>
<button
onClick={() => {
setYandexValue(analytics?.yandex_metrika_id || '')
setEditingYandex(true)
setError(null)
}}
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
</div>
)}
<p className="text-xs text-dark-500">
{t('admin.settings.yandexIdHint')}
</p>
</div>
</div>
{/* Google Ads */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/>
</svg>
</div>
<h3 className="text-lg font-semibold text-dark-100">
{t('admin.settings.googleAds')}
</h3>
</div>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
googleActive
? 'bg-success-500/15 text-success-400'
: 'bg-dark-700/50 text-dark-500'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`} />
{googleActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
</span>
</div>
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
{t('admin.settings.googleAdsDesc')}
</p>
<div className="space-y-5">
{/* Conversion ID */}
<div className="space-y-3">
<label className="block text-sm font-medium text-dark-300">
{t('admin.settings.conversionId')}
</label>
{editingGoogleId ? (
<div className="flex gap-2">
<input
type="text"
value={googleIdValue}
onChange={(e) => setGoogleIdValue(e.target.value)}
placeholder={t('admin.settings.googleIdPlaceholder')}
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
autoFocus
/>
<button
onClick={handleSaveGoogleId}
disabled={updateMutation.isPending}
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
>
<CheckIcon />
</button>
<button
onClick={() => { setEditingGoogleId(false); setError(null) }}
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
>
<CloseIcon />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<span className={`text-base ${analytics?.google_ads_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
{analytics?.google_ads_id || t('admin.settings.notConfigured')}
</span>
<button
onClick={() => {
setGoogleIdValue(analytics?.google_ads_id || '')
setEditingGoogleId(true)
setError(null)
}}
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
</div>
)}
<p className="text-xs text-dark-500">
{t('admin.settings.googleIdHint')}
</p>
</div>
{/* Conversion Label */}
<div className="space-y-3">
<label className="block text-sm font-medium text-dark-300">
{t('admin.settings.conversionLabel')}
</label>
{editingGoogleLabel ? (
<div className="flex gap-2">
<input
type="text"
value={googleLabelValue}
onChange={(e) => setGoogleLabelValue(e.target.value)}
placeholder={t('admin.settings.googleLabelPlaceholder')}
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
autoFocus
/>
<button
onClick={handleSaveGoogleLabel}
disabled={updateMutation.isPending}
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
>
<CheckIcon />
</button>
<button
onClick={() => { setEditingGoogleLabel(false); setError(null) }}
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
>
<CloseIcon />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<span className={`text-base ${analytics?.google_ads_label ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
{analytics?.google_ads_label || t('admin.settings.notConfigured')}
</span>
<button
onClick={() => {
setGoogleLabelValue(analytics?.google_ads_label || '')
setEditingGoogleLabel(true)
setError(null)
}}
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
</div>
)}
<p className="text-xs text-dark-500">
{t('admin.settings.googleLabelHint')}
</p>
</div>
</div>
</div>
{/* Info block */}
<div className="p-4 rounded-2xl bg-dark-800/30 border border-dark-700/30">
<p className="text-sm text-dark-500 leading-relaxed">
{t('admin.settings.analyticsHint')}
</p>
</div>
</div>
)
}

View File

@@ -20,6 +20,7 @@ export const MENU_SECTIONS: MenuSection[] = [
{ id: 'favorites', iconType: 'star' },
{ id: 'branding', iconType: null },
{ id: 'theme', iconType: null },
{ id: 'analytics', iconType: null },
]
},
{

View File

@@ -3,6 +3,7 @@ export * from './icons'
export * from './Toggle'
export * from './SettingInput'
export * from './SettingRow'
export * from './AnalyticsTab'
export * from './BrandingTab'
export * from './ThemeTab'
export * from './FavoritesTab'

View File

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

View File

@@ -0,0 +1,84 @@
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { brandingApi } from '../api/branding'
const YM_SCRIPT_ID = 'ym-counter-script'
const GTAG_LOADER_ID = 'gtag-loader-script'
const GTAG_INIT_ID = 'gtag-init-script'
function removeElement(id: string) {
document.getElementById(id)?.remove()
}
function injectYandexMetrika(counterId: string) {
if (document.getElementById(YM_SCRIPT_ID)) return
const script = document.createElement('script')
script.id = YM_SCRIPT_ID
script.type = 'text/javascript'
script.textContent = `
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym(${counterId}, "init", {
clickmap:true,
trackLinks:true,
accurateTrackBounce:true
});
`
document.head.appendChild(script)
}
function injectGoogleAds(conversionId: string) {
if (document.getElementById(GTAG_LOADER_ID)) return
// External gtag.js loader
const loader = document.createElement('script')
loader.id = GTAG_LOADER_ID
loader.async = true
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`
document.head.appendChild(loader)
// Init script
const init = document.createElement('script')
init.id = GTAG_INIT_ID
init.textContent = `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${conversionId}');
`
document.head.appendChild(init)
}
/**
* Fetches analytics counter settings from the API and dynamically
* injects Yandex Metrika and/or Google Ads scripts into <head>.
*/
export function useAnalyticsCounters() {
const { data } = useQuery({
queryKey: ['analytics-counters'],
queryFn: brandingApi.getAnalyticsCounters,
staleTime: 5 * 60 * 1000, // 5 min
})
useEffect(() => {
if (!data) return
// Yandex Metrika
if (data.yandex_metrika_id) {
injectYandexMetrika(data.yandex_metrika_id)
} else {
removeElement(YM_SCRIPT_ID)
}
// Google Ads
if (data.google_ads_id) {
injectGoogleAds(data.google_ads_id)
} else {
removeElement(GTAG_LOADER_ID)
removeElement(GTAG_INIT_ID)
}
}, [data])
}

View File

@@ -101,14 +101,22 @@ export function useTelegramWebApp() {
}, [isFullscreen, requestFullscreen, exitFullscreen])
const disableVerticalSwipes = useCallback(() => {
if (webApp?.disableVerticalSwipes) {
webApp.disableVerticalSwipes()
try {
if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.disableVerticalSwipes()
}
} catch {
// Not supported in this version
}
}, [webApp])
const enableVerticalSwipes = useCallback(() => {
if (webApp?.enableVerticalSwipes) {
webApp.enableVerticalSwipes()
try {
if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.enableVerticalSwipes()
}
} catch {
// Not supported in this version
}
}, [webApp])
@@ -140,9 +148,13 @@ export function initTelegramWebApp() {
webApp.ready()
webApp.expand()
// Disable vertical swipes to prevent accidental closing
if (webApp.disableVerticalSwipes) {
webApp.disableVerticalSwipes()
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
try {
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.disableVerticalSwipes()
}
} catch {
// Swipe control not supported in this version
}
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)

View File

@@ -753,7 +753,25 @@
"searchPlaceholder": "Search settings...",
"searchCategoriesPlaceholder": "Search categories...",
"noSearchResults": "Nothing found",
"example": "Example"
"example": "Example",
"analytics": "Analytics",
"notConfigured": "Not configured",
"counterActive": "Active",
"counterInactive": "Inactive",
"yandexMetrika": "Yandex Metrika",
"yandexMetrikaDesc": "Track visitors and analyze website behavior",
"counterId": "Counter ID",
"yandexIdPlaceholder": "e.g. 87654321",
"yandexIdHint": "Numeric ID from metrika.yandex.ru",
"googleAds": "Google Ads",
"googleAdsDesc": "Conversion tracking for ad campaigns",
"conversionId": "Conversion ID",
"googleIdPlaceholder": "e.g. AW-123456789",
"googleIdHint": "Conversion ID in AW-XXXXXXXXX format",
"conversionLabel": "Conversion Label (optional)",
"googleLabelPlaceholder": "e.g. AbCdEfGhIjKl",
"googleLabelHint": "Conversion label for event tracking",
"analyticsHint": "Counters are automatically embedded on all cabinet pages. After saving an ID, scripts are loaded on the next page load. To remove a counter, clear the field and save."
},
"apps": {
"title": "App Management",

View File

@@ -506,7 +506,25 @@
"searchPlaceholder": "جستجوی تنظیمات...",
"searchCategoriesPlaceholder": "جستجوی دسته‌ها...",
"noSearchResults": "چیزی یافت نشد",
"example": "مثال"
"example": "مثال",
"analytics": "تحلیل‌ها",
"notConfigured": "پیکربندی نشده",
"counterActive": "فعال",
"counterInactive": "غیرفعال",
"yandexMetrika": "Yandex Metrika",
"yandexMetrikaDesc": "ردیابی بازدیدکنندگان و تحلیل رفتار وب‌سایت",
"counterId": "شناسه شمارنده",
"yandexIdPlaceholder": "مثال: 87654321",
"yandexIdHint": "شناسه عددی از metrika.yandex.ru",
"googleAds": "Google Ads",
"googleAdsDesc": "ردیابی تبدیل برای کمپین‌های تبلیغاتی",
"conversionId": "شناسه تبدیل",
"googleIdPlaceholder": "مثال: AW-123456789",
"googleIdHint": "شناسه تبدیل با فرمت AW-XXXXXXXXX",
"conversionLabel": "برچسب تبدیل (اختیاری)",
"googleLabelPlaceholder": "مثال: AbCdEfGhIjKl",
"googleLabelHint": "برچسب تبدیل برای ردیابی رویدادها",
"analyticsHint": "شمارنده‌ها به‌صورت خودکار در تمام صفحات تعبیه می‌شوند. پس از ذخیره شناسه، اسکریپت‌ها در بارگذاری بعدی صفحه فعال می‌شوند. برای حذف شمارنده، فیلد را خالی کنید و ذخیره کنید."
},
"apps": {
"title": "مدیریت برنامه‌ها",

View File

@@ -772,6 +772,7 @@
"favoritesHint": "Нажмите ⭐ рядом с настройкой, чтобы добавить её сюда",
"branding": "Брендинг",
"theme": "Тема",
"analytics": "Аналитика",
"payments": "Платежи",
"subscriptions": "Подписки",
"interface": "Интерфейс",
@@ -797,6 +798,23 @@
"statusColors": "Статусные цвета",
"resetAllColors": "Сбросить все цвета",
"notSpecified": "Не указано",
"notConfigured": "Не настроено",
"counterActive": "Активен",
"counterInactive": "Не активен",
"yandexMetrika": "Яндекс Метрика",
"yandexMetrikaDesc": "Отслеживание посетителей и анализ поведения на сайте",
"counterId": "ID счётчика",
"yandexIdPlaceholder": "Например: 87654321",
"yandexIdHint": "Числовой ID из metrika.yandex.ru",
"googleAds": "Google Ads",
"googleAdsDesc": "Отслеживание конверсий для рекламных кампаний",
"conversionId": "Conversion ID",
"googleIdPlaceholder": "Например: AW-123456789",
"googleIdHint": "ID конверсии в формате AW-XXXXXXXXX",
"conversionLabel": "Conversion Label (необязательно)",
"googleLabelPlaceholder": "Например: AbCdEfGhIjKl",
"googleLabelHint": "Метка конверсии для отслеживания событий",
"analyticsHint": "Счётчики автоматически встраиваются на все страницы кабинета. После сохранения ID скрипты подключаются при следующей загрузке страницы. Для удаления счётчика очистите поле и сохраните.",
"presets": {
"standard": "Стандарт",
"ocean": "Океан",

View File

@@ -541,7 +541,25 @@
"searchPlaceholder": "搜索设置...",
"searchCategoriesPlaceholder": "搜索分类...",
"noSearchResults": "未找到结果",
"example": "示例"
"example": "示例",
"analytics": "分析",
"notConfigured": "未配置",
"counterActive": "已启用",
"counterInactive": "未启用",
"yandexMetrika": "Yandex Metrika",
"yandexMetrikaDesc": "跟踪访客并分析网站行为",
"counterId": "计数器 ID",
"yandexIdPlaceholder": "例如: 87654321",
"yandexIdHint": "metrika.yandex.ru 上的数字 ID",
"googleAds": "Google Ads",
"googleAdsDesc": "广告活动的转化跟踪",
"conversionId": "转化 ID",
"googleIdPlaceholder": "例如: AW-123456789",
"googleIdHint": "AW-XXXXXXXXX 格式的转化 ID",
"conversionLabel": "转化标签(可选)",
"googleLabelPlaceholder": "例如: AbCdEfGhIjKl",
"googleLabelHint": "用于事件跟踪的转化标签",
"analyticsHint": "计数器自动嵌入所有页面。保存 ID 后,脚本将在下次页面加载时生效。要移除计数器,请清空字段并保存。"
},
"apps": {
"title": "应用管理",

View File

@@ -85,20 +85,20 @@ function TemplateCard({
return (
<button
onClick={onClick}
className="w-full text-left p-4 bg-dark-800 rounded-xl border border-dark-700 hover:border-accent-500/50 transition-all duration-200 group"
className="w-full text-left p-3 sm:p-4 bg-dark-800 rounded-xl border border-dark-700 hover:border-accent-500/50 transition-all duration-200 group"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-2 sm:gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-dark-100 group-hover:text-accent-400 transition-colors truncate">
{label}
</h3>
<p className="text-xs text-dark-400 mt-1 line-clamp-2">{description}</p>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 mt-0.5">
<div className="flex items-center gap-1 sm:gap-1.5 flex-shrink-0 mt-0.5">
{Object.entries(template.languages).map(([lang, status]) => (
<span
key={lang}
className={`inline-flex items-center justify-center w-7 h-5 rounded text-2xs font-medium ${
className={`inline-flex items-center justify-center w-6 sm:w-7 h-5 rounded text-2xs font-medium ${
status.has_custom
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-700 text-dark-400'
@@ -270,29 +270,29 @@ function TemplateEditor({
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type
return (
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={onClose} className="p-1 rounded-lg hover:bg-dark-700 transition-colors">
<div className="flex items-start sm:items-center justify-between gap-2">
<div className="flex items-start sm:items-center gap-2 sm:gap-3 min-w-0">
<button onClick={onClose} className="p-1 rounded-lg hover:bg-dark-700 transition-colors flex-shrink-0 mt-0.5 sm:mt-0">
<BackIcon />
</button>
<div>
<h2 className="text-lg font-semibold text-dark-100">{label}</h2>
<p className="text-xs text-dark-400">
<div className="min-w-0">
<h2 className="text-base sm:text-lg font-semibold text-dark-100 truncate">{label}</h2>
<p className="text-xs text-dark-400 line-clamp-2">
{detail.description[interfaceLang] || detail.description['en'] || ''}
</p>
</div>
</div>
{langData && !langData.is_default && (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/25">
<span className="px-2 sm:px-2.5 py-1 rounded-full text-2xs sm:text-xs font-medium bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/25 flex-shrink-0">
Custom
</span>
)}
</div>
{/* Language tabs */}
<div className="flex items-center gap-1 p-1 bg-dark-900 rounded-lg">
<div className="flex items-center gap-1 p-1 bg-dark-900 rounded-lg overflow-x-auto">
{Object.keys(detail.languages).map(lang => {
const isActive = lang === activeLang
const langInfo = detail.languages[lang]
@@ -303,13 +303,14 @@ function TemplateEditor({
if (isDirty && !window.confirm(t('admin.emailTemplates.unsavedWarning', 'Unsaved changes will be lost. Continue?'))) return
setActiveLang(lang)
}}
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150 flex items-center justify-center gap-1.5 ${
className={`flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-all duration-150 flex items-center justify-center gap-1 sm:gap-1.5 whitespace-nowrap ${
isActive
? 'bg-dark-700 text-dark-100 shadow-sm'
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800'
}`}
>
{LANG_FULL_LABELS[lang] || lang}
<span className="sm:hidden">{LANG_LABELS[lang] || lang}</span>
<span className="hidden sm:inline">{LANG_FULL_LABELS[lang] || lang}</span>
{!langInfo.is_default && (
<span className="w-1.5 h-1.5 rounded-full bg-accent-400 flex-shrink-0" />
)}
@@ -334,11 +335,11 @@ function TemplateEditor({
{/* Context variables hint */}
{detail.context_vars.length > 0 && (
<div className="p-3 bg-dark-900/60 border border-dark-700 rounded-lg">
<div className="p-2.5 sm:p-3 bg-dark-900/60 border border-dark-700 rounded-lg">
<p className="text-xs font-medium text-dark-300 mb-1.5">
{t('admin.emailTemplates.variables', 'Available Variables')}
</p>
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap gap-1 sm:gap-1.5">
{detail.context_vars.map(v => (
<code
key={v}
@@ -365,8 +366,8 @@ function TemplateEditor({
ref={textareaRef}
value={editBody}
onChange={e => handleBodyChange(e.target.value)}
rows={16}
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-sm text-dark-100 placeholder-dark-500 font-mono leading-relaxed focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors resize-y"
rows={12}
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-xs sm:text-sm text-dark-100 placeholder-dark-500 font-mono leading-relaxed focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors resize-y min-h-[200px] sm:min-h-[300px]"
placeholder="<h2>Title</h2><p>Content...</p>"
spellCheck={false}
/>
@@ -376,53 +377,57 @@ function TemplateEditor({
</div>
{/* Actions */}
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => saveMutation.mutate()}
disabled={!isDirty || saveMutation.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<SaveIcon />
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
</button>
<button
onClick={() => previewMutation.mutate()}
disabled={previewMutation.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
>
<EyeIcon />
{t('admin.emailTemplates.preview', 'Preview')}
</button>
<button
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
>
<SendIcon />
{testMutation.isPending ? t('common.loading', 'Loading...') : t('admin.emailTemplates.sendTest', 'Send Test')}
</button>
{langData && !langData.is_default && (
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2">
<div className="grid grid-cols-2 sm:flex gap-2">
<button
onClick={() => {
if (window.confirm(t('admin.emailTemplates.resetConfirm', 'Reset this template to the default version?'))) {
resetMutation.mutate()
}
}}
disabled={resetMutation.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-dark-700 text-warning-400 hover:bg-dark-600 disabled:opacity-40 transition-colors ml-auto"
onClick={() => saveMutation.mutate()}
disabled={!isDirty || saveMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ResetIcon />
{t('admin.emailTemplates.resetDefault', 'Reset to Default')}
<SaveIcon />
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
</button>
)}
<button
onClick={() => previewMutation.mutate()}
disabled={previewMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
>
<EyeIcon />
{t('admin.emailTemplates.preview', 'Preview')}
</button>
</div>
<div className="grid grid-cols-2 sm:flex gap-2">
<button
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
>
<SendIcon />
{testMutation.isPending ? t('common.loading', 'Loading...') : t('admin.emailTemplates.sendTest', 'Send Test')}
</button>
{langData && !langData.is_default && (
<button
onClick={() => {
if (window.confirm(t('admin.emailTemplates.resetConfirm', 'Reset this template to the default version?'))) {
resetMutation.mutate()
}
}}
disabled={resetMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-warning-400 hover:bg-dark-600 disabled:opacity-40 transition-colors sm:ml-auto"
>
<ResetIcon />
<span className="truncate">{t('admin.emailTemplates.resetDefault', 'Reset to Default')}</span>
</button>
)}
</div>
</div>
{/* Toast */}
{toast && (
<div className={`fixed bottom-6 right-6 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg animate-fade-in ${
<div className={`fixed bottom-4 left-4 right-4 sm:left-auto sm:right-6 sm:bottom-6 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg animate-fade-in text-center sm:text-left ${
toast.type === 'success'
? 'bg-emerald-500/90 text-white'
: 'bg-red-500/90 text-white'
@@ -433,9 +438,9 @@ function TemplateEditor({
{/* Preview Modal */}
{showPreview && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-dark-800 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-700">
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center sm:p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-dark-800 rounded-t-2xl sm:rounded-2xl w-full sm:max-w-2xl max-h-[90vh] sm:max-h-[85vh] flex flex-col border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between px-4 sm:px-5 py-3 sm:py-4 border-b border-dark-700">
<h3 className="text-base font-semibold text-dark-100">
{t('admin.emailTemplates.preview', 'Preview')}
</h3>
@@ -449,7 +454,7 @@ function TemplateEditor({
<div className="flex-1 overflow-hidden p-1">
<iframe
ref={iframeRef}
className="w-full h-full min-h-[400px] rounded-lg bg-white"
className="w-full h-full min-h-[50vh] sm:min-h-[400px] rounded-lg bg-white"
sandbox="allow-same-origin"
title="Email Preview"
/>
@@ -482,24 +487,24 @@ export default function AdminEmailTemplates() {
})
return (
<div className="max-w-4xl mx-auto px-4 py-6 space-y-6">
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6">
{/* Page Header */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 sm:gap-3">
<Link
to="/admin"
className="p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors border border-dark-700"
className="p-1.5 sm:p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors border border-dark-700 flex-shrink-0"
>
<BackIcon />
</Link>
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 text-blue-400">
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0">
<div className="p-1.5 sm:p-2 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 text-blue-400 flex-shrink-0">
<MailIcon />
</div>
<div>
<h1 className="text-xl font-bold text-dark-100">
<div className="min-w-0">
<h1 className="text-lg sm:text-xl font-bold text-dark-100 truncate">
{t('admin.emailTemplates.title', 'Email Templates')}
</h1>
<p className="text-xs text-dark-400">
<p className="text-xs text-dark-400 truncate">
{t('admin.emailTemplates.description', 'Manage email notification templates')}
</p>
</div>
@@ -523,7 +528,7 @@ export default function AdminEmailTemplates() {
))}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2 sm:gap-3 grid-cols-1 sm:grid-cols-2">
{typesData?.items.map(template => (
<TemplateCard
key={template.type}

View File

@@ -10,6 +10,7 @@ import {
MenuItem,
formatSettingKey
} from '../components/admin'
import { AnalyticsTab } from '../components/admin/AnalyticsTab'
import { BrandingTab } from '../components/admin/BrandingTab'
import { ThemeTab } from '../components/admin/ThemeTab'
import { FavoritesTab } from '../components/admin/FavoritesTab'
@@ -132,6 +133,8 @@ export default function AdminSettings() {
}
switch (activeSection) {
case 'analytics':
return <AnalyticsTab />
case 'branding':
return <BrandingTab accentColor={themeColors?.accent} />
case 'theme':

View File

@@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { DotLottieReact } from '@lottiefiles/dotlottie-react'
import { useAuthStore } from '../store/auth'
import { subscriptionApi } from '../api/subscription'
import { referralApi } from '../api/referral'
@@ -38,33 +37,11 @@ const RefreshIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
</svg>
)
// Check if device might be low-performance (Telegram WebApp on mobile)
const isLowPerfDevice = (() => {
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
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>
)
}
const SupportLottieIcon = () => (
<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>
)
export default function Dashboard() {
const { t } = useTranslation()