mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
294
src/components/admin/AnalyticsTab.tsx
Normal file
294
src/components/admin/AnalyticsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
84
src/hooks/useAnalyticsCounters.ts
Normal file
84
src/hooks/useAnalyticsCounters.ts
Normal 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])
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "مدیریت برنامهها",
|
||||
|
||||
@@ -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": "Океан",
|
||||
|
||||
@@ -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": "应用管理",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user