feat(analytics): add site analytics with grouped navigation sections

- Add Analytics container: models, tasks, jobs, controllers, routes, services
- Add analytics config with section groups (structure, institute, content, services)
- Add database tables: analytics_sessions, analytics_hits, analytics_daily_stats
- Add frontend: Analytics page, section detail, chart, overview, navigation components
- Add consent banner and JS tracking service (sendBeacon + Inertia listener)
- Add permission sync for view_analytics, view_any_analytic
- Fix trailing slash URL matching in GetNavigationSectionsTask
- Fix event propagation on expand arrows in navigation sections
- Fix RecordVisitJob to not depend on dropped analytics_geo_cache table
- Replace plain text views/visitors with badge-style icons
- Group navigation sections by category with separate blocks
- Add config-driven non-CMS sections (faculties, divisions, DPO, journals)
- Remove Events, TV, Sveden from analytics sections
This commit is contained in:
F4ilji
2026-09-18 22:18:04 +05:00
parent 8af94388ab
commit d6c0f48d48
60 changed files with 2948 additions and 121 deletions
+132
View File
@@ -0,0 +1,132 @@
<template>
<DashboardLayout>
<template #header-title>Аналитика сайта</template>
<template #header-subtitle>Статистика посещений и активности пользователей</template>
<template #header-actions>
<div class="flex items-center gap-2">
<AnalyticsPeriodFilter :model-value="period" @update:model-value="changePeriod" />
<div class="relative" ref="settingsWrap">
<button
@click="showSettings = !showSettings"
class="p-2 text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover rounded-lg transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<div
v-if="showSettings"
class="absolute right-0 top-full mt-1 bg-layer border border-layer-line rounded-lg shadow-lg p-2 z-10 min-w-[160px]"
>
<button
@click="clearData"
class="w-full text-left px-3 py-2 text-xs text-red-600 hover:bg-red-50 rounded-md transition-colors"
>
Очистить данные
</button>
</div>
</div>
</div>
</template>
<FlashMessages />
<AnalyticsOverview :overview="overview" class="mb-6" />
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5 mb-6">
<AnalyticsChart :data="dailyStats" />
</div>
<!-- Groups -->
<div v-for="groupKey in groupKeys" :key="groupKey" class="bg-layer border border-layer-line rounded-lg shadow-xs p-5 mb-6">
<h3 class="text-sm font-semibold text-foreground mb-3">{{ navigation.groupLabels[groupKey] || groupKey }}</h3>
<AnalyticsNavigationSections :navigation="navigation" :period="period" :group="groupKey" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Топ страниц</h3>
<AnalyticsTopPages :pages="topPages" />
</div>
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Источники трафика</h3>
<AnalyticsReferrers :items="referrers" />
</div>
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Устройства</h3>
<AnalyticsDevices :devices="devices.devices" :browsers="devices.browsers" :oses="devices.oses" />
</div>
</div>
</DashboardLayout>
</template>
<script>
import DashboardLayout from './Components/DashboardLayout.vue';
import FlashMessages from './Components/shared/FlashMessages.vue';
import AnalyticsOverview from './Components/shared/AnalyticsOverview.vue';
import AnalyticsChart from './Components/shared/AnalyticsChart.vue';
import AnalyticsTopPages from './Components/shared/AnalyticsTopPages.vue';
import AnalyticsReferrers from './Components/shared/AnalyticsReferrers.vue';
import AnalyticsDevices from './Components/shared/AnalyticsDevices.vue';
import AnalyticsPeriodFilter from './Components/shared/AnalyticsPeriodFilter.vue';
import AnalyticsNavigationSections from './Components/shared/AnalyticsNavigationSections.vue';
export default {
name: 'Analytics',
components: {
DashboardLayout,
FlashMessages,
AnalyticsOverview,
AnalyticsChart,
AnalyticsTopPages,
AnalyticsReferrers,
AnalyticsDevices,
AnalyticsPeriodFilter,
AnalyticsNavigationSections,
},
props: {
overview: { type: Object, required: true },
topPages: { type: Array, required: true },
dailyStats: { type: Array, required: true },
referrers: { type: Array, required: true },
devices: { type: Object, required: true },
navigation: { type: Object, required: true },
period: { type: Number, default: 30 },
},
data() {
return { showSettings: false };
},
computed: {
groupKeys() {
const keys = new Set(this.navigation.sections.map(s => s.group).filter(Boolean));
return [...keys];
},
},
mounted() {
this.SET_DOCUMENT_TITLE('Аналитика');
document.addEventListener('click', this.handleClickOutside);
},
beforeUnmount() {
document.removeEventListener('click', this.handleClickOutside);
},
methods: {
changePeriod(days) {
this.$inertia.get(route('dashboard.analytics.index'), { days }, { preserveState: true });
},
clearData() {
this.showSettings = false;
if (confirm('Удалить все данные аналитики? Это действие необратимо.')) {
this.$inertia.post(route('dashboard.analytics.clear'));
}
},
handleClickOutside(e) {
if (this.$refs.settingsWrap && !this.$refs.settingsWrap.contains(e.target)) {
this.showSettings = false;
}
},
},
};
</script>
@@ -0,0 +1,184 @@
<template>
<DashboardLayout>
<template #header-title>{{ section.label }}</template>
<template #header-subtitle>Статистика раздела</template>
<template #header-actions>
<div class="flex items-center gap-2">
<AnalyticsPeriodFilter :model-value="period" @update:model-value="changePeriod" />
<a
:href="route('dashboard.analytics.index', { days: period })"
class="px-3 py-1.5 text-xs font-medium text-muted-foreground-1 hover:text-foreground border border-layer-line rounded-md transition-colors"
>
Назад
</a>
</div>
</template>
<FlashMessages />
<!-- Breadcrumbs -->
<nav v-if="section.breadcrumbs && section.breadcrumbs.length" class="flex items-center gap-1.5 mb-4 text-xs text-muted-foreground-1">
<a :href="section.breadcrumbs[0].url" class="hover:text-primary transition-colors">{{ section.breadcrumbs[0].title }}</a>
<span v-for="(crumb, i) in section.breadcrumbs.slice(1)" :key="i" class="flex items-center gap-1.5">
<span>|</span>
<a :href="crumb.url" class="hover:text-primary transition-colors">{{ crumb.title }}</a>
</span>
<span class="flex items-center gap-1.5">
<span>/</span>
<span class="text-foreground font-medium">{{ section.label }}</span>
</span>
</nav>
<!-- Children (main_section sub_sections, sub_section pages) -->
<div v-if="section.children && section.children.length" class="bg-layer border border-layer-line rounded-lg shadow-xs p-5 mb-6">
<h3 class="text-sm font-semibold text-foreground mb-3">
{{ section.type === 'main_section' ? 'Подразделы' : 'Страницы' }}
</h3>
<div class="space-y-1">
<a
v-for="child in section.children"
:key="child.id"
:href="sectionUrl(child.prefix || child.url)"
class="relative flex items-center justify-between py-2 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getChildWidth(child.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative min-w-0">
<span class="text-xs font-medium text-foreground truncate">{{ child.title }}</span>
<span v-if="child.pages_count" class="text-[10px] text-muted-foreground-2">({{ child.pages_count }} стр.)</span>
</div>
<div class="flex items-center gap-1.5 relative flex-shrink-0">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{{ child.views }}
</span>
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
{{ child.visitors }}
</span>
<svg class="w-3 h-3 text-muted-foreground-2 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</a>
</div>
</div>
<!-- Summary -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-layer border border-layer-line rounded-lg p-5 shadow-xs">
<p class="text-2xl font-semibold text-foreground">{{ formatNumber(section.total_views) }}</p>
<p class="text-xs text-muted-foreground-1">Просмотров</p>
</div>
<div class="bg-layer border border-layer-line rounded-lg p-5 shadow-xs">
<p class="text-2xl font-semibold text-foreground">{{ formatNumber(section.unique_visitors) }}</p>
<p class="text-xs text-muted-foreground-1">Уникальных посетителей</p>
</div>
</div>
<!-- Chart -->
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5 mb-6">
<AnalyticsChart :data="section.daily_stats" />
</div>
<!-- Widgets -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Источники трафика</h3>
<AnalyticsReferrers :items="section.referrers" />
</div>
<div class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Устройства</h3>
<div class="space-y-2">
<div v-for="(count, type) in section.devices" :key="type" class="flex items-center justify-between">
<span class="text-xs font-medium text-foreground">{{ deviceLabel(type) }}</span>
<div class="flex items-center gap-2">
<div class="w-20 h-1.5 bg-muted-hover rounded-full overflow-hidden">
<div class="h-full bg-primary/60 rounded-full" :style="{ width: getDevicePercent(count) + '%' }"></div>
</div>
<span class="text-[11px] text-muted-foreground-1 w-8 text-right">{{ count }}</span>
</div>
</div>
</div>
</div>
<!-- Exit pages (only for page type) -->
<div v-if="section.type === 'page' && section.exit_pages && section.exit_pages.length" class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Куда ушли</h3>
<div class="space-y-1.5">
<div v-for="ep in section.exit_pages" :key="ep.url" class="flex items-center justify-between">
<span class="text-[11px] font-medium text-foreground truncate">{{ ep.url }}</span>
<span class="text-[11px] text-muted-foreground-1">{{ ep.count }}</span>
</div>
</div>
</div>
<!-- Top pages (not for page type) -->
<div v-if="section.type !== 'page' && section.top_pages && section.top_pages.length" class="bg-layer border border-layer-line rounded-lg shadow-xs p-5">
<h3 class="text-sm font-semibold text-foreground mb-3">Топ страниц</h3>
<AnalyticsTopPages :pages="section.top_pages" />
</div>
</div>
</DashboardLayout>
</template>
<script>
import DashboardLayout from './Components/DashboardLayout.vue';
import FlashMessages from './Components/shared/FlashMessages.vue';
import AnalyticsChart from './Components/shared/AnalyticsChart.vue';
import AnalyticsTopPages from './Components/shared/AnalyticsTopPages.vue';
import AnalyticsReferrers from './Components/shared/AnalyticsReferrers.vue';
import AnalyticsPeriodFilter from './Components/shared/AnalyticsPeriodFilter.vue';
export default {
name: 'AnalyticsSectionDetail',
components: {
DashboardLayout,
FlashMessages,
AnalyticsChart,
AnalyticsTopPages,
AnalyticsReferrers,
AnalyticsPeriodFilter,
},
props: {
section: { type: Object, required: true },
period: { type: Number, default: 30 },
},
computed: {
totalDevices() {
return Object.values(this.section.devices || {}).reduce((a, b) => a + b, 0) || 1;
},
maxChildViews() {
if (!this.section.children || !this.section.children.length) return 1;
return Math.max(...this.section.children.map(c => c.views), 1);
},
},
mounted() {
this.SET_DOCUMENT_TITLE(this.section.label + ' — Аналитика');
},
methods: {
formatNumber(n) {
return new Intl.NumberFormat('ru-RU').format(n);
},
deviceLabel(type) {
const map = { desktop: 'Десктоп', mobile: 'Мобильные', tablet: 'Планшеты' };
return map[type] || type;
},
getDevicePercent(count) {
return Math.round((count / this.totalDevices) * 100);
},
getChildWidth(views) {
return Math.max((views / this.maxChildViews) * 100, 2);
},
sectionUrl(prefix) {
return route('dashboard.analytics.section', { prefix, days: this.period });
},
changePeriod(days) {
this.$inertia.get(route('dashboard.analytics.section'), { prefix: this.section.prefix, days }, { preserveState: true });
},
},
};
</script>
@@ -159,4 +159,12 @@ export const menuItems = [
activePrefixes: ['dashboard.integration-credentials'],
permission: null,
},
{
key: 'analytics',
label: 'Аналитика',
icon: 'chart-bar',
route: 'dashboard.analytics.index',
activePrefixes: ['dashboard.analytics'],
permission: 'view_analytics',
},
];
@@ -0,0 +1,145 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-foreground">Посещаемость</h3>
<div class="flex bg-layer border border-layer-line rounded-md p-0.5">
<button
@click="metric = 'visitors'"
class="px-2.5 py-1 text-[11px] font-medium rounded transition-colors"
:class="metric === 'visitors' ? 'bg-primary text-white' : 'text-muted-foreground-1 hover:text-foreground'"
>
Посетители
</button>
<button
@click="metric = 'views'"
class="px-2.5 py-1 text-[11px] font-medium rounded transition-colors"
:class="metric === 'views' ? 'bg-primary text-white' : 'text-muted-foreground-1 hover:text-foreground'"
>
Просмотры
</button>
</div>
</div>
<div v-if="data.length === 0" class="text-xs text-muted-foreground-1 py-8 text-center">
Нет данных за выбранный период
</div>
<div v-else-if="data.length === 1" class="py-8 text-center">
<p class="text-3xl font-bold text-foreground">{{ data[0][metric === 'views' ? 'views' : 'visitors'] }}</p>
<p class="text-xs text-muted-foreground-1 mt-1">{{ formatDate(data[0].date) }}</p>
</div>
<div v-else class="relative" style="height: 200px;">
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script>
import { Line } from 'vue-chartjs';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip, Legend);
export default {
name: 'AnalyticsChart',
components: { Line },
props: {
data: { type: Array, required: true },
},
data() {
return { metric: 'visitors' };
},
computed: {
chartData() {
const values = this.data.map(d => this.metric === 'views' ? d.views : d.visitors);
return {
labels: this.data.map(d => this.formatDate(d.date)),
datasets: [
{
label: this.metric === 'views' ? 'Просмотры' : 'Посетители',
data: values,
borderColor: 'rgb(30, 87, 163)',
backgroundColor: 'rgba(30, 87, 163, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: this.data.length > 31 ? 0 : 3,
pointHoverRadius: 5,
pointBackgroundColor: 'white',
pointBorderColor: 'rgb(30, 87, 163)',
pointBorderWidth: 2,
},
],
};
},
chartOptions() {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgb(30, 30, 30)',
titleFont: { size: 11 },
bodyFont: { size: 12 },
padding: 8,
cornerRadius: 6,
displayColors: false,
callbacks: {
title: (items) => {
const idx = items[0].dataIndex;
const d = new Date(this.data[idx].date);
const days = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
return `${d.getDate()}.${d.getMonth() + 1} (${days[d.getDay()]})`;
},
label: (item) => `${this.metric === 'views' ? 'Просмотры' : 'Посетители'}: ${item.formattedValue}`,
},
},
},
scales: {
x: {
grid: { display: false },
ticks: {
font: { size: 10 },
color: '#9ca3af',
maxTicksLimit: this.data.length > 31 ? 10 : 15,
},
},
y: {
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.05)' },
ticks: {
font: { size: 10 },
color: '#9ca3af',
precision: 0,
},
},
},
};
},
},
methods: {
formatDate(dateStr) {
const d = new Date(dateStr);
return `${d.getDate()}.${d.getMonth() + 1}`;
},
},
watch: {
metric() {},
},
};
</script>
@@ -0,0 +1,117 @@
<template>
<div>
<div class="flex gap-1 mb-4 bg-layer border border-layer-line rounded-md p-0.5">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex-1 px-2 py-1 text-[11px] font-medium rounded transition-colors"
:class="activeTab === tab.key ? 'bg-primary text-white' : 'text-muted-foreground-1 hover:text-foreground'"
>
{{ tab.label }}
</button>
</div>
<div v-if="activeTab === 'devices'">
<div v-if="Object.keys(devices).length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-3">
<div v-for="(count, type) in devices" :key="type" class="flex items-center justify-between">
<div class="flex items-center gap-2">
<DashboardIcon :name="deviceIcon(type)" size="5" class="text-muted-foreground-2" />
<span class="text-xs font-medium text-foreground">{{ deviceLabel(type) }}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-24 h-1.5 bg-muted-hover rounded-full overflow-hidden">
<div class="h-full bg-primary/60 rounded-full" :style="{ width: getDevicePercent(count) + '%' }"></div>
</div>
<span class="text-[11px] text-muted-foreground-1 w-12 text-right">{{ getDevicePercent(count) }}%</span>
</div>
</div>
</div>
</div>
<div v-else-if="activeTab === 'browsers'">
<div v-if="Object.keys(browsers).length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-2">
<div v-for="(count, name) in browsers" :key="name" class="flex items-center justify-between">
<span class="text-xs font-medium text-foreground">{{ name }}</span>
<div class="flex items-center gap-2">
<div class="w-20 h-1.5 bg-muted-hover rounded-full overflow-hidden">
<div class="h-full bg-primary/60 rounded-full" :style="{ width: getBrowserPercent(count) + '%' }"></div>
</div>
<span class="text-[11px] text-muted-foreground-1 w-8 text-right">{{ count }}</span>
</div>
</div>
</div>
</div>
<div v-else>
<div v-if="Object.keys(oses).length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-2">
<div v-for="(count, name) in oses" :key="name" class="flex items-center justify-between">
<span class="text-xs font-medium text-foreground">{{ name }}</span>
<div class="flex items-center gap-2">
<div class="w-20 h-1.5 bg-muted-hover rounded-full overflow-hidden">
<div class="h-full bg-primary/60 rounded-full" :style="{ width: getOsPercent(count) + '%' }"></div>
</div>
<span class="text-[11px] text-muted-foreground-1 w-8 text-right">{{ count }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'AnalyticsDevices',
components: { DashboardIcon },
props: {
devices: { type: Object, required: true },
browsers: { type: Object, required: true },
oses: { type: Object, required: true },
},
data() {
return {
activeTab: 'devices',
tabs: [
{ key: 'devices', label: 'Устройства' },
{ key: 'browsers', label: 'Браузеры' },
{ key: 'oses', label: 'ОС' },
],
};
},
computed: {
totalDevices() {
return Object.values(this.devices).reduce((a, b) => a + b, 0) || 1;
},
maxBrowser() {
return Math.max(...Object.values(this.browsers), 1);
},
maxOs() {
return Math.max(...Object.values(this.oses), 1);
},
},
methods: {
deviceIcon(type) {
const map = { desktop: 'computer-desktop', mobile: 'device-phone-mobile', tablet: 'device-tablet' };
return map[type] || 'computer-desktop';
},
deviceLabel(type) {
const map = { desktop: 'Десктоп', mobile: 'Мобильные', tablet: 'Планшеты' };
return map[type] || type;
},
getDevicePercent(count) {
return Math.round((count / this.totalDevices) * 100);
},
getBrowserPercent(count) {
return Math.round((count / this.maxBrowser) * 100);
},
getOsPercent(count) {
return Math.round((count / this.maxOs) * 100);
},
},
};
</script>
@@ -0,0 +1,183 @@
<template>
<div class="space-y-0.5">
<div v-for="ms in filteredSections" :key="ms.id">
<!-- Home item -->
<a
v-if="ms.type === 'home'"
:href="sectionUrl('/')"
class="relative flex items-center justify-between py-2 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(ms.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative">
<DashboardIcon name="home" size="5" class="text-primary" />
<span class="text-sm font-medium text-foreground">{{ ms.title }}</span>
</div>
<div class="flex items-center gap-1.5 relative">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{{ ms.views }}
</span>
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
{{ ms.visitors }}
</span>
</div>
</a>
<!-- Regular section -->
<template v-else>
<a
:href="sectionUrl('/' + ms.slug)"
class="relative flex items-center justify-between py-2 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(ms.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative min-w-0">
<button
v-if="ms.sub_sections && ms.sub_sections.length"
@click.stop.prevent="toggle(ms.id)"
class="p-0.5 rounded hover:bg-muted-hover transition-colors"
>
<svg
class="w-3 h-3 text-muted-foreground-2 transition-transform"
:class="{ 'rotate-90': expanded[ms.id] }"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<span v-else class="w-3"></span>
<span class="text-sm font-medium text-foreground truncate">{{ ms.title }}</span>
</div>
<div class="flex items-center gap-1.5 relative flex-shrink-0">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{{ ms.views }}
</span>
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
{{ ms.visitors }}
</span>
</div>
</a>
<!-- Sub Sections (collapsible) -->
<div v-if="expanded[ms.id] && ms.sub_sections" class="ml-6 mt-1 space-y-0.5">
<div v-for="ss in ms.sub_sections" :key="ss.id">
<a
:href="sectionUrl('/' + ms.slug + '/' + ss.slug)"
class="relative flex items-center justify-between py-1.5 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(ss.views) + '%' }"
></div>
<div class="flex items-center gap-2 relative min-w-0">
<button
v-if="ss.pages && ss.pages.length"
@click.stop.prevent="toggleMs(ms.id, ss.id)"
class="p-0.5 rounded hover:bg-muted-hover transition-colors"
>
<svg
class="w-2.5 h-2.5 text-muted-foreground-2 transition-transform"
:class="{ 'rotate-90': expandedMs[ms.id + '_' + ss.id] }"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<span v-else class="w-2.5"></span>
<span class="text-xs font-medium text-foreground truncate">{{ ss.title }}</span>
</div>
<div class="flex items-center gap-1.5 relative flex-shrink-0">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{{ ss.views }}
</span>
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
{{ ss.visitors }}
</span>
</div>
</a>
<!-- Pages (collapsible) -->
<div v-if="expandedMs[ms.id + '_' + ss.id] && ss.pages" class="ml-5 mt-0.5 space-y-0.5">
<a
v-for="page in ss.pages"
:key="page.id"
:href="`/dashboard/analytics/section?prefix=${encodeURIComponent(page.url)}&days=${period}`"
class="relative flex items-center justify-between py-1 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group-link"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(page.views) + '%' }"
></div>
<div class="flex items-center gap-1.5 relative min-w-0">
<DashboardIcon name="document" size="4" class="text-muted-foreground-2 flex-shrink-0" />
<span class="text-[11px] text-foreground truncate">{{ page.title }}</span>
</div>
<div class="flex items-center gap-1.5 relative flex-shrink-0">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted-hover text-[11px] text-muted-foreground-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
{{ page.views }}
</span>
</div>
</a>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script>
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'AnalyticsNavigationSections',
components: { DashboardIcon },
props: {
navigation: { type: Object, required: true },
period: { type: Number, default: 30 },
group: { type: String, default: null },
},
data() {
return {
expanded: {},
expandedMs: {},
};
},
computed: {
filteredSections() {
if (!this.group) return this.navigation.sections;
return this.navigation.sections.filter(s => s.group === this.group);
},
maxViews() {
const views = this.filteredSections.map(s => s.views);
return Math.max(...views, 1);
},
},
methods: {
getWidth(views) {
return Math.max((views / this.maxViews) * 100, 2);
},
toggle(id) {
this.expanded[id] = !this.expanded[id];
},
toggleMs(msId, ssId) {
const key = msId + '_' + ssId;
this.expandedMs[key] = !this.expandedMs[key];
},
sectionUrl(prefix) {
return route('dashboard.analytics.section', { prefix, days: this.period });
},
},
};
</script>
@@ -0,0 +1,113 @@
<template>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div
v-for="card in statCards"
:key="card.label"
class="bg-layer border border-layer-line rounded-lg p-5 shadow-xs"
>
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" :class="card.bgClass">
<DashboardIcon :name="card.icon" size="5" :class="card.iconClass" />
</div>
<span
v-if="card.change.direction !== 'neutral'"
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
:class="badgeClass(card)"
>
{{ card.change.direction === 'up' ? '↑' : '↓' }}
{{ formatChange(card) }}
</span>
</div>
<div class="space-y-1">
<p class="text-2xl font-semibold text-foreground">{{ formatValue(card.value, card.format) }}</p>
<p class="text-xs text-muted-foreground-1">{{ card.label }}</p>
</div>
</div>
</div>
</template>
<script>
import DashboardIcon from '../DashboardIcon.vue';
export default {
name: 'AnalyticsOverview',
components: { DashboardIcon },
props: {
overview: { type: Object, required: true },
},
computed: {
statCards() {
return [
{
icon: 'users',
bgClass: 'bg-blue-500/10',
iconClass: 'text-blue-600',
label: 'Уникальные посетители',
value: this.overview.unique_visitors.value,
change: this.overview.unique_visitors,
changeSuffix: '%',
format: 'number',
},
{
icon: 'eye',
bgClass: 'bg-emerald-500/10',
iconClass: 'text-emerald-600',
label: 'Просмотры страниц',
value: this.overview.page_views.value,
change: this.overview.page_views,
changeSuffix: '%',
format: 'number',
},
{
icon: 'clock',
bgClass: 'bg-violet-500/10',
iconClass: 'text-violet-600',
label: 'Среднее время',
value: this.overview.avg_time.value,
change: this.overview.avg_time,
changeSuffix: 'с',
format: 'duration',
},
{
icon: 'arrow-uturn-left',
bgClass: 'bg-rose-500/10',
iconClass: 'text-rose-600',
label: 'Отказы (Bounce Rate)',
value: this.overview.bounce_rate.value,
change: this.overview.bounce_rate,
changeSuffix: '%',
format: 'percent',
invertColor: true,
},
];
},
},
methods: {
formatValue(value, format) {
if (format === 'duration') {
const min = Math.floor(value / 60);
const sec = value % 60;
return min > 0 ? `${min}м ${sec}с` : `${sec}с`;
}
if (format === 'percent') return `${value}%`;
return new Intl.NumberFormat('ru-RU').format(value);
},
formatChange(card) {
const val = Math.abs(card.change.change);
if (card.format === 'duration') {
const min = Math.floor(val / 60);
const sec = val % 60;
return min > 0 ? `${min}м ${sec}с` : `${val}с`;
}
return `${val}${card.changeSuffix}`;
},
badgeClass(card) {
const isUp = card.change.direction === 'up';
const bad = card.invertColor ? isUp : !isUp;
return bad
? 'bg-red-500/10 text-red-600'
: 'bg-emerald-500/10 text-emerald-600';
},
},
};
</script>
@@ -0,0 +1,45 @@
<template>
<div class="flex items-center gap-2">
<div class="flex bg-layer border border-layer-line rounded-lg p-0.5">
<button
v-for="preset in presets"
:key="preset.value"
@click="selectPeriod(preset.value)"
class="px-3 py-1 text-xs font-medium rounded-md transition-colors"
:class="modelValue === preset.value
? 'bg-primary text-white'
: 'text-muted-foreground-1 hover:text-foreground hover:bg-muted-hover'"
>
{{ preset.label }}
</button>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsPeriodFilter',
props: {
modelValue: {
type: Number,
default: 30,
},
},
emits: ['update:modelValue'],
data() {
return {
presets: [
{ label: 'Сегодня', value: 0 },
{ label: '7 дней', value: 7 },
{ label: '30 дней', value: 30 },
{ label: 'Все', value: 365 },
],
};
},
methods: {
selectPeriod(value) {
this.$emit('update:modelValue', value);
},
},
};
</script>
@@ -0,0 +1,36 @@
<template>
<div class="space-y-2">
<div v-if="items.length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-for="item in items" :key="item.source" class="group">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-foreground truncate">{{ item.source }}</span>
<span class="text-xs text-muted-foreground-1 ml-2 flex-shrink-0">{{ item.hits }}</span>
</div>
<div class="h-1.5 bg-muted-hover rounded-full overflow-hidden">
<div
class="h-full bg-primary/60 rounded-full transition-all"
:style="{ width: getWidth(item.hits) + '%' }"
></div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsReferrers',
props: {
items: { type: Array, required: true },
},
computed: {
maxHits() {
return Math.max(...this.items.map(i => i.hits), 1);
},
},
methods: {
getWidth(hits) {
return Math.max((hits / this.maxHits) * 100, 2);
},
},
};
</script>
@@ -0,0 +1,51 @@
<template>
<div>
<div v-if="items.length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div v-else class="space-y-1">
<a
v-for="item in items"
:key="item.prefix"
:href="sectionUrl(item.prefix)"
class="relative flex items-center justify-between py-2 px-2 rounded-md overflow-hidden hover:bg-muted-hover transition-colors group"
>
<div
class="absolute inset-y-0 left-0 bg-primary/5 rounded-md"
:style="{ width: getWidth(item.views) + '%' }"
></div>
<div class="flex items-center gap-2 min-w-0 flex-1 relative">
<span class="text-xs font-medium text-foreground">{{ item.label }}</span>
<svg class="w-3 h-3 text-muted-foreground-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
<div class="flex items-center gap-3 flex-shrink-0 relative">
<span class="text-[11px] text-muted-foreground-1">{{ item.views }}</span>
<span class="text-[10px] text-muted-foreground-2">{{ item.visitors }} uniq</span>
</div>
</a>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsSections',
props: {
items: { type: Array, required: true },
period: { type: Number, default: 30 },
},
computed: {
maxViews() {
return Math.max(...this.items.map(i => i.views), 1);
},
},
methods: {
getWidth(views) {
return Math.max((views / this.maxViews) * 100, 2);
},
sectionUrl(prefix) {
return route('dashboard.analytics.section', { prefix, days: this.period });
},
},
};
</script>
@@ -0,0 +1,50 @@
<template>
<div class="space-y-1.5">
<div v-if="pages.length === 0" class="text-xs text-muted-foreground-1 py-4 text-center">Нет данных</div>
<div
v-for="page in pages"
:key="page.url"
class="relative group"
>
<div class="absolute inset-0 bg-primary/5 rounded-md" :style="{ width: getWidth(page.views) + '%' }"></div>
<div class="relative flex items-center justify-between py-1.5 px-2 rounded-md">
<div class="flex items-center gap-2 min-w-0 flex-1">
<a
:href="page.url"
target="_blank"
class="text-[11px] font-medium text-foreground truncate hover:text-primary transition-colors"
:title="page.url"
>
{{ page.url }}
</a>
<svg class="w-3 h-3 text-muted-foreground-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
<div class="flex items-center gap-3 flex-shrink-0 ml-2">
<span class="text-[11px] text-muted-foreground-1">{{ page.views }}</span>
<span class="text-[10px] text-muted-foreground-2">{{ page.unique_visitors }} uniq</span>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AnalyticsTopPages',
props: {
pages: { type: Array, required: true },
},
computed: {
maxViews() {
return Math.max(...this.pages.map(p => p.views), 1);
},
},
methods: {
getWidth(views) {
return Math.max((views / this.maxViews) * 100, 2);
},
},
};
</script>
@@ -0,0 +1,73 @@
<template>
<transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="visible"
class="fixed bottom-0 inset-x-0 z-50 p-4 sm:p-6"
>
<div class="max-w-3xl mx-auto bg-layer border border-layer-line rounded-lg shadow-lg p-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex-1">
<h3 class="text-sm font-semibold text-foreground mb-1">Cookie и аналитика</h3>
<p class="text-xs text-muted-foreground-1 leading-relaxed">
Мы собираем анонимную статистику посещений для улучшения качества сайта.
Данные не передаются третьим лицам.
<a :href="route('page.view', { path: 'privacy-policy' })" class="underline hover:text-primary">
Подробнее
</a>
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button
@click="decline"
class="px-3 py-1.5 text-xs font-medium text-muted-foreground-1 hover:text-foreground border border-layer-line rounded-md transition-colors"
>
Отклонить
</button>
<button
@click="accept"
class="px-3 py-1.5 text-xs font-medium text-white bg-primary hover:bg-primary/90 rounded-md transition-colors"
>
Принять
</button>
</div>
</div>
</div>
</div>
</transition>
</template>
<script>
import { getConsent } from '@/services/analytics.js';
export default {
name: 'ConsentBanner',
data() {
return {
visible: false,
};
},
mounted() {
const consent = getConsent();
if (!consent) {
this.visible = true;
}
},
methods: {
accept() {
localStorage.setItem('analytics_consent', 'granted');
this.visible = false;
},
decline() {
localStorage.setItem('analytics_consent', 'denied');
this.visible = false;
},
},
};
</script>
+12 -13
View File
@@ -12,6 +12,7 @@ import cookieMixin from "@/mixins/cookieMixin.js";
import {helpers} from "@/mixins/Helpers.js";
import store from '@/store/index.js';
import '@vuepic/vue-datepicker/dist/main.css';
import { initAnalytics } from '@/services/analytics.js';
// Load TinyMCE globally before app initialization
const loadTinyMCE = () => {
@@ -36,10 +37,15 @@ loadTinyMCE().then(() => {
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.vue')
return pages[`./Pages/${name}.vue`]()
const page = pages[`./Pages/${name}.vue`]
if (!page) {
console.error(`Page not found: ${name}`)
return pages['./Pages/Error.vue']()
}
return page()
},
setup({ el, App, props, plugin }) {
return createSSRApp({ render: () => h(App, props) })
const app = createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(store)
.mixin(linksReform)
@@ -47,21 +53,14 @@ loadTinyMCE().then(() => {
.mixin(helpers)
.use(ZiggyVue)
.mount(el);
initAnalytics();
return app;
},
progress: {
color: '#1E57A3',
delay: 250,
},
});
router.on('navigate', (event) => {
const metrikaId = event.detail.page.props.yandex_metrika_id;
if (metrikaId && typeof ym === 'function') {
ym(metrikaId, 'hit', window.location.href, {
title: document.title,
referer: event.detail.page.props.ziggy?.previous_url || document.referrer,
});
}
});
});
+68
View File
@@ -0,0 +1,68 @@
import { router } from '@inertiajs/vue3'
const CONSENT_KEY = 'analytics_consent'
const VISITOR_KEY = '_vid'
function getVisitorId() {
let vid = localStorage.getItem(VISITOR_KEY)
if (!vid) {
vid = crypto.randomUUID()
localStorage.setItem(VISITOR_KEY, vid)
}
return vid
}
function isExcludedPath() {
const path = window.location.pathname
return path.startsWith('/dashboard') || path.startsWith('/admin')
}
function sendPayload(payload) {
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' })
if (navigator.sendBeacon) {
const sent = navigator.sendBeacon('/api/track/hit', blob)
if (sent) return
}
fetch('/api/track/hit', {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {})
}
export function getConsent() {
return localStorage.getItem(CONSENT_KEY)
}
export function trackCurrentPage() {
if (typeof window === 'undefined') return
if (getConsent() !== 'granted') return
if (isExcludedPath()) return
sendPayload({
visitor_id: getVisitorId(),
url: window.location.pathname + window.location.search,
referrer: document.referrer || null,
screen_resolution: `${window.screen.width}x${window.screen.height}`,
title: document.title,
timestamp: Date.now(),
})
}
let listenerRegistered = false
export function initAnalytics() {
if (typeof window === 'undefined') return
if (!listenerRegistered) {
listenerRegistered = true
router.on('navigate', () => {
setTimeout(trackCurrentPage, 100)
})
}
window._ntspiTrackHit = trackCurrentPage
}
+37 -22
View File
@@ -54,28 +54,43 @@
<body class="">
@inertia
@if(config('services.yandex_metrika.id') && app()->environment('production'))
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(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();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
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({{ config('services.yandex_metrika.id') }}, 'init', {
defer: true,
webvisor:true,
clickmap:true,
ecommerce:"dataLayer",
accurateTrackBounce:true,
trackLinks:true
});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/{{ config('services.yandex_metrika.id') }}" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
@endif
<div id="consent-banner" style="display:none; position:fixed; bottom:0; inset-x:0; z-index:9999; padding:1rem;">
<div style="max-width:48rem; margin:0 auto; background:#fff; border:1px solid #e5e7eb; border-radius:0.5rem; box-shadow:0 4px 6px -1px rgb(0 0 0 / 0.1); padding:1.25rem; display:flex; flex-direction:column; gap:1rem; align-items:flex-start;" class="sm:flex-row sm:items-center sm:justify-between">
<div style="flex:1;">
<h3 style="font-size:0.875rem; font-weight:600; color:#111827; margin:0 0 0.25rem;">Cookie и аналитика</h3>
<p style="font-size:0.75rem; color:#6b7280; line-height:1.5; margin:0;">
Мы собираем анонимную статистику посещений для улучшения качества сайта. Данные не передаются третьим лицам.
</p>
</div>
<div style="display:flex; gap:0.5rem; flex-shrink:0;">
<button onclick="declineConsent()" style="padding:0.375rem 0.75rem; font-size:0.75rem; font-weight:500; color:#6b7280; background:transparent; border:1px solid #e5e7eb; border-radius:0.375rem; cursor:pointer;">Отклонить</button>
<button onclick="acceptConsent()" style="padding:0.375rem 0.75rem; font-size:0.75rem; font-weight:500; color:#fff; background:#1E57A3; border:none; border-radius:0.375rem; cursor:pointer;">Принять</button>
</div>
</div>
</div>
<script>
(function() {
var KEY = 'analytics_consent';
var banner = document.getElementById('consent-banner');
if (!banner) return;
var path = window.location.pathname;
if (path.startsWith('/dashboard') || path.startsWith('/admin')) return;
if (!localStorage.getItem(KEY)) {
banner.style.display = 'block';
}
})();
function acceptConsent() {
localStorage.setItem('analytics_consent', 'granted');
document.getElementById('consent-banner').style.display = 'none';
if (typeof window._ntspiTrackHit === 'function') {
window._ntspiTrackHit();
}
}
function declineConsent() {
localStorage.setItem('analytics_consent', 'denied');
document.getElementById('consent-banner').style.display = 'none';
}
</script>
</body>
</html>