From d6c0f48d48a4cc7ab29733981504f7fa43bb2c6a Mon Sep 17 00:00:00 2001 From: F4ilji Date: Fri, 18 Sep 2026 22:18:04 +0500 Subject: [PATCH] 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 --- .../Commands/AggregateAnalyticsCommand.php | 48 ++++ .../Analytics/Jobs/RecordVisitJob.php | 68 ++++++ .../Analytics/Models/AnalyticsDailyStat.php | 23 ++ .../Analytics/Models/AnalyticsHit.php | 36 +++ .../Analytics/Models/AnalyticsSession.php | 72 ++++++ .../Providers/AnalyticsServiceProvider.php | 22 ++ .../Services/DeviceDetectorService.php | 35 +++ .../Analytics/Services/GeoIpService.php | 56 +++++ .../Tasks/GetAnalyticsOverviewTask.php | 97 ++++++++ .../Analytics/Tasks/GetDailyStatsTask.php | 50 ++++ .../Analytics/Tasks/GetDevicesTask.php | 56 +++++ .../Tasks/GetNavigationSectionsTask.php | 134 +++++++++++ .../Analytics/Tasks/GetReferrersTask.php | 62 +++++ .../Analytics/Tasks/GetSectionDetailTask.php | 216 ++++++++++++++++++ .../Analytics/Tasks/GetSectionsStatsTask.php | 71 ++++++ .../Analytics/Tasks/GetTopPagesTask.php | 22 ++ .../WEB/Controllers/AnalyticsController.php | 72 ++++++ .../UI/WEB/Controllers/TrackController.php | 38 +++ .../Analytics/UI/WEB/Routes/api.php | 8 + .../Analytics/UI/WEB/Routes/web.php | 3 + .../Dashboard/UI/WEB/Routes/web.php | 5 + app/Http/Middleware/HandleInertiaRequests.php | 2 - app/Providers/AppServiceProvider.php | 10 +- .../Commands/SyncDashboardPermissions.php | 12 + app/Ship/Middleware/HandleInertiaRequests.php | 1 - composer.json | 8 +- composer.lock | 65 +++++- config/analytics.php | 88 +++++++ config/app.php | 1 + config/services.php | 4 - .../factories/EducationalGroupFactory.php | 2 +- database/factories/EventFactory.php | 45 ++-- database/factories/PageFactory.php | 1 - database/factories/UserDetailFactory.php | 4 +- ...100000_create_analytics_sessions_table.php | 38 +++ ..._18_100001_create_analytics_hits_table.php | 31 +++ ...002_create_analytics_daily_stats_table.php | 27 +++ ...09_18_120000_remove_geo_from_analytics.php | 34 +++ database/seeders/AnalyticsMockSeeder.php | 96 ++++++++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/MockDataSeeder.php | 77 ++++--- docker-compose.yml | 2 +- package-lock.json | 31 ++- package.json | 2 + resources/js/Pages/Dashboard/Analytics.vue | 132 +++++++++++ .../Dashboard/AnalyticsSectionDetail.vue | 184 +++++++++++++++ .../Pages/Dashboard/Components/menuConfig.js | 8 + .../Components/shared/AnalyticsChart.vue | 145 ++++++++++++ .../Components/shared/AnalyticsDevices.vue | 117 ++++++++++ .../shared/AnalyticsNavigationSections.vue | 183 +++++++++++++++ .../Components/shared/AnalyticsOverview.vue | 113 +++++++++ .../shared/AnalyticsPeriodFilter.vue | 45 ++++ .../Components/shared/AnalyticsReferrers.vue | 36 +++ .../Components/shared/AnalyticsSections.vue | 51 +++++ .../Components/shared/AnalyticsTopPages.vue | 50 ++++ .../Components/shared/ConsentBanner.vue | 73 ++++++ resources/js/app.js | 25 +- resources/js/services/analytics.js | 68 ++++++ resources/views/app.blade.php | 59 +++-- routes/api.php | 4 +- 60 files changed, 2948 insertions(+), 121 deletions(-) create mode 100644 app/Containers/Analytics/Commands/AggregateAnalyticsCommand.php create mode 100644 app/Containers/Analytics/Jobs/RecordVisitJob.php create mode 100644 app/Containers/Analytics/Models/AnalyticsDailyStat.php create mode 100644 app/Containers/Analytics/Models/AnalyticsHit.php create mode 100644 app/Containers/Analytics/Models/AnalyticsSession.php create mode 100644 app/Containers/Analytics/Providers/AnalyticsServiceProvider.php create mode 100644 app/Containers/Analytics/Services/DeviceDetectorService.php create mode 100644 app/Containers/Analytics/Services/GeoIpService.php create mode 100644 app/Containers/Analytics/Tasks/GetAnalyticsOverviewTask.php create mode 100644 app/Containers/Analytics/Tasks/GetDailyStatsTask.php create mode 100644 app/Containers/Analytics/Tasks/GetDevicesTask.php create mode 100644 app/Containers/Analytics/Tasks/GetNavigationSectionsTask.php create mode 100644 app/Containers/Analytics/Tasks/GetReferrersTask.php create mode 100644 app/Containers/Analytics/Tasks/GetSectionDetailTask.php create mode 100644 app/Containers/Analytics/Tasks/GetSectionsStatsTask.php create mode 100644 app/Containers/Analytics/Tasks/GetTopPagesTask.php create mode 100644 app/Containers/Analytics/UI/WEB/Controllers/AnalyticsController.php create mode 100644 app/Containers/Analytics/UI/WEB/Controllers/TrackController.php create mode 100644 app/Containers/Analytics/UI/WEB/Routes/api.php create mode 100644 app/Containers/Analytics/UI/WEB/Routes/web.php create mode 100644 config/analytics.php create mode 100644 database/migrations/2026_09_18_100000_create_analytics_sessions_table.php create mode 100644 database/migrations/2026_09_18_100001_create_analytics_hits_table.php create mode 100644 database/migrations/2026_09_18_100002_create_analytics_daily_stats_table.php create mode 100644 database/migrations/2026_09_18_120000_remove_geo_from_analytics.php create mode 100644 database/seeders/AnalyticsMockSeeder.php create mode 100644 resources/js/Pages/Dashboard/Analytics.vue create mode 100644 resources/js/Pages/Dashboard/AnalyticsSectionDetail.vue create mode 100644 resources/js/Pages/Dashboard/Components/shared/AnalyticsChart.vue create mode 100644 resources/js/Pages/Dashboard/Components/shared/AnalyticsDevices.vue create mode 100644 resources/js/Pages/Dashboard/Components/shared/AnalyticsNavigationSections.vue create mode 100644 resources/js/Pages/Dashboard/Components/shared/AnalyticsOverview.vue create mode 100644 resources/js/Pages/Dashboard/Components/shared/AnalyticsPeriodFilter.vue create mode 100644 resources/js/Pages/Dashboard/Components/shared/AnalyticsReferrers.vue create mode 100644 resources/js/Pages/Dashboard/Components/shared/AnalyticsSections.vue create mode 100644 resources/js/Pages/Dashboard/Components/shared/AnalyticsTopPages.vue create mode 100644 resources/js/Pages/Dashboard/Components/shared/ConsentBanner.vue create mode 100644 resources/js/services/analytics.js diff --git a/app/Containers/Analytics/Commands/AggregateAnalyticsCommand.php b/app/Containers/Analytics/Commands/AggregateAnalyticsCommand.php new file mode 100644 index 0000000..6df028f --- /dev/null +++ b/app/Containers/Analytics/Commands/AggregateAnalyticsCommand.php @@ -0,0 +1,48 @@ +option('days'); + $date = now()->subDays($days)->toDateString(); + + $stats = AnalyticsHit::selectRaw(' + DATE(created_at) as hit_date, + url, + COUNT(*) as views_count, + COUNT(DISTINCT session_id) as unique_visitors + ') + ->whereDate('created_at', $date) + ->groupBy(DB::raw('DATE(created_at)'), 'url') + ->get(); + + $inserted = 0; + + foreach ($stats as $row) { + AnalyticsDailyStat::updateOrCreate( + ['date' => $row->hit_date, 'url' => $row->url], + [ + 'views_count' => $row->views_count, + 'unique_visitors' => $row->unique_visitors, + ] + ); + $inserted++; + } + + $this->info("Aggregated {$inserted} daily stat rows for {$date}"); + + return static::SUCCESS; + } +} diff --git a/app/Containers/Analytics/Jobs/RecordVisitJob.php b/app/Containers/Analytics/Jobs/RecordVisitJob.php new file mode 100644 index 0000000..360b149 --- /dev/null +++ b/app/Containers/Analytics/Jobs/RecordVisitJob.php @@ -0,0 +1,68 @@ +data['user_agent'] ?? ''; + + if ($detector->isBot($userAgent)) { + return; + } + + $device = $detector->parse($userAgent); + + $utmParams = array_filter([ + 'utm_source' => $this->data['utm_source'] ?? null, + 'utm_medium' => $this->data['utm_medium'] ?? null, + 'utm_campaign' => $this->data['utm_campaign'] ?? null, + ], fn($v) => $v !== null); + + $session = (new AnalyticsSession())->firstOrCreateSession( + visitorId: $this->data['visitor_id'], + ip: $this->data['ip'] ?? '0.0.0.0', + userId: $this->data['user_id'] ?? null, + entryPage: $this->data['url'], + utmParams: $utmParams, + ); + + if (empty($session->browser)) { + $session->update([ + 'browser' => $device['browser'], + 'os' => $device['os'], + 'device_type' => $device['type'], + ]); + } + + AnalyticsHit::create([ + 'session_id' => $session->id, + 'user_id' => $this->data['user_id'] ?? null, + 'url' => $this->data['url'], + 'referrer' => $this->data['referrer'] ?? null, + 'title' => $this->data['title'] ?? null, + 'created_at' => now(), + ]); + } +} diff --git a/app/Containers/Analytics/Models/AnalyticsDailyStat.php b/app/Containers/Analytics/Models/AnalyticsDailyStat.php new file mode 100644 index 0000000..b4077d9 --- /dev/null +++ b/app/Containers/Analytics/Models/AnalyticsDailyStat.php @@ -0,0 +1,23 @@ + 'date', + 'views_count' => 'integer', + 'unique_visitors' => 'integer', + ]; +} diff --git a/app/Containers/Analytics/Models/AnalyticsHit.php b/app/Containers/Analytics/Models/AnalyticsHit.php new file mode 100644 index 0000000..5a959ec --- /dev/null +++ b/app/Containers/Analytics/Models/AnalyticsHit.php @@ -0,0 +1,36 @@ + 'datetime', + ]; + + public function session(): BelongsTo + { + return $this->belongsTo(AnalyticsSession::class, 'session_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(\App\Containers\User\Models\User::class, 'user_id'); + } +} diff --git a/app/Containers/Analytics/Models/AnalyticsSession.php b/app/Containers/Analytics/Models/AnalyticsSession.php new file mode 100644 index 0000000..1b98a0d --- /dev/null +++ b/app/Containers/Analytics/Models/AnalyticsSession.php @@ -0,0 +1,72 @@ + 'datetime', + 'last_activity_at' => 'datetime', + ]; + + public function hits(): HasMany + { + return $this->hasMany(AnalyticsHit::class, 'session_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(\App\Containers\User\Models\User::class, 'user_id'); + } + + public function firstOrCreateSession( + string $visitorId, + string $ip, + int $userId = null, + string $entryPage = null, + array $utmParams = [], + ): self { + $lastSession = static::where('visitor_id', $visitorId) + ->where('last_activity_at', '>', now()->subMinutes(30)) + ->latest('last_activity_at') + ->first(); + + if ($lastSession) { + $lastSession->update(['last_activity_at' => now()]); + return $lastSession; + } + + return static::create([ + 'visitor_id' => $visitorId, + 'user_id' => $userId, + 'entry_page' => $entryPage, + 'ip' => $ip, + 'utm_source' => $utmParams['utm_source'] ?? null, + 'utm_medium' => $utmParams['utm_medium'] ?? null, + 'utm_campaign' => $utmParams['utm_campaign'] ?? null, + 'started_at' => now(), + 'last_activity_at' => now(), + ]); + } +} diff --git a/app/Containers/Analytics/Providers/AnalyticsServiceProvider.php b/app/Containers/Analytics/Providers/AnalyticsServiceProvider.php new file mode 100644 index 0000000..2d4e36d --- /dev/null +++ b/app/Containers/Analytics/Providers/AnalyticsServiceProvider.php @@ -0,0 +1,22 @@ +app->runningInConsole()) { + $this->commands([ + \App\Containers\Analytics\Commands\AggregateAnalyticsCommand::class, + ]); + + $this->app->afterResolving(Schedule::class, function (Schedule $schedule) { + $schedule->command('analytics:aggregate', ['--days' => 1])->hourly(); + }); + } + } +} diff --git a/app/Containers/Analytics/Services/DeviceDetectorService.php b/app/Containers/Analytics/Services/DeviceDetectorService.php new file mode 100644 index 0000000..f77b3f2 --- /dev/null +++ b/app/Containers/Analytics/Services/DeviceDetectorService.php @@ -0,0 +1,35 @@ +browser->name ?? null; + $os = $parser->os->name ?? null; + + $deviceType = 'desktop'; + if ($parser->isType('tablet')) { + $deviceType = 'tablet'; + } elseif ($parser->isType('mobile')) { + $deviceType = 'mobile'; + } + + return [ + 'browser' => $browser, + 'os' => $os, + 'type' => $deviceType, + ]; + } + + public function isBot(string $userAgent): bool + { + $parser = new Parser($userAgent); + return $parser->isType('bot'); + } +} diff --git a/app/Containers/Analytics/Services/GeoIpService.php b/app/Containers/Analytics/Services/GeoIpService.php new file mode 100644 index 0000000..d416924 --- /dev/null +++ b/app/Containers/Analytics/Services/GeoIpService.php @@ -0,0 +1,56 @@ +where('cached_at', '>', now()->subDays(self::CACHE_TTL_DAYS)) + ->first(); + + if ($cached) { + return [ + 'country' => $cached->country, + 'city' => $cached->city, + ]; + } + + try { + $response = Http::timeout(3)->get("http://ip-api.com/json/{$ip}", [ + 'fields' => 'country,city', + ]); + + if ($response->successful()) { + $data = $response->json(); + + AnalyticsGeoCache::updateOrCreate( + ['ip_hash' => $ipHash], + [ + 'country' => $data['country'] ?? null, + 'city' => $data['city'] ?? null, + 'cached_at' => now(), + ] + ); + + return [ + 'country' => $data['country'] ?? null, + 'city' => $data['city'] ?? null, + ]; + } + } catch (\Throwable $e) { + Log::warning('GeoIP lookup failed', ['ip' => $ip, 'error' => $e->getMessage()]); + } + + return null; + } +} diff --git a/app/Containers/Analytics/Tasks/GetAnalyticsOverviewTask.php b/app/Containers/Analytics/Tasks/GetAnalyticsOverviewTask.php new file mode 100644 index 0000000..10f0eb3 --- /dev/null +++ b/app/Containers/Analytics/Tasks/GetAnalyticsOverviewTask.php @@ -0,0 +1,97 @@ +subDays($days)->startOfDay(); + $to = now()->endOfDay(); + + if ($days === 0) { + $prevFrom = Carbon::yesterday()->startOfDay(); + $prevTo = Carbon::yesterday()->endOfDay(); + } else { + $prevFrom = Carbon::now()->subDays($days * 2)->startOfDay(); + $prevTo = Carbon::now()->subDays($days)->startOfDay(); + } + + $current = $this->getPeriodStats($from, $to); + $previous = $this->getPeriodStats($prevFrom, $prevTo); + + return [ + 'unique_visitors' => $this->compare($current['visitors'], $previous['visitors']), + 'page_views' => $this->compare($current['views'], $previous['views']), + 'avg_time' => $this->compare($current['avg_time'], $previous['avg_time'], true), + 'bounce_rate' => $this->compare($current['bounce_rate'], $previous['bounce_rate'], true, true), + ]; + } + + private function getPeriodStats(Carbon $from, Carbon $to): array + { + $hits = AnalyticsHit::whereBetween('created_at', [$from, $to])->count(); + + $sessions = AnalyticsSession::whereBetween('started_at', [$from, $to]) + ->get(['id', 'started_at', 'last_activity_at']); + + $uniqueVisitors = AnalyticsSession::whereBetween('started_at', [$from, $to]) + ->distinct('visitor_id') + ->count('visitor_id'); + + $totalSessions = $sessions->count(); + + $avgTime = 0; + if ($totalSessions > 0) { + $totalSeconds = $sessions->sum(function ($s) { + return $s->started_at->diffInSeconds($s->last_activity_at); + }); + $avgTime = (int) round($totalSeconds / $totalSessions); + } + + $bounceRate = 0; + if ($totalSessions > 0) { + $sessionIds = $sessions->pluck('id'); + $singleHitSessions = AnalyticsHit::whereIn('session_id', $sessionIds) + ->selectRaw('session_id, COUNT(*) as hits') + ->groupBy('session_id') + ->havingRaw('COUNT(*) = 1') + ->count(); + $bounceRate = (int) round(($singleHitSessions / $totalSessions) * 100); + } + + return [ + 'views' => $hits, + 'visitors' => $uniqueVisitors, + 'avg_time' => $avgTime, + 'bounce_rate' => $bounceRate, + ]; + } + + private function compare(int $current, int $previous, bool $isRatio = false, bool $invertDirection = false): array + { + $change = 0; + if ($previous > 0) { + $change = $isRatio + ? $current - $previous + : (int) round((($current - $previous) / $previous) * 100); + } elseif ($current > 0) { + $change = $isRatio ? 0 : 100; + } + + $direction = $invertDirection + ? ($change < 0 ? 'up' : ($change > 0 ? 'down' : 'neutral')) + : ($change > 0 ? 'up' : ($change < 0 ? 'down' : 'neutral')); + + return [ + 'value' => $current, + 'change' => $change, + 'direction' => $direction, + ]; + } +} diff --git a/app/Containers/Analytics/Tasks/GetDailyStatsTask.php b/app/Containers/Analytics/Tasks/GetDailyStatsTask.php new file mode 100644 index 0000000..190c7b3 --- /dev/null +++ b/app/Containers/Analytics/Tasks/GetDailyStatsTask.php @@ -0,0 +1,50 @@ +subDays($days)->startOfDay(); + + $stats = AnalyticsDailyStat::where('date', '>=', $from) + ->selectRaw(' + date, + SUM(views_count) as total_views, + SUM(unique_visitors) as total_unique + ') + ->groupBy('date') + ->orderBy('date') + ->get(); + + if ($stats->isEmpty()) { + return AnalyticsHit::where('created_at', '>=', $from) + ->selectRaw(' + DATE(created_at) as hit_date, + COUNT(*) as total_views, + COUNT(DISTINCT session_id) as total_unique + ') + ->groupBy(DB::raw('DATE(created_at)')) + ->orderBy('hit_date') + ->get() + ->map(fn($row) => [ + 'date' => (string) $row->hit_date, + 'views' => (int) $row->total_views, + 'visitors' => (int) $row->total_unique, + ]) + ->toArray(); + } + + return $stats->map(fn($row) => [ + 'date' => $row->date instanceof Carbon ? $row->date->format('Y-m-d') : (string) $row->date, + 'views' => (int) $row->total_views, + 'visitors' => (int) $row->total_unique, + ])->toArray(); + } +} diff --git a/app/Containers/Analytics/Tasks/GetDevicesTask.php b/app/Containers/Analytics/Tasks/GetDevicesTask.php new file mode 100644 index 0000000..e1b87e0 --- /dev/null +++ b/app/Containers/Analytics/Tasks/GetDevicesTask.php @@ -0,0 +1,56 @@ +subDays($days)->startOfDay(); + + $devices = AnalyticsSession::where('started_at', '>=', $from) + ->selectRaw(' + device_type, + COUNT(*) as count + ') + ->groupBy('device_type') + ->get() + ->pluck('count', 'device_type') + ->toArray(); + + $browsers = AnalyticsSession::where('started_at', '>=', $from) + ->whereNotNull('browser') + ->selectRaw(' + browser, + COUNT(*) as count + ') + ->groupBy('browser') + ->orderByDesc('count') + ->limit(10) + ->get() + ->pluck('count', 'browser') + ->toArray(); + + $oses = AnalyticsSession::where('started_at', '>=', $from) + ->whereNotNull('os') + ->selectRaw(' + os, + COUNT(*) as count + ') + ->groupBy('os') + ->orderByDesc('count') + ->limit(10) + ->get() + ->pluck('count', 'os') + ->toArray(); + + return [ + 'devices' => $devices, + 'browsers' => $browsers, + 'oses' => $oses, + ]; + } +} diff --git a/app/Containers/Analytics/Tasks/GetNavigationSectionsTask.php b/app/Containers/Analytics/Tasks/GetNavigationSectionsTask.php new file mode 100644 index 0000000..69febcf --- /dev/null +++ b/app/Containers/Analytics/Tasks/GetNavigationSectionsTask.php @@ -0,0 +1,134 @@ +subDays($days)->startOfDay(); + + $hits = AnalyticsHit::where('created_at', '>=', $from) + ->selectRaw('url, session_id') + ->get(); + + $mainSections = MainSection::with('subSections.pages') + ->orderBy('sort') + ->get(); + + $sections = []; + $cmsSlugs = []; + + // 1. CMS sections (MainSection → SubSection → Page) + foreach ($mainSections as $ms) { + $msPrefix = '/' . $ms->slug; + $cmsSlugs[] = $msPrefix; + $msHits = $hits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $msPrefix)); + $subSections = []; + + foreach ($ms->subSections as $ss) { + $ssPrefix = $msPrefix . '/' . $ss->slug; + $ssHits = $msHits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $ssPrefix)); + $pages = []; + + foreach ($ss->pages as $page) { + $pagePath = '/' . $page->path; + $pHits = $ssHits->filter(fn($h) => rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === $pagePath); + + $pages[] = [ + 'id' => $page->id, + 'title' => $page->title ?: $page->slug, + 'path' => $page->path, + 'url' => $pagePath, + 'views' => $pHits->count(), + 'visitors' => $pHits->pluck('session_id')->unique()->count(), + ]; + } + + $subSections[] = [ + 'id' => $ss->id, + 'title' => $ss->title, + 'slug' => $ss->slug, + 'views' => $ssHits->count(), + 'visitors' => $ssHits->pluck('session_id')->unique()->count(), + 'pages' => $pages, + ]; + } + + $sections[] = [ + 'id' => $ms->id, + 'title' => $ms->title, + 'slug' => $ms->slug, + 'group' => 'structure', + 'views' => $msHits->count(), + 'visitors' => $msHits->pluck('session_id')->unique()->count(), + 'sub_sections' => $subSections, + ]; + } + + // 2. Config-defined sections (non-CMS) + $configSections = config('analytics.sections', []); + foreach ($configSections as $prefix => $config) { + if ($prefix === '/') continue; + if (in_array($prefix, $cmsSlugs)) continue; + + $sectionHits = $hits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $prefix)); + $subSections = []; + + if (isset($config['children'])) { + $model = $config['children']['model']; + $nameKey = $config['children']['name_key']; + $slugKey = $config['children']['slug_key']; + $children = $model::all(); + + foreach ($children as $child) { + $childSlug = $child->{$slugKey}; + $childPrefix = $prefix . '/' . $childSlug; + $childHits = $sectionHits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $childPrefix)); + + $subSections[] = [ + 'id' => $child->id, + 'title' => $child->{$nameKey}, + 'slug' => $childSlug, + 'views' => $childHits->count(), + 'visitors' => $childHits->pluck('session_id')->unique()->count(), + 'pages' => [], + ]; + } + } + + $sections[] = [ + 'id' => 'config:' . $prefix, + 'title' => $config['label'], + 'slug' => ltrim($prefix, '/'), + 'group' => $config['group'] ?? 'other', + 'views' => $sectionHits->count(), + 'visitors' => $sectionHits->pluck('session_id')->unique()->count(), + 'sub_sections' => $subSections, + ]; + } + + // Homepage — as first item in structure group + $homeHits = $hits->filter(fn($h) => (rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === '/')); + + array_unshift($sections, [ + 'id' => 'home', + 'title' => 'Главная страница', + 'slug' => '', + 'group' => 'structure', + 'type' => 'home', + 'views' => $homeHits->count(), + 'visitors' => $homeHits->pluck('session_id')->unique()->count(), + 'sub_sections' => [], + ]); + + return [ + 'sections' => $sections, + 'groupLabels' => config('analytics.group_labels', []), + ]; + } +} diff --git a/app/Containers/Analytics/Tasks/GetReferrersTask.php b/app/Containers/Analytics/Tasks/GetReferrersTask.php new file mode 100644 index 0000000..fd47803 --- /dev/null +++ b/app/Containers/Analytics/Tasks/GetReferrersTask.php @@ -0,0 +1,62 @@ +subDays($days)->startOfDay(); + + $referrers = AnalyticsHit::where('created_at', '>=', $from) + ->whereNotNull('referrer') + ->where('referrer', '!=', '') + ->selectRaw(' + CASE + WHEN referrer LIKE "%google.%" THEN "Google" + WHEN referrer LIKE "%yandex.%" THEN "Yandex" + WHEN referrer LIKE "%vk.com%" THEN "VK" + WHEN referrer LIKE "%t.me%" THEN "Telegram" + WHEN referrer LIKE "%ok.ru%" THEN "Одноклассники" + WHEN referrer LIKE "%dzen.ru%" THEN "Дзен" + WHEN referrer LIKE "%facebook.%" THEN "Facebook" + WHEN referrer LIKE "%instagram.%" THEN "Instagram" + WHEN referrer LIKE "%twitter.%" THEN "Twitter" + ELSE SUBSTRING_INDEX(SUBSTRING_INDEX(referrer, "/", 3), "/", -1) + END as source, + COUNT(*) as hits, + COUNT(DISTINCT session_id) as visitors + ') + ->groupBy('source') + ->orderByDesc('hits') + ->limit($limit) + ->get() + ->toArray(); + + $directCount = AnalyticsHit::where('created_at', '>=', $from) + ->where(function ($q) { + $q->whereNull('referrer')->orWhere('referrer', ''); + }) + ->count(); + + $results = array_map(fn($r) => [ + 'source' => $r['source'], + 'hits' => (int) $r['hits'], + 'visitors' => (int) $r['visitors'], + ], $referrers); + + if ($directCount > 0) { + array_unshift($results, [ + 'source' => 'Прямой заход', + 'hits' => $directCount, + 'visitors' => $directCount, + ]); + } + + return $results; + } +} diff --git a/app/Containers/Analytics/Tasks/GetSectionDetailTask.php b/app/Containers/Analytics/Tasks/GetSectionDetailTask.php new file mode 100644 index 0000000..3d15c26 --- /dev/null +++ b/app/Containers/Analytics/Tasks/GetSectionDetailTask.php @@ -0,0 +1,216 @@ +subDays($days)->startOfDay(); + + $hits = AnalyticsHit::where('created_at', '>=', $from) + ->where('url', 'like', $prefix . '%') + ->selectRaw('url, session_id, created_at, referrer') + ->get(); + + $totalViews = $hits->count(); + $uniqueVisitors = $hits->pluck('session_id')->unique()->count(); + + $mainSection = null; + $subSection = null; + $page = null; + $children = []; + $breadcrumbs = []; + $type = 'unknown'; + $exitPages = []; + + $cleanPrefix = ltrim($prefix, '/'); + $parts = array_filter(explode('/', $cleanPrefix)); + + // 1. Check if MainSection + $mainSection = MainSection::where('slug', $cleanPrefix)->first(); + if ($mainSection) { + $type = 'main_section'; + $breadcrumbs = [['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')]]; + $subSections = SubSection::where('main_section_id', $mainSection->id) + ->with('pages') + ->orderBy('sort') + ->get(); + + foreach ($subSections as $ss) { + $ssPrefix = '/' . $mainSection->slug . '/' . $ss->slug; + $ssHits = $hits->filter(fn($h) => str_starts_with(parse_url($h->url, PHP_URL_PATH) ?: '', $ssPrefix)); + $children[] = [ + 'id' => $ss->id, + 'title' => $ss->title, + 'prefix' => $ssPrefix, + 'views' => $ssHits->count(), + 'visitors' => $ssHits->pluck('session_id')->unique()->count(), + 'pages_count' => $ss->pages->count(), + ]; + } + } + // 2. Check if SubSection (2 parts) + elseif (count($parts) === 2) { + $mainSection = MainSection::where('slug', $parts[0])->first(); + $subSection = SubSection::where('slug', $parts[1]) + ->where('main_section_id', $mainSection?->id) + ->first(); + + if ($subSection && $mainSection) { + $type = 'sub_section'; + $breadcrumbs = [ + ['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')], + ['title' => $mainSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug])], + ]; + + $pages = Page::where('sub_section_id', $subSection->id) + ->orderBy('sort') + ->get(); + + foreach ($pages as $p) { + $pUrl = '/' . $p->path; + $pHits = $hits->filter(fn($h) => rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === $pUrl); + $children[] = [ + 'id' => $p->id, + 'title' => $p->title ?: $p->slug, + 'url' => $pUrl, + 'views' => $pHits->count(), + 'visitors' => $pHits->pluck('session_id')->unique()->count(), + ]; + } + } + } + + // 3. Check if Page (by path or 3+ parts) + if ($type === 'unknown') { + $page = Page::where('path', $cleanPrefix)->first(); + if ($page && $page->section) { + $subSection = $page->section; + $mainSection = $subSection->mainSection; + } + + if ($mainSection && $subSection) { + $type = 'page'; + $breadcrumbs = [ + ['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')], + ['title' => $mainSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug])], + ['title' => $subSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug . '/' . $subSection->slug])], + ]; + + // Exit pages: URLs visited after this page in the same session + $exitPages = $this->getExitPages($hits, $prefix, $days); + } + } + + $label = $cleanPrefix; + if ($mainSection) $label = $mainSection->title; + if ($subSection) $label = $subSection->title; + if ($page) $label = $page->title ?: $page->slug; + + $topPages = $hits->groupBy('url') + ->map(fn($group) => [ + 'url' => $group->first()->url, + 'views' => $group->count(), + 'unique_visitors' => $group->pluck('session_id')->unique()->count(), + ]) + ->sortByDesc('views') + ->values() + ->take(15) + ->toArray(); + + $dailyStats = $hits->groupBy(fn($h) => $h->created_at->format('Y-m-d')) + ->map(fn($group, $date) => [ + 'date' => $date, + 'views' => $group->count(), + 'visitors' => $group->pluck('session_id')->unique()->count(), + ]) + ->values() + ->sortBy('date') + ->toArray(); + + $referrers = $hits->filter(fn($h) => !empty($h->referrer)) + ->groupBy(function ($h) { + $url = $h->referrer; + if (str_contains($url, 'google.')) return 'Google'; + if (str_contains($url, 'yandex.')) return 'Yandex'; + if (str_contains($url, 'vk.com')) return 'VK'; + if (str_contains($url, 't.me')) return 'Telegram'; + if (str_contains($url, 'ok.ru')) return 'Одноклассники'; + if (str_contains($url, 'dzen.ru')) return 'Дзен'; + return parse_url($url, PHP_URL_HOST) ?? 'Другое'; + }) + ->map(fn($group) => [ + 'source' => $group->first()->referrer, + 'hits' => $group->count(), + 'visitors' => $group->pluck('session_id')->unique()->count(), + ]) + ->sortByDesc('hits') + ->values() + ->take(10) + ->toArray(); + + $devices = AnalyticsSession::where('started_at', '>=', $from) + ->whereIn('id', $hits->pluck('session_id')->unique()) + ->selectRaw('device_type, COUNT(*) as count') + ->groupBy('device_type') + ->pluck('count', 'device_type') + ->toArray(); + + return [ + 'prefix' => $prefix, + 'type' => $type, + 'label' => $label, + 'breadcrumbs' => $breadcrumbs, + 'children' => $children, + 'exit_pages' => $exitPages, + 'total_views' => $totalViews, + 'unique_visitors' => $uniqueVisitors, + 'top_pages' => $topPages, + 'daily_stats' => $dailyStats, + 'referrers' => $referrers, + 'devices' => $devices, + ]; + } + + private function getExitPages($hits, string $prefix, int $days): array + { + $sessionIds = $hits->pluck('session_id')->unique(); + + $allHits = AnalyticsHit::where('created_at', '>=', now()->subDays($days)->startOfDay()) + ->whereIn('session_id', $sessionIds) + ->orderBy('session_id') + ->orderBy('created_at') + ->selectRaw('session_id, url, created_at') + ->get() + ->groupBy('session_id'); + + $exitCounts = []; + foreach ($allHits as $sessionId => $sessionHits) { + $sorted = $sessionHits->values(); + for ($i = 0; $i < $sorted->count() - 1; $i++) { + $currentUrl = parse_url($sorted[$i]->url, PHP_URL_PATH) ?: '/'; + if (str_starts_with($currentUrl, $prefix)) { + $nextUrl = parse_url($sorted[$i + 1]->url, PHP_URL_PATH) ?: '/'; + if (!isset($exitCounts[$nextUrl])) { + $exitCounts[$nextUrl] = 0; + } + $exitCounts[$nextUrl]++; + } + } + } + + arsort($exitCounts); + return array_slice(array_map(fn($url, $count) => [ + 'url' => $url, + 'count' => $count, + ], array_keys($exitCounts), $exitCounts), 0, 10); + } +} diff --git a/app/Containers/Analytics/Tasks/GetSectionsStatsTask.php b/app/Containers/Analytics/Tasks/GetSectionsStatsTask.php new file mode 100644 index 0000000..2cf07c4 --- /dev/null +++ b/app/Containers/Analytics/Tasks/GetSectionsStatsTask.php @@ -0,0 +1,71 @@ +subDays($days)->startOfDay(); + $sections = config('analytics.sections', []); + + $hits = AnalyticsHit::where('created_at', '>=', $from) + ->selectRaw('url, session_id') + ->get(); + + $grouped = []; + foreach ($hits as $hit) { + $prefix = $this->resolvePrefix($hit->url, $sections); + if (!isset($grouped[$prefix])) { + $grouped[$prefix] = ['views' => 0, 'visitors' => []]; + } + $grouped[$prefix]['views']++; + $grouped[$prefix]['visitors'][$hit->session_id] = true; + } + + $result = []; + foreach ($grouped as $prefix => $data) { + $config = $sections[$prefix] ?? null; + $result[] = [ + 'prefix' => $prefix, + 'label' => $config['label'] ?? $prefix, + 'icon' => $config['icon'] ?? 'folder', + 'views' => $data['views'], + 'visitors' => count($data['visitors']), + 'order' => $config['order'] ?? 99, + ]; + } + + usort($result, fn($a, $b) => $a['order'] <=> $b['order']); + + return $result; + } + + private function resolvePrefix(string $url, array $sections): string + { + $path = parse_url($url, PHP_URL_PATH) ?: '/'; + $segments = explode('/', trim($path, '/')); + $firstSegment = '/' . ($segments[0] ?? ''); + + if (isset($sections[$firstSegment])) { + return $firstSegment; + } + + if ($firstSegment === '/' && $path === '/') { + return '/'; + } + + // Check main sections for CMS pages + $mainSection = MainSection::where('slug', $segments[0] ?? '')->first(); + if ($mainSection) { + return '/page'; + } + + return '/other'; + } +} diff --git a/app/Containers/Analytics/Tasks/GetTopPagesTask.php b/app/Containers/Analytics/Tasks/GetTopPagesTask.php new file mode 100644 index 0000000..64e6299 --- /dev/null +++ b/app/Containers/Analytics/Tasks/GetTopPagesTask.php @@ -0,0 +1,22 @@ +selectRaw('COUNT(*) as views') + ->selectRaw('COUNT(DISTINCT session_id) as unique_visitors') + ->where('created_at', '>=', now()->subDays($days)) + ->groupBy('url') + ->orderByDesc('views') + ->limit($limit) + ->get() + ->toArray(); + } +} diff --git a/app/Containers/Analytics/UI/WEB/Controllers/AnalyticsController.php b/app/Containers/Analytics/UI/WEB/Controllers/AnalyticsController.php new file mode 100644 index 0000000..b9134a2 --- /dev/null +++ b/app/Containers/Analytics/UI/WEB/Controllers/AnalyticsController.php @@ -0,0 +1,72 @@ +query('days', 30); + + return inertia()->render('Dashboard/Analytics', [ + 'overview' => $this->overviewTask->run(days: $days), + 'topPages' => $this->topPagesTask->run(days: $days), + 'dailyStats' => $this->dailyStatsTask->run(days: $days), + 'referrers' => $this->referrersTask->run(days: $days), + 'devices' => $this->devicesTask->run(days: $days), + 'navigation' => $this->navigationSectionsTask->run(days: $days), + 'period' => $days, + ]); + } + + public function section(Request $request): Response + { + $prefix = $request->query('prefix', '/'); + $days = (int) $request->query('days', 30); + + $section = $this->sectionDetailTask->run(prefix: $prefix, days: $days); + + return inertia()->render('Dashboard/AnalyticsSectionDetail', [ + 'section' => $section, + 'period' => $days, + ]); + } + + public function clear(): RedirectResponse + { + DB::statement('SET FOREIGN_KEY_CHECKS=0'); + AnalyticsHit::truncate(); + AnalyticsSession::truncate(); + AnalyticsDailyStat::truncate(); + DB::statement('SET FOREIGN_KEY_CHECKS=1'); + + return redirect()->route('dashboard.analytics.index') + ->with('success', 'Данные аналитики очищены'); + } +} diff --git a/app/Containers/Analytics/UI/WEB/Controllers/TrackController.php b/app/Containers/Analytics/UI/WEB/Controllers/TrackController.php new file mode 100644 index 0000000..40ee17d --- /dev/null +++ b/app/Containers/Analytics/UI/WEB/Controllers/TrackController.php @@ -0,0 +1,38 @@ + $request->input('visitor_id'), + 'url' => $request->input('url'), + 'referrer' => $request->input('referrer'), + 'title' => $request->input('title'), + 'screen_resolution' => $request->input('screen_resolution'), + 'ip' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'user_id' => auth('web')->id(), + ]; + + $url = parse_url($request->input('url', ''), PHP_URL_QUERY); + if ($url) { + parse_str($url, $queryParams); + $payload['utm_source'] = $queryParams['utm_source'] ?? null; + $payload['utm_medium'] = $queryParams['utm_medium'] ?? null; + $payload['utm_campaign'] = $queryParams['utm_campaign'] ?? null; + } + + RecordVisitJob::dispatch($payload)->onQueue('analytics'); + + return response()->noContent(); + } +} diff --git a/app/Containers/Analytics/UI/WEB/Routes/api.php b/app/Containers/Analytics/UI/WEB/Routes/api.php new file mode 100644 index 0000000..0521838 --- /dev/null +++ b/app/Containers/Analytics/UI/WEB/Routes/api.php @@ -0,0 +1,8 @@ +middleware(['throttle:60,1']) + ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); diff --git a/app/Containers/Analytics/UI/WEB/Routes/web.php b/app/Containers/Analytics/UI/WEB/Routes/web.php new file mode 100644 index 0000000..7fbb9b5 --- /dev/null +++ b/app/Containers/Analytics/UI/WEB/Routes/web.php @@ -0,0 +1,3 @@ +g Route::put('/{credential}', [IntegrationCredentialsController::class, 'update'])->name('update'); Route::delete('/{credential}', [IntegrationCredentialsController::class, 'destroy'])->name('destroy'); }); + + // Аналитика + Route::get('/dashboard/analytics', \App\Containers\Analytics\UI\WEB\Controllers\AnalyticsController::class)->name('dashboard.analytics.index'); + Route::get('/dashboard/analytics/section', [\App\Containers\Analytics\UI\WEB\Controllers\AnalyticsController::class, 'section'])->name('dashboard.analytics.section'); + Route::post('/dashboard/analytics/clear', [\App\Containers\Analytics\UI\WEB\Controllers\AnalyticsController::class, 'clear'])->name('dashboard.analytics.clear'); }); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 0275d6f..abaca22 100755 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -21,7 +21,6 @@ class HandleInertiaRequests extends Middleware public function share(Request $request): array { - // Отключаем SSR для Dashboard роутов if (str_starts_with($request->path(), 'dashboard')) { config(['inertia.ssr.enabled' => false]); } @@ -49,7 +48,6 @@ class HandleInertiaRequests extends Middleware } return 'empty'; }, - 'yandex_metrika_id' => config('services.yandex_metrika.id'), 'flash' => function () use ($request) { return [ 'success' => $request->session()->get('success'), diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 136f2fd..8c13710 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -87,15 +87,9 @@ class AppServiceProvider extends ServiceProvider private static function configureFactoryResolution(): void { Factory::guessFactoryNamesUsing(function (string $modelName) { - $appNamespace = 'App\\'; + $className = class_basename($modelName); - if (str_starts_with($modelName, $appNamespace.'Models\\')) { - $modelName = substr($modelName, strlen($appNamespace.'Models\\')); - } else { - $modelName = substr($modelName, strlen($appNamespace)); - } - - return 'Database\\Factories\\'.$modelName.'Factory'; + return 'Database\\Factories\\'.$className.'Factory'; }); } diff --git a/app/Ship/Commands/SyncDashboardPermissions.php b/app/Ship/Commands/SyncDashboardPermissions.php index 0bcdfa0..6fa9a03 100644 --- a/app/Ship/Commands/SyncDashboardPermissions.php +++ b/app/Ship/Commands/SyncDashboardPermissions.php @@ -21,6 +21,11 @@ class SyncDashboardPermissions extends AbstractConsoleCommand 'educational_group', 'division', ]; + private const EXTRA_PERMISSIONS = [ + 'view_analytics', + 'view_any_analytic', + ]; + private const PREFIXES = [ 'view_any', 'create', 'update', 'delete', 'restore', 'force_delete', ]; @@ -46,6 +51,13 @@ class SyncDashboardPermissions extends AbstractConsoleCommand ['name' => 'view_any_contact_widget', 'guard_name' => 'web'], ); + foreach (self::EXTRA_PERMISSIONS as $name) { + Permission::firstOrCreate( + ['name' => $name, 'guard_name' => 'web'], + ); + $created++; + } + $roleNames = explode(',', $this->option('role')); $allPerms = Permission::where('guard_name', 'web')->get(); diff --git a/app/Ship/Middleware/HandleInertiaRequests.php b/app/Ship/Middleware/HandleInertiaRequests.php index 2561dee..dab9995 100755 --- a/app/Ship/Middleware/HandleInertiaRequests.php +++ b/app/Ship/Middleware/HandleInertiaRequests.php @@ -49,7 +49,6 @@ class HandleInertiaRequests extends Middleware } return 'empty'; }, - 'yandex_metrika_id' => config('services.yandex_metrika.id'), ]; } } diff --git a/composer.json b/composer.json index 46a12a0..18bd926 100755 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "tomatophp/filament-icons": "v1.1.4", "vkcom/vk-php-sdk": "^5.131", "webklex/php-imap": "^6.2", + "whichbrowser/parser": "^2.1", "xvladqt/faker-lorem-flickr": "^1.0", "yepsua/filament-range-field": "^0.3.4" }, @@ -91,5 +92,10 @@ } }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "repositories": [{ + "name": "packagist", + "type": "composer", + "url": "https://repo.packagist.org" + }] } diff --git a/composer.lock b/composer.lock index d55962c..da355f8 100755 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6df5b3167c82bcd51f51f438281adf2b", + "content-hash": "b8cca6122813359fd3f1884506e001fa", "packages": [ { "name": "alxdorosenco/porto-for-laravel", @@ -11435,6 +11435,69 @@ ], "time": "2025-04-25T06:02:37+00:00" }, + { + "name": "whichbrowser/parser", + "version": "v2.1.8", + "source": { + "type": "git", + "url": "https://github.com/WhichBrowser/Parser-PHP.git", + "reference": "581d614d686bfbec3529ad60562a5213ac5d8d72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WhichBrowser/Parser-PHP/zipball/581d614d686bfbec3529ad60562a5213ac5d8d72", + "reference": "581d614d686bfbec3529ad60562a5213ac5d8d72", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "cache/array-adapter": "^1.1", + "icomefromthenet/reverse-regex": "0.0.6.3", + "php-coveralls/php-coveralls": "^2.0", + "phpunit/php-code-coverage": "^5.0 || ^7.0", + "phpunit/phpunit": "^6.0 || ^8.0", + "squizlabs/php_codesniffer": "^3.5", + "symfony/yaml": "~3.4 || ~4.0" + }, + "suggest": { + "cache/array-adapter": "Allows testing of the caching functionality" + }, + "type": "library", + "autoload": { + "psr-4": { + "WhichBrowser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niels Leenheer", + "email": "niels@leenheer.nl", + "role": "Developer" + } + ], + "description": "Useragent sniffing library for PHP", + "homepage": "http://whichbrowser.net", + "keywords": [ + "browser", + "sniffing", + "ua", + "useragent" + ], + "support": { + "issues": "https://github.com/WhichBrowser/Parser-PHP/issues", + "source": "https://github.com/WhichBrowser/Parser-PHP/tree/v2.1.8" + }, + "time": "2024-04-17T12:47:41+00:00" + }, { "name": "xvladqt/faker-lorem-flickr", "version": "v1.0.0", diff --git a/config/analytics.php b/config/analytics.php new file mode 100644 index 0000000..ea33aec --- /dev/null +++ b/config/analytics.php @@ -0,0 +1,88 @@ + [ + 'structure' => 'Структура сайта', + 'institute' => 'Институт', + 'content' => 'Контент', + 'services' => 'Сервисы', + ], + + 'sections' => [ + '/' => [ + 'label' => 'Главная', + 'icon' => 'home', + 'order' => 0, + ], + '/faculties' => [ + 'label' => 'Факультеты', + 'icon' => 'academic-cap', + 'order' => 1, + 'group' => 'institute', + 'children' => [ + 'model' => \App\Containers\InstituteStructure\Models\Faculty::class, + 'name_key' => 'title', + 'slug_key' => 'slug', + ], + ], + '/divisions' => [ + 'label' => 'Подразделения', + 'icon' => 'building', + 'order' => 2, + 'group' => 'institute', + 'children' => [ + 'model' => \App\Containers\InstituteStructure\Models\Division::class, + 'name_key' => 'title', + 'slug_key' => 'slug', + ], + ], + '/news' => [ + 'label' => 'Новости', + 'icon' => 'document', + 'order' => 3, + 'group' => 'content', + ], + '/program' => [ + 'label' => 'Программы', + 'icon' => 'book-open', + 'order' => 4, + 'group' => 'content', + ], + '/schedule' => [ + 'label' => 'Расписание', + 'icon' => 'clock', + 'order' => 6, + 'group' => 'services', + ], + '/additional-education' => [ + 'label' => 'ДПО', + 'icon' => 'academic-cap', + 'order' => 7, + 'group' => 'content', + 'children' => [ + 'model' => \App\Containers\AdditionalEducation\Models\AdditionalEducation::class, + 'name_key' => 'title', + 'slug_key' => 'slug', + ], + ], + '/academic-journals' => [ + 'label' => 'Журналы', + 'icon' => 'beaker', + 'order' => 8, + 'group' => 'content', + 'children' => [ + 'model' => \App\Containers\Science\Models\AcademicJournal::class, + 'name_key' => 'title', + 'slug_key' => 'slug', + ], + ], + '/persons' => [ + 'label' => 'Сотрудники', + 'icon' => 'user', + 'order' => 9, + 'group' => 'institute', + ], + ], + +]; diff --git a/config/app.php b/config/app.php index 247f3e1..65c4b70 100755 --- a/config/app.php +++ b/config/app.php @@ -173,6 +173,7 @@ return [ App\Providers\RouteServiceProvider::class, \App\Providers\ForceHttpsServiceProvider::class, \App\Containers\VikonIntegration\Providers\VikonServiceProvider::class, + \App\Containers\Analytics\Providers\AnalyticsServiceProvider::class, ])->toArray(), /* diff --git a/config/services.php b/config/services.php index e18128a..bdfe590 100755 --- a/config/services.php +++ b/config/services.php @@ -31,10 +31,6 @@ return [ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], - 'yandex_metrika' => [ - 'id' => env('YANDEX_METRIKA_ID'), - ], - 'vk' => [ 'app_id' => env('VK_APP_ID'), 'service_key' => env('SERVICE_ACCESS_VK_KEY'), diff --git a/database/factories/EducationalGroupFactory.php b/database/factories/EducationalGroupFactory.php index d554d2f..b515171 100755 --- a/database/factories/EducationalGroupFactory.php +++ b/database/factories/EducationalGroupFactory.php @@ -12,7 +12,7 @@ class EducationalGroupFactory extends Factory { return [ 'title' => $this->faker->words(3, true), - 'faculty_id' => \App\Containers\InstituteStructure\Models\Faculty::factory(), + 'faculty_id' => \App\Containers\InstituteStructure\Models\Faculty::inRandomOrder()->first(), 'education_form_id' => $this->faker->numberBetween(1, 3), 'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'), 'updated_at' => $this->faker->dateTimeBetween('-1 year', 'now'), diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php index 31da916..9249b4b 100755 --- a/database/factories/EventFactory.php +++ b/database/factories/EventFactory.php @@ -6,23 +6,13 @@ use App\Containers\Event\Models\EventCategory; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; -/** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Event> - */ class EventFactory extends Factory { protected $model = \App\Containers\Event\Models\Event::class; - /** - * Define the model's default state. - * - * @return array - */ public function definition(): array { - $id = $this->faker->numberBetween(1, 1000000); $title = $this->faker->sentence; - $slug = Str::slug($title); $content = array_map(function () { return [ 'type' => $this->faker->randomElement(['heading', 'paragraph']), @@ -32,29 +22,26 @@ class EventFactory extends Factory ], ]; }, range(1, $this->faker->numberBetween(1, 5))); - $today = now(); - $random_days = rand(1, 25); // Генерируем случайное число дней от 1 до 365 - $event_date_start = date('Y-m-d', strtotime($today->format('Y-m-d') . ' +' . $random_days . ' days')); - $event_time_start = $this->faker->time('H:i'); - $address = $this->faker->address; - $is_online = $this->faker->randomElement([true, false]); - $category_id = EventCategory::inRandomOrder()->first(); - $created_at = now(); - $updated_at = now(); + + $eventDateStart = $this->faker->dateTimeBetween('-6 months', 'now'); + $endDate = date('Y-m-d', strtotime($eventDateStart->format('Y-m-d') . ' +' . rand(1, 30) . ' days')); + $eventDateEnd = $this->faker->optional(0.5)->dateTimeBetween( + $eventDateStart->format('Y-m-d'), + $endDate + ); return [ - 'id' => $id, 'title' => $title, - 'slug' => $slug, + 'slug' => Str::slug($title), 'content' => $content, - 'event_date_start' => $event_date_start, - 'event_time_start' => $event_time_start, - 'address' => $address, - 'is_online' => $is_online, - 'category_id' => $category_id, - 'created_at' => $created_at, - 'updated_at' => $updated_at, + 'event_date_start' => $eventDateStart->format('Y-m-d'), + 'event_date_end' => $eventDateEnd?->format('Y-m-d'), + 'event_time_start' => $this->faker->time('H:i'), + 'address' => $this->faker->address, + 'is_online' => $this->faker->boolean, + 'category_id' => EventCategory::factory(), + 'created_at' => $this->faker->dateTimeBetween('-6 months', 'now'), + 'updated_at' => $this->faker->dateTimeBetween('-6 months', 'now'), ]; - } } diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php index 9a1f715..7e9c9e4 100755 --- a/database/factories/PageFactory.php +++ b/database/factories/PageFactory.php @@ -18,7 +18,6 @@ class PageFactory extends Factory return [ 'title' => $title, 'slug' => $slug, - 'path' => $slug, 'content' => [['type' => 'paragraph', 'data' => ['content' => $this->faker->paragraph]]], 'is_registered' => false, 'is_visible' => true, diff --git a/database/factories/UserDetailFactory.php b/database/factories/UserDetailFactory.php index 31ac699..13a4095 100755 --- a/database/factories/UserDetailFactory.php +++ b/database/factories/UserDetailFactory.php @@ -43,12 +43,12 @@ class UserDetailFactory extends Factory 'photo' => "images/01J7TKMCR55KY7ARS2DA6VVAHE.jpg", 'academicTitle' => $this->faker->word, 'AcademicDegree' => $this->faker->word, - 'education' => $this->faker->sentence, + 'education' => [['item' => $this->faker->sentence(6)]], 'awards' => $awards, 'professDisciplines' => $generateItems(), 'professionalRetraining' => $generateItems(), 'professionalDevelopment' => $generateItems(), - 'workExperience' => $this->faker->randomDigit(), + 'workExperience' => $generateItems(), 'attendedConferences' => $generateItems(), 'publications' => $generateItems(), 'contactEmail' => $this->faker->unique()->safeEmail, diff --git a/database/migrations/2026_09_18_100000_create_analytics_sessions_table.php b/database/migrations/2026_09_18_100000_create_analytics_sessions_table.php new file mode 100644 index 0000000..771f4f5 --- /dev/null +++ b/database/migrations/2026_09_18_100000_create_analytics_sessions_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('visitor_id', 36)->index(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('entry_page'); + $table->string('ip', 45); + $table->string('country', 2)->nullable(); + $table->string('city')->nullable(); + $table->string('utm_source')->nullable(); + $table->string('utm_medium')->nullable(); + $table->string('utm_campaign')->nullable(); + $table->string('browser')->nullable(); + $table->string('os')->nullable(); + $table->enum('device_type', ['desktop', 'tablet', 'mobile'])->nullable(); + $table->timestamp('started_at'); + $table->timestamp('last_activity_at'); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + $table->index(['visitor_id', 'last_activity_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_sessions'); + } +}; diff --git a/database/migrations/2026_09_18_100001_create_analytics_hits_table.php b/database/migrations/2026_09_18_100001_create_analytics_hits_table.php new file mode 100644 index 0000000..0c7e0ea --- /dev/null +++ b/database/migrations/2026_09_18_100001_create_analytics_hits_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('session_id'); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('url'); + $table->string('referrer')->nullable(); + $table->string('title')->nullable(); + $table->timestamp('created_at'); + + $table->foreign('session_id')->references('id')->on('analytics_sessions')->cascadeOnDelete(); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + $table->index('url'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_hits'); + } +}; diff --git a/database/migrations/2026_09_18_100002_create_analytics_daily_stats_table.php b/database/migrations/2026_09_18_100002_create_analytics_daily_stats_table.php new file mode 100644 index 0000000..a414f7d --- /dev/null +++ b/database/migrations/2026_09_18_100002_create_analytics_daily_stats_table.php @@ -0,0 +1,27 @@ +id(); + $table->date('date'); + $table->string('url'); + $table->unsignedInteger('views_count')->default(0); + $table->unsignedInteger('unique_visitors')->default(0); + $table->timestamps(); + + $table->unique(['date', 'url']); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_daily_stats'); + } +}; diff --git a/database/migrations/2026_09_18_120000_remove_geo_from_analytics.php b/database/migrations/2026_09_18_120000_remove_geo_from_analytics.php new file mode 100644 index 0000000..6244b12 --- /dev/null +++ b/database/migrations/2026_09_18_120000_remove_geo_from_analytics.php @@ -0,0 +1,34 @@ +dropColumn(['country', 'city']); + }); + + Schema::dropIfExists('analytics_geo_cache'); + } + + public function down(): void + { + Schema::table('analytics_sessions', function (Blueprint $table) { + $table->string('country', 2)->nullable()->after('ip'); + $table->string('city')->nullable()->after('country'); + }); + + Schema::create('analytics_geo_cache', function (Blueprint $table) { + $table->id(); + $table->string('ip_hash', 64)->unique(); + $table->string('country')->nullable(); + $table->string('city')->nullable(); + $table->timestamp('cached_at'); + $table->index('cached_at'); + }); + } +}; diff --git a/database/seeders/AnalyticsMockSeeder.php b/database/seeders/AnalyticsMockSeeder.php new file mode 100644 index 0000000..8bc54c2 --- /dev/null +++ b/database/seeders/AnalyticsMockSeeder.php @@ -0,0 +1,96 @@ +command?->info('Creating analytics mock data (30 days)...'); + + for ($day = 0; $day < 30; $day++) { + $date = now()->subDays($day); + $sessionsCount = rand(8, 40); + + for ($s = 0; $s < $sessionsCount; $s++) { + $visitorId = Str::uuid(); + $hitsCount = rand(1, 12); + $browser = self::BROWSERS[array_rand(self::BROWSERS)]; + $os = self::OSSES[array_rand(self::OSSES)]; + $device = self::DEVICES[array_rand(self::DEVICES)]; + + $startedAt = $date->copy()->addHours(rand(0, 23))->addMinutes(rand(0, 59)); + $lastActivity = $startedAt->copy()->addSeconds(rand(10, 1800)); + + $session = AnalyticsSession::create([ + 'visitor_id' => $visitorId, + 'user_id' => rand(1, 10) > 8 ? User::inRandomOrder()->value('id') : null, + 'entry_page' => self::URLS[array_rand(self::URLS)], + 'ip' => long2ip(rand(0, 0xFFFFFF00)), + 'country' => self::COUNTRIES[array_rand(self::COUNTRIES)], + 'city' => self::CITIES[array_rand(self::CITIES)], + 'utm_source' => rand(1, 10) > 7 ? ['google', 'vk', 'telegram', 'dzen'][array_rand(['google', 'vk', 'telegram', 'dzen'])] : null, + 'utm_medium' => null, + 'utm_campaign' => null, + 'browser' => $browser, + 'os' => $os, + 'device_type' => $device, + 'started_at' => $startedAt, + 'last_activity_at' => $lastActivity, + ]); + + for ($h = 0; $h < $hitsCount; $h++) { + $hitTime = $startedAt->copy()->addSeconds(rand(0, max(1, $lastActivity->timestamp - $startedAt->timestamp))); + + AnalyticsHit::create([ + 'session_id' => $session->id, + 'user_id' => $session->user_id, + 'url' => self::URLS[array_rand(self::URLS)], + 'referrer' => self::SOURCES[array_rand(self::SOURCES)], + 'title' => null, + 'created_at' => $hitTime, + ]); + } + } + } + + $this->command?->info('Analytics mock: ' . AnalyticsSession::count() . ' sessions, ' . AnalyticsHit::count() . ' hits'); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 49ec0a3..f723e7d 100755 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -9,6 +9,7 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->call([ + RolesSeeder::class, MockDataSeeder::class, ]); } diff --git a/database/seeders/MockDataSeeder.php b/database/seeders/MockDataSeeder.php index 2813f0a..899db9d 100644 --- a/database/seeders/MockDataSeeder.php +++ b/database/seeders/MockDataSeeder.php @@ -16,8 +16,6 @@ use App\Containers\Education\Models\AdmissionCampaign; use App\Containers\Education\Models\AdmissionPlan; use App\Containers\Education\Models\DirectionStudy; use App\Containers\Education\Models\EducationalProgram; -use App\Containers\Event\Models\Event; -use App\Containers\Event\Models\EventCategory; use App\Containers\InstituteStructure\Models\Department; use App\Containers\InstituteStructure\Models\Division; use App\Containers\InstituteStructure\Models\Faculty; @@ -49,52 +47,61 @@ class MockDataSeeder extends Seeder // 1. Users & Roles $this->createUsers(); - // 2. Institute Structure - $faculties = Faculty::factory()->count(8)->create(); - $departments = Department::factory()->count(15)->create(); - $divisions = Division::factory()->count(5)->create(); + // 2. Institute Structure: 6 факультетов, по 2 кафедры на каждый + $faculties = Faculty::factory()->count(6)->create(); + + $departments = collect(); + foreach ($faculties as $faculty) { + $departments = $departments->merge( + Department::factory()->count(2)->create(['faculty_id' => $faculty->id]) + ); + } + + $divisions = Division::factory()->count(20)->create(); // 3. Pivot: workers_faculties, workers_departments, teachers_departments $this->attachWorkers($faculties, $departments); // 4. Article $categories = Category::factory()->count(20)->create(); - Post::factory()->count(100)->create(); + Post::factory()->count(1000)->create(); Tag::factory()->count(30)->create(); - // 5. Events - $eventCategories = EventCategory::factory()->count(8)->create(); - Event::factory()->count(50)->create(); - - // 6. Education + // 5. Education $directionStudies = DirectionStudy::factory()->count(10)->create(); - $programs = EducationalProgram::factory()->count(25)->create(); - $campaigns = AdmissionCampaign::factory()->count(5)->create(); - AdmissionPlan::factory()->count(30)->create(); + $programs = EducationalProgram::factory()->count(25) + ->sequence(fn () => ['direction_study_id' => $directionStudies->random()->id]) + ->create(); + $campaigns = AdmissionCampaign::factory()->count(6)->create(); + AdmissionPlan::factory()->count(30) + ->sequence(fn () => [ + 'admission_campaigns_id' => $campaigns->random()->id, + 'educational_programs_id' => $programs->random()->id, + ]) + ->create(); - // 7. Pivot: program_department + // 6. Pivot: program_department $this->attachProgramsToDepartments($programs, $departments); - // 8. Additional Education - $directions = DirectionAdditionalEducation::factory()->count(5)->create(); - $addCategories = AdditionalEducationCategory::factory()->count(12)->create(); + // 7. Additional Education + DirectionAdditionalEducation::factory()->count(5)->create(); + AdditionalEducationCategory::factory()->count(12)->create(); AdditionalEducation::factory()->count(20)->create(); - // 9. Science - $journals = AcademicJournal::factory()->count(4)->create(); + // 8. Science + AcademicJournal::factory()->count(4)->create(); JournalIssue::factory()->count(15)->create(); - // 10. Schedule + // 9. Schedule $groups = EducationalGroup::factory()->count(40)->create(); Schedule::factory()->count(90)->create(); - // 11. Widgets + // 10. Widgets: 1 главный слайдер + 5 слайдов Slider::create([ 'title' => 'Главный слайдер', 'slug' => 'quos-velit-quisquam', 'is_active' => true, ]); - Slider::factory()->count(4)->create(); $mainSlider = Slider::where('slug', 'quos-velit-quisquam')->first(); Slide::factory()->count(5)->create([ @@ -103,8 +110,8 @@ class MockDataSeeder extends Seeder 'start_time' => now()->subWeek(), 'end_time' => now()->addMonth(), ]); - Slide::factory()->count(15)->create(); + // 11. Contact & Reference widgets ContactWidget::create([ 'title' => 'Главная страница контакты', 'slug' => 'glavnaia-stranica-kontakty', @@ -130,7 +137,6 @@ class MockDataSeeder extends Seeder ], 'is_active' => true, ]); - ContactWidget::factory()->count(2)->create(); PageReferenceList::create([ 'title' => 'Главная страница ресурсы', @@ -142,8 +148,8 @@ class MockDataSeeder extends Seeder ], 'is_active' => true, ]); - PageReferenceList::factory()->count(4)->create(); - $forms = CustomForm::factory()->count(8)->create(); + + CustomForm::factory()->count(8)->create(); CustomFormResponse::factory()->count(40)->create(); // 12. App Structure @@ -152,10 +158,16 @@ class MockDataSeeder extends Seeder ->count(15) ->sequence(fn () => ['main_section_id' => $mainSections->random()->id]) ->create(); - Page::factory() - ->count(40) - ->sequence(fn () => ['sub_section_id' => $subSections->random()->id]) - ->create(); + + $pages = collect(); + for ($i = 0; $i < 40; $i++) { + $ss = $subSections->random(); + $ms = $ss->mainSection; + $page = Page::factory()->make(['sub_section_id' => $ss->id]); + $page->path = $ms->slug . '/' . $ss->slug . '/' . $page->slug; + $page->save(); + $pages->push($page); + } // 13. Integration Credentials IntegrationCredential::factory()->count(3)->create(); @@ -188,7 +200,6 @@ class MockDataSeeder extends Seeder ); User::factory()->count(50)->create(); - UserDetail::factory()->count(30)->create(); } diff --git a/docker-compose.yml b/docker-compose.yml index 8fbd653..8dcc1ff 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,7 +57,7 @@ services: volumes: - ./:/var/www container_name: ntspi-php-queue - command: php artisan queue:work --tries=3 --timeout=90 --sleep=3 + command: php artisan queue:work --queue=analytics,default --tries=3 --timeout=90 --sleep=3 depends_on: db: condition: service_healthy diff --git a/package-lock.json b/package-lock.json index 7b9cfc0..7e0b464 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,9 +19,9 @@ "@preline/overlay": "^1.4.0", "@preline/scrollspy": "^2.0.0", "@preline/select": "^2.5.0", - "@rollup/rollup-linux-arm64-gnu": "^4.60.1", "@vue/server-renderer": "^3.5.12", "@vuepic/vue-datepicker": "^11.0.2", + "chart.js": "^4.5.1", "daisyui": "^4.12.14", "flowbite": "^2.5.2", "fslightbox": "^3.4.1", @@ -31,6 +31,7 @@ "preline": "^1.9.0", "slugify": "^1.6.6", "vite": "^6.3.5", + "vue-chartjs": "^5.3.4", "vuex": "^4.1.0" }, "devDependencies": { @@ -666,6 +667,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1856,6 +1863,18 @@ "node": ">=0.8.0" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -9418,6 +9437,16 @@ } } }, + "node_modules/vue-chartjs": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz", + "integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, "node_modules/vuex": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vuex/-/vuex-4.1.0.tgz", diff --git a/package.json b/package.json index 0f38ebd..e2c46e9 100755 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@preline/select": "^2.5.0", "@vue/server-renderer": "^3.5.12", "@vuepic/vue-datepicker": "^11.0.2", + "chart.js": "^4.5.1", "daisyui": "^4.12.14", "flowbite": "^2.5.2", "fslightbox": "^3.4.1", @@ -51,6 +52,7 @@ "preline": "^1.9.0", "slugify": "^1.6.6", "vite": "^6.3.5", + "vue-chartjs": "^5.3.4", "vuex": "^4.1.0" }, "optionalDependencies": { diff --git a/resources/js/Pages/Dashboard/Analytics.vue b/resources/js/Pages/Dashboard/Analytics.vue new file mode 100644 index 0000000..27c377b --- /dev/null +++ b/resources/js/Pages/Dashboard/Analytics.vue @@ -0,0 +1,132 @@ + + + diff --git a/resources/js/Pages/Dashboard/AnalyticsSectionDetail.vue b/resources/js/Pages/Dashboard/AnalyticsSectionDetail.vue new file mode 100644 index 0000000..5d221db --- /dev/null +++ b/resources/js/Pages/Dashboard/AnalyticsSectionDetail.vue @@ -0,0 +1,184 @@ + + + diff --git a/resources/js/Pages/Dashboard/Components/menuConfig.js b/resources/js/Pages/Dashboard/Components/menuConfig.js index a84cf01..f67759a 100644 --- a/resources/js/Pages/Dashboard/Components/menuConfig.js +++ b/resources/js/Pages/Dashboard/Components/menuConfig.js @@ -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', + }, ]; diff --git a/resources/js/Pages/Dashboard/Components/shared/AnalyticsChart.vue b/resources/js/Pages/Dashboard/Components/shared/AnalyticsChart.vue new file mode 100644 index 0000000..746b18e --- /dev/null +++ b/resources/js/Pages/Dashboard/Components/shared/AnalyticsChart.vue @@ -0,0 +1,145 @@ + + + diff --git a/resources/js/Pages/Dashboard/Components/shared/AnalyticsDevices.vue b/resources/js/Pages/Dashboard/Components/shared/AnalyticsDevices.vue new file mode 100644 index 0000000..c82a1f9 --- /dev/null +++ b/resources/js/Pages/Dashboard/Components/shared/AnalyticsDevices.vue @@ -0,0 +1,117 @@ + + + diff --git a/resources/js/Pages/Dashboard/Components/shared/AnalyticsNavigationSections.vue b/resources/js/Pages/Dashboard/Components/shared/AnalyticsNavigationSections.vue new file mode 100644 index 0000000..8a5fbb0 --- /dev/null +++ b/resources/js/Pages/Dashboard/Components/shared/AnalyticsNavigationSections.vue @@ -0,0 +1,183 @@ + + + diff --git a/resources/js/Pages/Dashboard/Components/shared/AnalyticsOverview.vue b/resources/js/Pages/Dashboard/Components/shared/AnalyticsOverview.vue new file mode 100644 index 0000000..7e7099b --- /dev/null +++ b/resources/js/Pages/Dashboard/Components/shared/AnalyticsOverview.vue @@ -0,0 +1,113 @@ + + + diff --git a/resources/js/Pages/Dashboard/Components/shared/AnalyticsPeriodFilter.vue b/resources/js/Pages/Dashboard/Components/shared/AnalyticsPeriodFilter.vue new file mode 100644 index 0000000..05d69a8 --- /dev/null +++ b/resources/js/Pages/Dashboard/Components/shared/AnalyticsPeriodFilter.vue @@ -0,0 +1,45 @@ + + + diff --git a/resources/js/Pages/Dashboard/Components/shared/AnalyticsReferrers.vue b/resources/js/Pages/Dashboard/Components/shared/AnalyticsReferrers.vue new file mode 100644 index 0000000..775108c --- /dev/null +++ b/resources/js/Pages/Dashboard/Components/shared/AnalyticsReferrers.vue @@ -0,0 +1,36 @@ + + + diff --git a/resources/js/Pages/Dashboard/Components/shared/AnalyticsSections.vue b/resources/js/Pages/Dashboard/Components/shared/AnalyticsSections.vue new file mode 100644 index 0000000..c847f81 --- /dev/null +++ b/resources/js/Pages/Dashboard/Components/shared/AnalyticsSections.vue @@ -0,0 +1,51 @@ + + + diff --git a/resources/js/Pages/Dashboard/Components/shared/AnalyticsTopPages.vue b/resources/js/Pages/Dashboard/Components/shared/AnalyticsTopPages.vue new file mode 100644 index 0000000..6761fa5 --- /dev/null +++ b/resources/js/Pages/Dashboard/Components/shared/AnalyticsTopPages.vue @@ -0,0 +1,50 @@ + + + diff --git a/resources/js/Pages/Dashboard/Components/shared/ConsentBanner.vue b/resources/js/Pages/Dashboard/Components/shared/ConsentBanner.vue new file mode 100644 index 0000000..741fc90 --- /dev/null +++ b/resources/js/Pages/Dashboard/Components/shared/ConsentBanner.vue @@ -0,0 +1,73 @@ + + + diff --git a/resources/js/app.js b/resources/js/app.js index a706807..d11a9ec 100755 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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, - }); - } - }); }); \ No newline at end of file diff --git a/resources/js/services/analytics.js b/resources/js/services/analytics.js new file mode 100644 index 0000000..7615941 --- /dev/null +++ b/resources/js/services/analytics.js @@ -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 +} diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index f8cf5f8..20c4139 100755 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -54,28 +54,43 @@ @inertia -@if(config('services.yandex_metrika.id') && app()->environment('production')) - - - - -@endif + + \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index a814366..471f45f 100755 --- a/routes/api.php +++ b/routes/api.php @@ -1 +1,3 @@ -