diff --git a/index.html b/index.html index e654320..35ef396 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - + Loading... diff --git a/src/App.tsx b/src/App.tsx index 6f371fd..be685c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( <> diff --git a/src/api/branding.ts b/src/api/branding.ts index d1f35e6..f9701f2 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -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('/cabinet/branding/email-auth', { enabled }) return response.data }, + + // Get analytics counters (public, no auth required) + getAnalyticsCounters: async (): Promise => { + try { + const response = await apiClient.get('/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): Promise => { + const response = await apiClient.patch('/cabinet/branding/analytics', data) + return response.data + }, } diff --git a/src/components/admin/AnalyticsTab.tsx b/src/components/admin/AnalyticsTab.tsx new file mode 100644 index 0000000..e63e9dd --- /dev/null +++ b/src/components/admin/AnalyticsTab.tsx @@ -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(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 ( +
+ {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Yandex Metrika */} +
+
+
+
+ + + +
+

+ {t('admin.settings.yandexMetrika')} +

+
+ + + {yandexActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')} + +
+

+ {t('admin.settings.yandexMetrikaDesc')} +

+ +
+ + {editingYandex ? ( +
+ 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 + /> + + +
+ ) : ( +
+ + {analytics?.yandex_metrika_id || t('admin.settings.notConfigured')} + + +
+ )} +

+ {t('admin.settings.yandexIdHint')} +

+
+
+ + {/* Google Ads */} +
+
+
+
+ + + +
+

+ {t('admin.settings.googleAds')} +

+
+ + + {googleActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')} + +
+

+ {t('admin.settings.googleAdsDesc')} +

+ +
+ {/* Conversion ID */} +
+ + {editingGoogleId ? ( +
+ 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 + /> + + +
+ ) : ( +
+ + {analytics?.google_ads_id || t('admin.settings.notConfigured')} + + +
+ )} +

+ {t('admin.settings.googleIdHint')} +

+
+ + {/* Conversion Label */} +
+ + {editingGoogleLabel ? ( +
+ 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 + /> + + +
+ ) : ( +
+ + {analytics?.google_ads_label || t('admin.settings.notConfigured')} + + +
+ )} +

+ {t('admin.settings.googleLabelHint')} +

+
+
+
+ + {/* Info block */} +
+

+ {t('admin.settings.analyticsHint')} +

+
+
+ ) +} diff --git a/src/components/admin/constants.ts b/src/components/admin/constants.ts index f686581..b74b3ca 100644 --- a/src/components/admin/constants.ts +++ b/src/components/admin/constants.ts @@ -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 }, ] }, { diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index 1d6357d..4be4e1f 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -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' diff --git a/src/components/common/PageLoader.tsx b/src/components/common/PageLoader.tsx index b5e7fb5..a1cb613 100644 --- a/src/components/common/PageLoader.tsx +++ b/src/components/common/PageLoader.tsx @@ -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 (
-
- -
+
) } diff --git a/src/hooks/useAnalyticsCounters.ts b/src/hooks/useAnalyticsCounters.ts new file mode 100644 index 0000000..d7f10f7 --- /dev/null +++ b/src/hooks/useAnalyticsCounters.ts @@ -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 . + */ +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]) +} diff --git a/src/hooks/useTelegramWebApp.ts b/src/hooks/useTelegramWebApp.ts index 4fc5b03..03c7f6a 100644 --- a/src/hooks/useTelegramWebApp.ts +++ b/src/hooks/useTelegramWebApp.ts @@ -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) diff --git a/src/locales/en.json b/src/locales/en.json index 218bfa5..7e26ac3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index a9f2143..ba8a1ce 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": "مدیریت برنامه‌ها", diff --git a/src/locales/ru.json b/src/locales/ru.json index 6211876..a8eda22 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": "Океан", diff --git a/src/locales/zh.json b/src/locales/zh.json index 2010326..d8978e5 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": "应用管理", diff --git a/src/pages/AdminEmailTemplates.tsx b/src/pages/AdminEmailTemplates.tsx index 44d6c15..b415d0f 100644 --- a/src/pages/AdminEmailTemplates.tsx +++ b/src/pages/AdminEmailTemplates.tsx @@ -85,20 +85,20 @@ function TemplateCard({ return ( -
-

{label}

-

+

+

{label}

+

{detail.description[interfaceLang] || detail.description['en'] || ''}

{langData && !langData.is_default && ( - + Custom )}
{/* Language tabs */} -
+
{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} + {LANG_LABELS[lang] || lang} + {LANG_FULL_LABELS[lang] || lang} {!langInfo.is_default && ( )} @@ -334,11 +335,11 @@ function TemplateEditor({ {/* Context variables hint */} {detail.context_vars.length > 0 && ( -
+

{t('admin.emailTemplates.variables', 'Available Variables')}

-
+
{detail.context_vars.map(v => ( 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="

Title

Content...

" spellCheck={false} /> @@ -376,53 +377,57 @@ function TemplateEditor({
{/* Actions */} -
- - - - - - - {langData && !langData.is_default && ( +
+
- )} + + +
+ +
+ + + {langData && !langData.is_default && ( + + )} +
{/* Toast */} {toast && ( -
-
-
+
+
+

{t('admin.emailTemplates.preview', 'Preview')}

@@ -449,7 +454,7 @@ function TemplateEditor({