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:
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Commands;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsDailyStat;
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AggregateAnalyticsCommand extends \Illuminate\Console\Command
|
||||
{
|
||||
protected $signature = 'analytics:aggregate {--days=1 : Days back to aggregate}';
|
||||
|
||||
protected $description = 'Aggregate analytics hits into daily stats';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Jobs;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use App\Containers\Analytics\Models\AnalyticsSession;
|
||||
use App\Containers\Analytics\Services\DeviceDetectorService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RecordVisitJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 15;
|
||||
|
||||
public function __construct(
|
||||
public array $data,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
DeviceDetectorService $detector,
|
||||
): void {
|
||||
$userAgent = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AnalyticsDailyStat extends Model
|
||||
{
|
||||
protected $table = 'analytics_daily_stats';
|
||||
|
||||
protected $fillable = [
|
||||
'date',
|
||||
'url',
|
||||
'views_count',
|
||||
'unique_visitors',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'views_count' => 'integer',
|
||||
'unique_visitors' => 'integer',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AnalyticsHit extends Model
|
||||
{
|
||||
protected $table = 'analytics_hits';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'session_id',
|
||||
'user_id',
|
||||
'url',
|
||||
'referrer',
|
||||
'title',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => '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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AnalyticsSession extends Model
|
||||
{
|
||||
protected $table = 'analytics_sessions';
|
||||
|
||||
protected $fillable = [
|
||||
'visitor_id',
|
||||
'user_id',
|
||||
'entry_page',
|
||||
'ip',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'browser',
|
||||
'os',
|
||||
'device_type',
|
||||
'started_at',
|
||||
'last_activity_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'started_at' => '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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
class AnalyticsServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Services;
|
||||
|
||||
use WhichBrowser\Parser;
|
||||
|
||||
class DeviceDetectorService
|
||||
{
|
||||
public function parse(string $userAgent): array
|
||||
{
|
||||
$parser = new Parser($userAgent);
|
||||
|
||||
$browser = $parser->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Services;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsGeoCache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GeoIpService
|
||||
{
|
||||
private const CACHE_TTL_DAYS = 7;
|
||||
|
||||
public function locate(string $ip): ?array
|
||||
{
|
||||
$ipHash = sha1($ip);
|
||||
|
||||
$cached = AnalyticsGeoCache::where('ip_hash', $ipHash)
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Tasks;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use App\Containers\Analytics\Models\AnalyticsSession;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GetAnalyticsOverviewTask
|
||||
{
|
||||
public function run(int $days = 30): array
|
||||
{
|
||||
$from = Carbon::now()->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Tasks;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsDailyStat;
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GetDailyStatsTask
|
||||
{
|
||||
public function run(int $days = 30): array
|
||||
{
|
||||
$from = Carbon::now()->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Tasks;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsSession;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class GetDevicesTask
|
||||
{
|
||||
public function run(int $days = 30): array
|
||||
{
|
||||
$from = Carbon::now()->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Tasks;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use App\Containers\AppStructure\Models\MainSection;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class GetNavigationSectionsTask
|
||||
{
|
||||
public function run(int $days = 30): array
|
||||
{
|
||||
$from = Carbon::now()->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', []),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Tasks;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GetReferrersTask
|
||||
{
|
||||
public function run(int $days = 30, int $limit = 10): array
|
||||
{
|
||||
$from = Carbon::now()->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Tasks;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use App\Containers\Analytics\Models\AnalyticsSession;
|
||||
use App\Containers\AppStructure\Models\MainSection;
|
||||
use App\Containers\AppStructure\Models\Page;
|
||||
use App\Containers\AppStructure\Models\SubSection;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class GetSectionDetailTask
|
||||
{
|
||||
public function run(string $prefix, int $days = 30): array
|
||||
{
|
||||
$from = Carbon::now()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Tasks;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use App\Containers\AppStructure\Models\MainSection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GetSectionsStatsTask
|
||||
{
|
||||
public function run(int $days = 30): array
|
||||
{
|
||||
$from = Carbon::now()->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\Tasks;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GetTopPagesTask
|
||||
{
|
||||
public function run(int $days = 7, int $limit = 10): array
|
||||
{
|
||||
return AnalyticsHit::select('url')
|
||||
->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\UI\WEB\Controllers;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsDailyStat;
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use App\Containers\Analytics\Models\AnalyticsSession;
|
||||
use App\Containers\Analytics\Tasks\GetAnalyticsOverviewTask;
|
||||
use App\Containers\Analytics\Tasks\GetDailyStatsTask;
|
||||
use App\Containers\Analytics\Tasks\GetDevicesTask;
|
||||
use App\Containers\Analytics\Tasks\GetNavigationSectionsTask;
|
||||
use App\Containers\Analytics\Tasks\GetReferrersTask;
|
||||
use App\Containers\Analytics\Tasks\GetSectionDetailTask;
|
||||
use App\Containers\Analytics\Tasks\GetTopPagesTask;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Response;
|
||||
|
||||
class AnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GetAnalyticsOverviewTask $overviewTask,
|
||||
private readonly GetTopPagesTask $topPagesTask,
|
||||
private readonly GetDailyStatsTask $dailyStatsTask,
|
||||
private readonly GetReferrersTask $referrersTask,
|
||||
private readonly GetDevicesTask $devicesTask,
|
||||
private readonly GetNavigationSectionsTask $navigationSectionsTask,
|
||||
private readonly GetSectionDetailTask $sectionDetailTask,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$days = (int) $request->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', 'Данные аналитики очищены');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Containers\Analytics\UI\WEB\Controllers;
|
||||
|
||||
use App\Containers\Analytics\Jobs\RecordVisitJob;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TrackController extends Controller
|
||||
{
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
$payload = [
|
||||
'visitor_id' => $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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Containers\Analytics\UI\WEB\Controllers\TrackController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/track/hit', [TrackController::class, 'store'])
|
||||
->middleware(['throttle:60,1'])
|
||||
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
// Route is registered in Dashboard/UI/WEB/Routes/web.php
|
||||
@@ -431,6 +431,11 @@ Route::middleware(['access-check', 'dashboard.auth', 'dashboard.permission'])->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');
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ class HandleInertiaRequests extends Middleware
|
||||
}
|
||||
return 'empty';
|
||||
},
|
||||
'yandex_metrika_id' => config('services.yandex_metrika.id'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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"
|
||||
}]
|
||||
}
|
||||
|
||||
Generated
+64
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'group_labels' => [
|
||||
'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',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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(),
|
||||
|
||||
/*
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
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'),
|
||||
];
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('analytics_sessions', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('analytics_hits', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('analytics_daily_stats', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('analytics_sessions', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Containers\Analytics\Models\AnalyticsHit;
|
||||
use App\Containers\Analytics\Models\AnalyticsSession;
|
||||
use App\Containers\User\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AnalyticsMockSeeder extends Seeder
|
||||
{
|
||||
private const URLS = [
|
||||
'/', '/news', '/faculties', '/faculties/1', '/faculties/2', '/faculties/3',
|
||||
'/departments', '/departments/1', '/departments/2', '/departments/3',
|
||||
'/schedules', '/schedules/1', '/schedules/2', '/events', '/events/1',
|
||||
'/additional-educations', '/additional-educations/1',
|
||||
'/page/abiturientam', '/page/studentam', '/page/nauchnaya-rabota',
|
||||
'/page/ob-institute', '/page/struktura', '/page/obrazovanie',
|
||||
'/news/1', '/news/2', '/news/3', '/news/4', '/news/5',
|
||||
'/news/6', '/news/7', '/news/8', '/news/9', '/news/10',
|
||||
'/departments/1/workers', '/departments/2/workers', '/departments/3/workers',
|
||||
'/faculties/1/workers', '/faculties/2/workers',
|
||||
];
|
||||
|
||||
private const BROWSERS = ['Chrome', 'Chrome', 'Chrome', 'YandexBrowser', 'Firefox', 'Safari', 'Edge'];
|
||||
private const OSSES = ['Windows', 'Windows', 'Windows', 'macOS', 'Linux', 'Android', 'iOS'];
|
||||
private const DEVICES = ['desktop', 'desktop', 'desktop', 'desktop', 'mobile', 'mobile', 'tablet'];
|
||||
private const COUNTRIES = ['RU', 'RU', 'RU', 'RU', 'BY', 'KZ', 'UZ'];
|
||||
private const CITIES = [
|
||||
'Moscow', 'Saint Petersburg', 'Novosibirsk', 'Yekaterinburg', 'Kazan',
|
||||
'Nizhny Tagil', 'Chelyabinsk', 'Minsk', 'Almaty', 'Tashkent',
|
||||
];
|
||||
private const SOURCES = [
|
||||
null, null, null, null, null,
|
||||
'https://www.google.com/search?q=ntspi',
|
||||
'https://yandex.ru/search/?text=ntspi',
|
||||
'https://vk.com/ntspi_official',
|
||||
'https://t.me/ntspi_news',
|
||||
'https://dzen.ru/a/ntspi',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class DatabaseSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
RolesSeeder::class,
|
||||
MockDataSeeder::class,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Generated
+30
-1
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
<?php
|
||||
<?php
|
||||
|
||||
require base_path('app/Containers/Analytics/UI/WEB/Routes/api.php');
|
||||
|
||||
Reference in New Issue
Block a user