mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" href="data:," />
|
<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="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="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="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
<title>Loading...</title>
|
<title>Loading...</title>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Layout from './components/layout/Layout'
|
|||||||
import PageLoader from './components/common/PageLoader'
|
import PageLoader from './components/common/PageLoader'
|
||||||
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking'
|
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking'
|
||||||
import { saveReturnUrl } from './utils/token'
|
import { saveReturnUrl } from './utils/token'
|
||||||
|
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'
|
||||||
|
|
||||||
// Auth pages - load immediately (small)
|
// Auth pages - load immediately (small)
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
@@ -109,6 +110,8 @@ function BlockingOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
useAnalyticsCounters()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BlockingOverlay />
|
<BlockingOverlay />
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export interface EmailAuthEnabled {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsCounters {
|
||||||
|
yandex_metrika_id: string
|
||||||
|
google_ads_id: string
|
||||||
|
google_ads_label: string
|
||||||
|
}
|
||||||
|
|
||||||
const BRANDING_CACHE_KEY = 'cabinet_branding'
|
const BRANDING_CACHE_KEY = 'cabinet_branding'
|
||||||
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
|
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 })
|
const response = await apiClient.patch<EmailAuthEnabled>('/cabinet/branding/email-auth', { enabled })
|
||||||
return response.data
|
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: 'favorites', iconType: 'star' },
|
||||||
{ id: 'branding', iconType: null },
|
{ id: 'branding', iconType: null },
|
||||||
{ id: 'theme', iconType: null },
|
{ id: 'theme', iconType: null },
|
||||||
|
{ id: 'analytics', iconType: null },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * from './icons'
|
|||||||
export * from './Toggle'
|
export * from './Toggle'
|
||||||
export * from './SettingInput'
|
export * from './SettingInput'
|
||||||
export * from './SettingRow'
|
export * from './SettingRow'
|
||||||
|
export * from './AnalyticsTab'
|
||||||
export * from './BrandingTab'
|
export * from './BrandingTab'
|
||||||
export * from './ThemeTab'
|
export * from './ThemeTab'
|
||||||
export * from './FavoritesTab'
|
export * from './FavoritesTab'
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { DotLottieReact } from '@lottiefiles/dotlottie-react'
|
|
||||||
|
|
||||||
interface PageLoaderProps {
|
interface PageLoaderProps {
|
||||||
variant?: 'dark' | 'light'
|
variant?: 'dark' | 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
|
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
|
||||||
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50'
|
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50'
|
||||||
|
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex items-center justify-center ${bgClass}`}>
|
<div className={`min-h-screen flex items-center justify-center ${bgClass}`}>
|
||||||
<div className="w-48 max-w-full">
|
<div className={`w-10 h-10 border-[3px] ${spinnerColor} border-t-transparent rounded-full animate-spin`} />
|
||||||
<DotLottieReact
|
|
||||||
src="https://lottie.host/14b9dc34-cdaf-408c-87ea-291c1b01e343/r2rZZVuahg.lottie"
|
|
||||||
loop
|
|
||||||
autoplay
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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])
|
}, [isFullscreen, requestFullscreen, exitFullscreen])
|
||||||
|
|
||||||
const disableVerticalSwipes = useCallback(() => {
|
const disableVerticalSwipes = useCallback(() => {
|
||||||
if (webApp?.disableVerticalSwipes) {
|
try {
|
||||||
webApp.disableVerticalSwipes()
|
if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||||
|
webApp.disableVerticalSwipes()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not supported in this version
|
||||||
}
|
}
|
||||||
}, [webApp])
|
}, [webApp])
|
||||||
|
|
||||||
const enableVerticalSwipes = useCallback(() => {
|
const enableVerticalSwipes = useCallback(() => {
|
||||||
if (webApp?.enableVerticalSwipes) {
|
try {
|
||||||
webApp.enableVerticalSwipes()
|
if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||||
|
webApp.enableVerticalSwipes()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not supported in this version
|
||||||
}
|
}
|
||||||
}, [webApp])
|
}, [webApp])
|
||||||
|
|
||||||
@@ -140,9 +148,13 @@ export function initTelegramWebApp() {
|
|||||||
webApp.ready()
|
webApp.ready()
|
||||||
webApp.expand()
|
webApp.expand()
|
||||||
|
|
||||||
// Disable vertical swipes to prevent accidental closing
|
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
|
||||||
if (webApp.disableVerticalSwipes) {
|
try {
|
||||||
webApp.disableVerticalSwipes()
|
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)
|
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
|
||||||
|
|||||||
@@ -753,7 +753,25 @@
|
|||||||
"searchPlaceholder": "Search settings...",
|
"searchPlaceholder": "Search settings...",
|
||||||
"searchCategoriesPlaceholder": "Search categories...",
|
"searchCategoriesPlaceholder": "Search categories...",
|
||||||
"noSearchResults": "Nothing found",
|
"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": {
|
"apps": {
|
||||||
"title": "App Management",
|
"title": "App Management",
|
||||||
|
|||||||
@@ -506,7 +506,25 @@
|
|||||||
"searchPlaceholder": "جستجوی تنظیمات...",
|
"searchPlaceholder": "جستجوی تنظیمات...",
|
||||||
"searchCategoriesPlaceholder": "جستجوی دستهها...",
|
"searchCategoriesPlaceholder": "جستجوی دستهها...",
|
||||||
"noSearchResults": "چیزی یافت نشد",
|
"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": {
|
"apps": {
|
||||||
"title": "مدیریت برنامهها",
|
"title": "مدیریت برنامهها",
|
||||||
|
|||||||
@@ -772,6 +772,7 @@
|
|||||||
"favoritesHint": "Нажмите ⭐ рядом с настройкой, чтобы добавить её сюда",
|
"favoritesHint": "Нажмите ⭐ рядом с настройкой, чтобы добавить её сюда",
|
||||||
"branding": "Брендинг",
|
"branding": "Брендинг",
|
||||||
"theme": "Тема",
|
"theme": "Тема",
|
||||||
|
"analytics": "Аналитика",
|
||||||
"payments": "Платежи",
|
"payments": "Платежи",
|
||||||
"subscriptions": "Подписки",
|
"subscriptions": "Подписки",
|
||||||
"interface": "Интерфейс",
|
"interface": "Интерфейс",
|
||||||
@@ -797,6 +798,23 @@
|
|||||||
"statusColors": "Статусные цвета",
|
"statusColors": "Статусные цвета",
|
||||||
"resetAllColors": "Сбросить все цвета",
|
"resetAllColors": "Сбросить все цвета",
|
||||||
"notSpecified": "Не указано",
|
"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": {
|
"presets": {
|
||||||
"standard": "Стандарт",
|
"standard": "Стандарт",
|
||||||
"ocean": "Океан",
|
"ocean": "Океан",
|
||||||
|
|||||||
@@ -541,7 +541,25 @@
|
|||||||
"searchPlaceholder": "搜索设置...",
|
"searchPlaceholder": "搜索设置...",
|
||||||
"searchCategoriesPlaceholder": "搜索分类...",
|
"searchCategoriesPlaceholder": "搜索分类...",
|
||||||
"noSearchResults": "未找到结果",
|
"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": {
|
"apps": {
|
||||||
"title": "应用管理",
|
"title": "应用管理",
|
||||||
|
|||||||
@@ -85,20 +85,20 @@ function TemplateCard({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-medium text-dark-100 group-hover:text-accent-400 transition-colors truncate">
|
<h3 className="text-sm font-medium text-dark-100 group-hover:text-accent-400 transition-colors truncate">
|
||||||
{label}
|
{label}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-dark-400 mt-1 line-clamp-2">{description}</p>
|
<p className="text-xs text-dark-400 mt-1 line-clamp-2">{description}</p>
|
||||||
</div>
|
</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]) => (
|
{Object.entries(template.languages).map(([lang, status]) => (
|
||||||
<span
|
<span
|
||||||
key={lang}
|
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
|
status.has_custom
|
||||||
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30'
|
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30'
|
||||||
: 'bg-dark-700 text-dark-400'
|
: 'bg-dark-700 text-dark-400'
|
||||||
@@ -270,29 +270,29 @@ function TemplateEditor({
|
|||||||
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type
|
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-start sm:items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<button onClick={onClose} className="p-1 rounded-lg hover:bg-dark-700 transition-colors flex-shrink-0 mt-0.5 sm:mt-0">
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-lg font-semibold text-dark-100">{label}</h2>
|
<h2 className="text-base sm:text-lg font-semibold text-dark-100 truncate">{label}</h2>
|
||||||
<p className="text-xs text-dark-400">
|
<p className="text-xs text-dark-400 line-clamp-2">
|
||||||
{detail.description[interfaceLang] || detail.description['en'] || ''}
|
{detail.description[interfaceLang] || detail.description['en'] || ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{langData && !langData.is_default && (
|
{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
|
Custom
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Language tabs */}
|
{/* 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 => {
|
{Object.keys(detail.languages).map(lang => {
|
||||||
const isActive = lang === activeLang
|
const isActive = lang === activeLang
|
||||||
const langInfo = detail.languages[lang]
|
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
|
if (isDirty && !window.confirm(t('admin.emailTemplates.unsavedWarning', 'Unsaved changes will be lost. Continue?'))) return
|
||||||
setActiveLang(lang)
|
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
|
isActive
|
||||||
? 'bg-dark-700 text-dark-100 shadow-sm'
|
? 'bg-dark-700 text-dark-100 shadow-sm'
|
||||||
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800'
|
: '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 && (
|
{!langInfo.is_default && (
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-accent-400 flex-shrink-0" />
|
<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 */}
|
{/* Context variables hint */}
|
||||||
{detail.context_vars.length > 0 && (
|
{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">
|
<p className="text-xs font-medium text-dark-300 mb-1.5">
|
||||||
{t('admin.emailTemplates.variables', 'Available Variables')}
|
{t('admin.emailTemplates.variables', 'Available Variables')}
|
||||||
</p>
|
</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 => (
|
{detail.context_vars.map(v => (
|
||||||
<code
|
<code
|
||||||
key={v}
|
key={v}
|
||||||
@@ -365,8 +366,8 @@ function TemplateEditor({
|
|||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={editBody}
|
value={editBody}
|
||||||
onChange={e => handleBodyChange(e.target.value)}
|
onChange={e => handleBodyChange(e.target.value)}
|
||||||
rows={16}
|
rows={12}
|
||||||
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"
|
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>"
|
placeholder="<h2>Title</h2><p>Content...</p>"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
@@ -376,53 +377,57 @@ function TemplateEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2">
|
||||||
<button
|
<div className="grid grid-cols-2 sm:flex gap-2">
|
||||||
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 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => saveMutation.mutate()}
|
||||||
if (window.confirm(t('admin.emailTemplates.resetConfirm', 'Reset this template to the default version?'))) {
|
disabled={!isDirty || saveMutation.isPending}
|
||||||
resetMutation.mutate()
|
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"
|
||||||
}
|
|
||||||
}}
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<ResetIcon />
|
<SaveIcon />
|
||||||
{t('admin.emailTemplates.resetDefault', 'Reset to Default')}
|
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{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'
|
toast.type === 'success'
|
||||||
? 'bg-emerald-500/90 text-white'
|
? 'bg-emerald-500/90 text-white'
|
||||||
: 'bg-red-500/90 text-white'
|
: 'bg-red-500/90 text-white'
|
||||||
@@ -433,9 +438,9 @@ function TemplateEditor({
|
|||||||
|
|
||||||
{/* Preview Modal */}
|
{/* Preview Modal */}
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
<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-2xl w-full max-w-2xl max-h-[85vh] flex flex-col border border-dark-600 shadow-2xl">
|
<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-5 py-4 border-b border-dark-700">
|
<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">
|
<h3 className="text-base font-semibold text-dark-100">
|
||||||
{t('admin.emailTemplates.preview', 'Preview')}
|
{t('admin.emailTemplates.preview', 'Preview')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -449,7 +454,7 @@ function TemplateEditor({
|
|||||||
<div className="flex-1 overflow-hidden p-1">
|
<div className="flex-1 overflow-hidden p-1">
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
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"
|
sandbox="allow-same-origin"
|
||||||
title="Email Preview"
|
title="Email Preview"
|
||||||
/>
|
/>
|
||||||
@@ -482,24 +487,24 @@ export default function AdminEmailTemplates() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Page Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
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 />
|
<BackIcon />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0">
|
||||||
<div className="p-2 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 text-blue-400">
|
<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 />
|
<MailIcon />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl font-bold text-dark-100">
|
<h1 className="text-lg sm:text-xl font-bold text-dark-100 truncate">
|
||||||
{t('admin.emailTemplates.title', 'Email Templates')}
|
{t('admin.emailTemplates.title', 'Email Templates')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-dark-400">
|
<p className="text-xs text-dark-400 truncate">
|
||||||
{t('admin.emailTemplates.description', 'Manage email notification templates')}
|
{t('admin.emailTemplates.description', 'Manage email notification templates')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,7 +528,7 @@ export default function AdminEmailTemplates() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 => (
|
{typesData?.items.map(template => (
|
||||||
<TemplateCard
|
<TemplateCard
|
||||||
key={template.type}
|
key={template.type}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
formatSettingKey
|
formatSettingKey
|
||||||
} from '../components/admin'
|
} from '../components/admin'
|
||||||
|
import { AnalyticsTab } from '../components/admin/AnalyticsTab'
|
||||||
import { BrandingTab } from '../components/admin/BrandingTab'
|
import { BrandingTab } from '../components/admin/BrandingTab'
|
||||||
import { ThemeTab } from '../components/admin/ThemeTab'
|
import { ThemeTab } from '../components/admin/ThemeTab'
|
||||||
import { FavoritesTab } from '../components/admin/FavoritesTab'
|
import { FavoritesTab } from '../components/admin/FavoritesTab'
|
||||||
@@ -132,6 +133,8 @@ export default function AdminSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
|
case 'analytics':
|
||||||
|
return <AnalyticsTab />
|
||||||
case 'branding':
|
case 'branding':
|
||||||
return <BrandingTab accentColor={themeColors?.accent} />
|
return <BrandingTab accentColor={themeColors?.accent} />
|
||||||
case 'theme':
|
case 'theme':
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, useRef } from 'react'
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { DotLottieReact } from '@lottiefiles/dotlottie-react'
|
|
||||||
import { useAuthStore } from '../store/auth'
|
import { useAuthStore } from '../store/auth'
|
||||||
import { subscriptionApi } from '../api/subscription'
|
import { subscriptionApi } from '../api/subscription'
|
||||||
import { referralApi } from '../api/referral'
|
import { referralApi } from '../api/referral'
|
||||||
@@ -38,33 +37,11 @@ const RefreshIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check if device might be low-performance (Telegram WebApp on mobile)
|
const SupportLottieIcon = () => (
|
||||||
const isLowPerfDevice = (() => {
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
|
<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" />
|
||||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
</svg>
|
||||||
return isTelegramWebApp && isMobile
|
)
|
||||||
})()
|
|
||||||
|
|
||||||
const SupportLottieIcon = () => {
|
|
||||||
// Use static icon on low-performance devices
|
|
||||||
if (isLowPerfDevice) {
|
|
||||||
return (
|
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-6 h-6">
|
|
||||||
<DotLottieReact
|
|
||||||
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
|
|
||||||
loop
|
|
||||||
autoplay
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
Reference in New Issue
Block a user