diff --git a/PLAN.md b/PLAN.md index 725fbd3..407f351 100644 --- a/PLAN.md +++ b/PLAN.md @@ -45,29 +45,29 @@ Individual report pages accessible from the dashboard sidebar. ### 3a — Audience Reports -- [ ] **Geographic report** — visitors by country, region, city (table + world map visualization) -- [ ] **Technology report** — breakdown by browser, OS, device type, screen resolution -- [ ] **Language report** — visitors by browser language -- [ ] **New vs. returning visitors** — segment visitors who have more than one session +- [x] **Geographic report** — visitors by country, region, city (table + world map visualization) +- [x] **Technology report** — breakdown by browser, OS, device type, screen resolution +- [x] **Language report** — visitors by browser language +- [x] **New vs. returning visitors** — segment visitors who have more than one session ### 3b — Acquisition Reports -- [ ] **Traffic sources** — breakdown by referrer domain (direct, organic, referral, social) -- [ ] **Campaigns (UTM)** — table of sessions grouped by `utm_source`, `utm_medium`, `utm_campaign` -- [ ] **Referrers** — top referring URLs and domains +- [x] **Traffic sources** — breakdown by referrer domain (direct, organic, referral, social) +- [x] **Campaigns (UTM)** — table of sessions grouped by `utm_source`, `utm_medium`, `utm_campaign` +- [x] **Referrers** — top referring URLs and domains ### 3c — Behavior Reports -- [ ] **Pages report** — top pages by views, unique views, avg. time on page, bounce rate -- [ ] **Landing pages** — top entry pages with session count and bounce rate -- [ ] **Exit pages** — top exit pages +- [x] **Pages report** — top pages by views, unique views, avg. time on page, bounce rate +- [x] **Landing pages** — top entry pages with session count and bounce rate +- [x] **Exit pages** — top exit pages - [ ] **User flow** — simplified Sankey or funnel showing common page sequences (can be deferred) ### 3d — Events Report -- [ ] **Events overview** — table of events grouped by name, category, action -- [ ] **Event detail** — drill-down into a single event name with label/value breakdown -- [ ] **Event trending** — chart of event counts over time +- [x] **Events overview** — table of events grouped by name, category, action +- [x] **Event detail** — drill-down into a single event name with label/value breakdown +- [x] **Event trending** — chart of event counts over time --- diff --git a/app/Http/Controllers/Concerns/ParsesDateRange.php b/app/Http/Controllers/Concerns/ParsesDateRange.php new file mode 100644 index 0000000..d0f1be3 --- /dev/null +++ b/app/Http/Controllers/Concerns/ParsesDateRange.php @@ -0,0 +1,34 @@ +get('range', '7d'); + $to = Carbon::today(); + + $from = match ($range) { + 'today' => Carbon::today(), + '7d' => Carbon::today()->subDays(6), + '30d' => Carbon::today()->subDays(29), + '90d' => Carbon::today()->subDays(89), + 'custom' => Carbon::parse($request->get('from', Carbon::today()->subDays(6)->toDateString())), + default => Carbon::today()->subDays(6), + }; + + if ($range === 'custom' && $request->has('to')) { + $to = Carbon::parse($request->get('to')); + } + + if ($from->gt($to)) { + [$from, $to] = [$to, $from]; + } + + return [$from, $to]; + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 08eff82..dbb6e1b 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -2,15 +2,17 @@ namespace App\Http\Controllers; +use App\Http\Controllers\Concerns\ParsesDateRange; use App\Models\Website; use App\Services\AnalyticsQueryService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Carbon; use Illuminate\View\View; class DashboardController extends Controller { + use ParsesDateRange; + public function show(Request $request, Website $website): View { $this->authorize('view', $website); @@ -35,30 +37,4 @@ class DashboardController extends Controller 'active' => AnalyticsQueryService::activeVisitors($website), ]); } - - private function parseDateRange(Request $request): array - { - $range = $request->get('range', '7d'); - - $to = Carbon::today(); - - $from = match ($range) { - 'today' => Carbon::today(), - '7d' => Carbon::today()->subDays(6), - '30d' => Carbon::today()->subDays(29), - '90d' => Carbon::today()->subDays(89), - 'custom' => Carbon::parse($request->get('from', Carbon::today()->subDays(6)->toDateString())), - default => Carbon::today()->subDays(6), - }; - - if ($range === 'custom' && $request->has('to')) { - $to = Carbon::parse($request->get('to')); - } - - if ($from->gt($to)) { - [$from, $to] = [$to, $from]; - } - - return [$from, $to]; - } } diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php new file mode 100644 index 0000000..5eae8d2 --- /dev/null +++ b/app/Http/Controllers/ReportController.php @@ -0,0 +1,196 @@ +authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.audience.geographic', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'countries' => $service->visitorsByCountry(), + 'cities' => $service->visitorsByCity(), + ]); + } + + public function technology(Request $request, Website $website): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.audience.technology', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'browsers' => $service->visitorsByBrowser(), + 'operatingSystems' => $service->visitorsByOS(), + 'devices' => $service->visitorsByDeviceType(), + 'resolutions' => $service->visitorsByScreenResolution(), + ]); + } + + public function language(Request $request, Website $website): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.audience.language', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'languages' => $service->visitorsByLanguage(), + ]); + } + + public function newVsReturning(Request $request, Website $website): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.audience.new-vs-returning', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'data' => $service->newVsReturning(), + ]); + } + + // ---- Acquisition ---- + + public function sources(Request $request, Website $website): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.acquisition.sources', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'sources' => $service->trafficSources(), + ]); + } + + public function campaigns(Request $request, Website $website): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.acquisition.campaigns', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'campaigns' => $service->campaigns(), + ]); + } + + public function referrers(Request $request, Website $website): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.acquisition.referrers', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'referrers' => $service->referrers(), + ]); + } + + // ---- Behavior ---- + + public function pages(Request $request, Website $website): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.behavior.pages', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'pages' => $service->topPages(), + ]); + } + + public function landingPages(Request $request, Website $website): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.behavior.landing-pages', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'pages' => $service->landingPages(), + ]); + } + + public function exitPages(Request $request, Website $website): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.behavior.exit-pages', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'pages' => $service->exitPages(), + ]); + } + + // ---- Events ---- + + public function events(Request $request, Website $website): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.events.overview', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'events' => $service->eventsOverview(), + ]); + } + + public function eventDetail(Request $request, Website $website, string $eventName): View + { + $this->authorize('view', $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return view('reports.events.detail', [ + 'website' => $website, + 'from' => $from, + 'to' => $to, + 'eventName' => $eventName, + 'details' => $service->eventDetail($eventName), + 'trending' => $service->eventTrending($eventName), + ]); + } +} diff --git a/app/Services/AnalyticsQueryService.php b/app/Services/AnalyticsQueryService.php index 5c795e8..400ef47 100644 --- a/app/Services/AnalyticsQueryService.php +++ b/app/Services/AnalyticsQueryService.php @@ -146,6 +146,292 @@ class AnalyticsQueryService return $periods; } + // ---- Audience: Geographic ---- + + public function visitorsByCountry(int $limit = 50): Collection + { + return Visitor::where('website_id', $this->website->id) + ->whereNotNull('country') + ->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to])) + ->select('country', DB::raw('COUNT(*) as visitors')) + ->groupBy('country') + ->orderByDesc('visitors') + ->limit($limit) + ->get(); + } + + public function visitorsByCity(int $limit = 50): Collection + { + return Visitor::where('website_id', $this->website->id) + ->whereNotNull('city') + ->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to])) + ->select('country', 'region', 'city', DB::raw('COUNT(*) as visitors')) + ->groupBy('country', 'region', 'city') + ->orderByDesc('visitors') + ->limit($limit) + ->get(); + } + + // ---- Audience: Technology ---- + + public function visitorsByBrowser(int $limit = 20): Collection + { + return $this->visitorGroupedBy('browser', $limit); + } + + public function visitorsByOS(int $limit = 20): Collection + { + return $this->visitorGroupedBy('os', $limit); + } + + public function visitorsByDeviceType(int $limit = 10): Collection + { + return $this->visitorGroupedBy('device_type', $limit); + } + + public function visitorsByScreenResolution(int $limit = 20): Collection + { + return $this->visitorGroupedBy('screen_resolution', $limit); + } + + // ---- Audience: Language ---- + + public function visitorsByLanguage(int $limit = 20): Collection + { + return $this->visitorGroupedBy('language', $limit); + } + + // ---- Audience: New vs Returning ---- + + public function newVsReturning(): array + { + $activeVisitorIds = PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$this->from, $this->to]) + ->distinct() + ->pluck('visitor_id'); + + $total = $activeVisitorIds->count(); + if ($total === 0) { + return ['new' => 0, 'returning' => 0, 'new_pct' => 0, 'returning_pct' => 0]; + } + + $returning = AnalyticsSession::where('website_id', $this->website->id) + ->whereIn('visitor_id', $activeVisitorIds) + ->select('visitor_id') + ->groupBy('visitor_id') + ->havingRaw('COUNT(*) > 1') + ->get() + ->count(); + + $new = $total - $returning; + + return [ + 'new' => $new, + 'returning' => $returning, + 'new_pct' => round(($new / $total) * 100, 1), + 'returning_pct' => round(($returning / $total) * 100, 1), + ]; + } + + // ---- Acquisition: Traffic Sources ---- + + public function trafficSources(int $limit = 50): Collection + { + return AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + ->select( + DB::raw("COALESCE(NULLIF(referrer_domain, ''), 'Direct') as source"), + DB::raw('COUNT(*) as sessions'), + DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'), + DB::raw('AVG(duration) as avg_duration'), + DB::raw('AVG(pages_count) as avg_pages'), + ) + ->groupBy('source') + ->orderByDesc('sessions') + ->limit($limit) + ->get(); + } + + // ---- Acquisition: Campaigns (UTM) ---- + + public function campaigns(int $limit = 50): Collection + { + return AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + ->whereNotNull('utm_source') + ->select( + 'utm_source', 'utm_medium', 'utm_campaign', + DB::raw('COUNT(*) as sessions'), + DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'), + DB::raw('AVG(duration) as avg_duration'), + ) + ->groupBy('utm_source', 'utm_medium', 'utm_campaign') + ->orderByDesc('sessions') + ->limit($limit) + ->get(); + } + + // ---- Acquisition: Referrers ---- + + public function referrers(int $limit = 50): Collection + { + return AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + ->whereNotNull('referrer_domain') + ->where('referrer_domain', '!=', '') + ->select( + 'referrer_domain', + DB::raw('COUNT(*) as sessions'), + DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'), + DB::raw('AVG(duration) as avg_duration'), + ) + ->groupBy('referrer_domain') + ->orderByDesc('sessions') + ->limit($limit) + ->get(); + } + + // ---- Behavior: Pages ---- + + public function topPages(int $limit = 50): Collection + { + return PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$this->from, $this->to]) + ->select( + 'path', + DB::raw('COUNT(*) as views'), + DB::raw('COUNT(DISTINCT visitor_id) as unique_views'), + DB::raw('AVG(time_on_page) as avg_time'), + ) + ->groupBy('path') + ->orderByDesc('views') + ->limit($limit) + ->get(); + } + + // ---- Behavior: Landing Pages ---- + + public function landingPages(int $limit = 50): Collection + { + return AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + ->whereNotNull('entry_page') + ->select( + 'entry_page as path', + DB::raw('COUNT(*) as sessions'), + DB::raw('SUM(CASE WHEN is_bounce = 1 THEN 1 ELSE 0 END) as bounces'), + DB::raw('AVG(duration) as avg_duration'), + ) + ->groupBy('entry_page') + ->orderByDesc('sessions') + ->limit($limit) + ->get(); + } + + // ---- Behavior: Exit Pages ---- + + public function exitPages(int $limit = 50): Collection + { + return AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + ->whereNotNull('exit_page') + ->select( + 'exit_page as path', + DB::raw('COUNT(*) as exits'), + ) + ->groupBy('exit_page') + ->orderByDesc('exits') + ->limit($limit) + ->get(); + } + + // ---- Events: Overview ---- + + public function eventsOverview(int $limit = 50): Collection + { + return \App\Models\Event::where('website_id', $this->website->id) + ->whereBetween('occurred_at', [$this->from, $this->to]) + ->select( + 'name', + 'category', + DB::raw('COUNT(*) as total'), + DB::raw('COUNT(DISTINCT visitor_id) as unique_visitors'), + DB::raw('SUM(CAST(value AS REAL)) as total_value'), + ) + ->groupBy('name', 'category') + ->orderByDesc('total') + ->limit($limit) + ->get(); + } + + // ---- Events: Detail ---- + + public function eventDetail(string $eventName): Collection + { + return \App\Models\Event::where('website_id', $this->website->id) + ->whereBetween('occurred_at', [$this->from, $this->to]) + ->where('name', $eventName) + ->select( + 'action', 'label', + DB::raw('COUNT(*) as total'), + DB::raw('SUM(CAST(value AS REAL)) as total_value'), + ) + ->groupBy('action', 'label') + ->orderByDesc('total') + ->get(); + } + + // ---- Events: Trending ---- + + public function eventTrending(string $eventName): Collection + { + $days = $this->from->diffInDays($this->to); + $groupFormat = $days <= 2 ? '%Y-%m-%d %H:00' : '%Y-%m-%d'; + $carbonFormat = $days <= 2 ? 'Y-m-d H:00' : 'Y-m-d'; + + $data = \App\Models\Event::where('website_id', $this->website->id) + ->whereBetween('occurred_at', [$this->from, $this->to]) + ->where('name', $eventName) + ->select( + DB::raw("strftime('{$groupFormat}', occurred_at) as period"), + DB::raw('COUNT(*) as total'), + ) + ->groupBy('period') + ->orderBy('period') + ->get() + ->keyBy('period'); + + $periods = collect(); + $cursor = $this->from->copy(); + $increment = $days <= 2 ? 'addHour' : 'addDay'; + + while ($cursor->lte($this->to)) { + $key = $cursor->format($carbonFormat); + $periods->push([ + 'period' => $key, + 'total' => $data->get($key)?->total ?? 0, + ]); + $cursor->$increment(); + } + + return $periods; + } + + // ---- Helpers ---- + + private function visitorGroupedBy(string $column, int $limit): Collection + { + return Visitor::where('website_id', $this->website->id) + ->whereNotNull($column) + ->where($column, '!=', '') + ->whereHas('pageViews', fn ($q) => $q->whereBetween('viewed_at', [$this->from, $this->to])) + ->select($column, DB::raw('COUNT(*) as visitors')) + ->groupBy($column) + ->orderByDesc('visitors') + ->limit($limit) + ->get(); + } + // ---- Real-Time ---- public static function activeVisitors(Website $website, int $minutes = 5): int diff --git a/resources/views/components/date-range-picker.blade.php b/resources/views/components/date-range-picker.blade.php index f2a25fd..5576d57 100644 --- a/resources/views/components/date-range-picker.blade.php +++ b/resources/views/components/date-range-picker.blade.php @@ -2,6 +2,8 @@ @php $currentRange = request('range', '7d'); + $currentRouteName = request()->route()->getName(); + $currentRouteParams = request()->route()->parameters(); $presets = [ 'today' => 'Today', '7d' => 'Last 7 days', @@ -34,7 +36,7 @@
@foreach ($presets as $key => $label) {{ $label }} @@ -43,7 +45,7 @@
-
+
diff --git a/resources/views/components/report-layout.blade.php b/resources/views/components/report-layout.blade.php new file mode 100644 index 0000000..a56b6e8 --- /dev/null +++ b/resources/views/components/report-layout.blade.php @@ -0,0 +1,72 @@ +@props(['website', 'from', 'to', 'title']) + + + +
+
+ + + + + +

{{ $title }}

+ {{ $website->domain }} +
+ +
+ + +
+
+
+ {{-- Sidebar --}} + + + {{-- Main Content --}} +
+ {{ $slot }} +
+
+
+
+ + @isset($scripts) + @push('scripts') + {{ $scripts }} + @endpush + @endisset + diff --git a/resources/views/components/report-table.blade.php b/resources/views/components/report-table.blade.php new file mode 100644 index 0000000..ebdce90 --- /dev/null +++ b/resources/views/components/report-table.blade.php @@ -0,0 +1,24 @@ +@props(['headers', 'empty' => 'No data available for this period.']) + +
+
+ + + + @foreach ($headers as $header) + + @endforeach + + + + {{ $slot }} + +
+ {{ $header }} +
+ + @if (trim((string) $slot) === '') +
{{ $empty }}
+ @endif +
+
diff --git a/resources/views/dashboard/show.blade.php b/resources/views/dashboard/show.blade.php index f526124..22f538f 100644 --- a/resources/views/dashboard/show.blade.php +++ b/resources/views/dashboard/show.blade.php @@ -92,6 +92,45 @@
+ {{-- Quick Report Links --}} +
+

Reports

+ @php $q = request()->only(['range', 'from', 'to']); @endphp +
+
+

Audience

+ +
+
+

Acquisition

+ +
+
+

Behavior

+ +
+
+

Events

+ +
+
+
+ diff --git a/resources/views/reports/acquisition/campaigns.blade.php b/resources/views/reports/acquisition/campaigns.blade.php new file mode 100644 index 0000000..ea2b27d --- /dev/null +++ b/resources/views/reports/acquisition/campaigns.blade.php @@ -0,0 +1,36 @@ + + +
+ @if ($campaigns->isEmpty()) +
No campaign data available. Traffic with UTM parameters will appear here.
+ @else +
+ + + + + + + + + + + + + @foreach ($campaigns as $row) + + + + + + + + + @endforeach + +
SourceMediumCampaignSessionsBounce RateAvg. Duration
{{ $row->utm_source }}{{ $row->utm_medium ?? '—' }}{{ $row->utm_campaign ?? '—' }}{{ number_format($row->sessions) }}{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%{{ gmdate('i:s', (int) round($row->avg_duration)) }}
+
+ @endif +
+ +
diff --git a/resources/views/reports/acquisition/referrers.blade.php b/resources/views/reports/acquisition/referrers.blade.php new file mode 100644 index 0000000..2f68cd5 --- /dev/null +++ b/resources/views/reports/acquisition/referrers.blade.php @@ -0,0 +1,32 @@ + + +
+ @if ($referrers->isEmpty()) +
No referrer data available for this period.
+ @else +
+ + + + + + + + + + + @foreach ($referrers as $row) + + + + + + + @endforeach + +
Referrer DomainSessionsBounce RateAvg. Duration
{{ $row->referrer_domain }}{{ number_format($row->sessions) }}{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%{{ gmdate('i:s', (int) round($row->avg_duration)) }}
+
+ @endif +
+ +
diff --git a/resources/views/reports/acquisition/sources.blade.php b/resources/views/reports/acquisition/sources.blade.php new file mode 100644 index 0000000..9ab38e2 --- /dev/null +++ b/resources/views/reports/acquisition/sources.blade.php @@ -0,0 +1,34 @@ + + +
+ @if ($sources->isEmpty()) +
No traffic source data available for this period.
+ @else +
+ + + + + + + + + + + + @foreach ($sources as $row) + + + + + + + + @endforeach + +
SourceSessionsBounce RateAvg. DurationPages / Session
{{ $row->source }}{{ number_format($row->sessions) }}{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%{{ gmdate('i:s', (int) round($row->avg_duration)) }}{{ round($row->avg_pages, 1) }}
+
+ @endif +
+ +
diff --git a/resources/views/reports/audience/geographic.blade.php b/resources/views/reports/audience/geographic.blade.php new file mode 100644 index 0000000..d13ed22 --- /dev/null +++ b/resources/views/reports/audience/geographic.blade.php @@ -0,0 +1,64 @@ + + +
+
+

Visitors by Country

+ @if ($countries->isEmpty()) +

No geographic data available for this period.

+ @else +
+ + + + + + + + + + @php $totalCountry = $countries->sum('visitors'); @endphp + @foreach ($countries as $row) + + + + + + @endforeach + +
CountryVisitors%
{{ $row->country }}{{ number_format($row->visitors) }}{{ $totalCountry > 0 ? round(($row->visitors / $totalCountry) * 100, 1) : 0 }}%
+
+ @endif +
+ +
+

Visitors by City

+ @if ($cities->isEmpty()) +

No city-level data available.

+ @else +
+ + + + + + + + + + + @foreach ($cities as $row) + + + + + + + @endforeach + +
CityRegionCountryVisitors
{{ $row->city }}{{ $row->region }}{{ $row->country }}{{ number_format($row->visitors) }}
+
+ @endif +
+
+ +
diff --git a/resources/views/reports/audience/language.blade.php b/resources/views/reports/audience/language.blade.php new file mode 100644 index 0000000..f4dd7b5 --- /dev/null +++ b/resources/views/reports/audience/language.blade.php @@ -0,0 +1,25 @@ + + +
+

Visitors by Language

+ @if ($languages->isNotEmpty()) + @php $total = $languages->sum('visitors'); @endphp +
+ @foreach ($languages as $row) +
+
+ {{ $row->language }} + {{ number_format($row->visitors) }} ({{ $total > 0 ? round(($row->visitors / $total) * 100, 1) : 0 }}%) +
+
+
+
+
+ @endforeach +
+ @else +

No language data available for this period.

+ @endif +
+ +
diff --git a/resources/views/reports/audience/new-vs-returning.blade.php b/resources/views/reports/audience/new-vs-returning.blade.php new file mode 100644 index 0000000..3abf96b --- /dev/null +++ b/resources/views/reports/audience/new-vs-returning.blade.php @@ -0,0 +1,33 @@ + + +
+ @if ($data['new'] === 0 && $data['returning'] === 0) +

No visitor data available for this period.

+ @else +
+
+
{{ $data['new_pct'] }}%
+
New Visitors
+
{{ number_format($data['new']) }} visitors
+
+
+
{{ $data['returning_pct'] }}%
+
Returning Visitors
+
{{ number_format($data['returning']) }} visitors
+
+
+ +
+
+
+
+
+
+ New + Returning +
+
+ @endif +
+ +
diff --git a/resources/views/reports/audience/technology.blade.php b/resources/views/reports/audience/technology.blade.php new file mode 100644 index 0000000..47cec60 --- /dev/null +++ b/resources/views/reports/audience/technology.blade.php @@ -0,0 +1,99 @@ + + +
+ {{-- Device Type --}} +
+

Device Type

+ @if ($devices->isNotEmpty()) + @php $totalDevices = $devices->sum('visitors'); @endphp +
+ @foreach ($devices as $row) +
+
{{ $totalDevices > 0 ? round(($row->visitors / $totalDevices) * 100, 1) : 0 }}%
+
{{ $row->device_type ?? 'Unknown' }}
+
{{ number_format($row->visitors) }} visitors
+
+ @endforeach +
+ @else +

No device data available.

+ @endif +
+ +
+ {{-- Browsers --}} +
+

Browsers

+ @if ($browsers->isNotEmpty()) + @php $totalBrowsers = $browsers->sum('visitors'); @endphp +
+ @foreach ($browsers as $row) +
+
+ {{ $row->browser }} + {{ number_format($row->visitors) }} ({{ $totalBrowsers > 0 ? round(($row->visitors / $totalBrowsers) * 100, 1) : 0 }}%) +
+
+
+
+
+ @endforeach +
+ @else +

No browser data available.

+ @endif +
+ + {{-- Operating Systems --}} +
+

Operating Systems

+ @if ($operatingSystems->isNotEmpty()) + @php $totalOS = $operatingSystems->sum('visitors'); @endphp +
+ @foreach ($operatingSystems as $row) +
+
+ {{ $row->os }} + {{ number_format($row->visitors) }} ({{ $totalOS > 0 ? round(($row->visitors / $totalOS) * 100, 1) : 0 }}%) +
+
+
+
+
+ @endforeach +
+ @else +

No OS data available.

+ @endif +
+
+ + {{-- Screen Resolutions --}} +
+

Screen Resolutions

+ @if ($resolutions->isNotEmpty()) +
+ + + + + + + + + @foreach ($resolutions as $row) + + + + + @endforeach + +
ResolutionVisitors
{{ $row->screen_resolution }}{{ number_format($row->visitors) }}
+
+ @else +

No screen resolution data available.

+ @endif +
+
+ +
diff --git a/resources/views/reports/behavior/exit-pages.blade.php b/resources/views/reports/behavior/exit-pages.blade.php new file mode 100644 index 0000000..6149c6f --- /dev/null +++ b/resources/views/reports/behavior/exit-pages.blade.php @@ -0,0 +1,28 @@ + + +
+ @if ($pages->isEmpty()) +
No exit page data available for this period.
+ @else +
+ + + + + + + + + @foreach ($pages as $row) + + + + + @endforeach + +
Exit PageExits
{{ $row->path }}{{ number_format($row->exits) }}
+
+ @endif +
+ +
diff --git a/resources/views/reports/behavior/landing-pages.blade.php b/resources/views/reports/behavior/landing-pages.blade.php new file mode 100644 index 0000000..eab77e2 --- /dev/null +++ b/resources/views/reports/behavior/landing-pages.blade.php @@ -0,0 +1,32 @@ + + +
+ @if ($pages->isEmpty()) +
No landing page data available for this period.
+ @else +
+ + + + + + + + + + + @foreach ($pages as $row) + + + + + + + @endforeach + +
Entry PageSessionsBounce RateAvg. Duration
{{ $row->path }}{{ number_format($row->sessions) }}{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%{{ gmdate('i:s', (int) round($row->avg_duration)) }}
+
+ @endif +
+ +
diff --git a/resources/views/reports/behavior/pages.blade.php b/resources/views/reports/behavior/pages.blade.php new file mode 100644 index 0000000..be3c955 --- /dev/null +++ b/resources/views/reports/behavior/pages.blade.php @@ -0,0 +1,32 @@ + + +
+ @if ($pages->isEmpty()) +
No page view data available for this period.
+ @else +
+ + + + + + + + + + + @foreach ($pages as $row) + + + + + + + @endforeach + +
Page PathViewsUnique ViewsAvg. Time
{{ $row->path }}{{ number_format($row->views) }}{{ number_format($row->unique_views) }}{{ $row->avg_time !== null ? gmdate('i:s', (int) round($row->avg_time)) : '—' }}
+
+ @endif +
+ +
diff --git a/resources/views/reports/events/detail.blade.php b/resources/views/reports/events/detail.blade.php new file mode 100644 index 0000000..053adda --- /dev/null +++ b/resources/views/reports/events/detail.blade.php @@ -0,0 +1,73 @@ + + +
+ {{-- Trending Chart --}} +
+

Trend

+
+ +
+
+ + {{-- Detail Table --}} +
+ @if ($details->isEmpty()) +
No detail breakdown available.
+ @else +
+ + + + + + + + + + + @foreach ($details as $row) + + + + + + + @endforeach + +
ActionLabelCountTotal Value
{{ $row->action ?? '—' }}{{ $row->label ?? '—' }}{{ number_format($row->total) }}{{ $row->total_value ? number_format($row->total_value, 2) : '—' }}
+
+ @endif +
+
+ + + + + + +
diff --git a/resources/views/reports/events/overview.blade.php b/resources/views/reports/events/overview.blade.php new file mode 100644 index 0000000..3fa7ca1 --- /dev/null +++ b/resources/views/reports/events/overview.blade.php @@ -0,0 +1,38 @@ + + +
+ @if ($events->isEmpty()) +
No events tracked for this period.
+ @else +
+ + + + + + + + + + + + + @foreach ($events as $row) + + + + + + + + + @endforeach + +
Event NameCategoryTotalUnique VisitorsTotal Value
{{ $row->name }}{{ $row->category ?? '—' }}{{ number_format($row->total) }}{{ number_format($row->unique_visitors) }}{{ $row->total_value ? number_format($row->total_value, 2) : '—' }} + Details +
+
+ @endif +
+ +
diff --git a/routes/web.php b/routes/web.php index af3a36b..b062bc1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\DashboardController; use App\Http\Controllers\ProfileController; +use App\Http\Controllers\ReportController; use App\Http\Controllers\WebsiteController; use Illuminate\Support\Facades\Route; @@ -19,6 +20,25 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('websites/{website}/dashboard', [DashboardController::class, 'show'])->name('dashboard.show'); Route::get('websites/{website}/realtime', [DashboardController::class, 'realtime'])->name('dashboard.realtime'); + + // Reports + Route::prefix('websites/{website}/reports')->name('reports.')->group(function () { + Route::get('geographic', [ReportController::class, 'geographic'])->name('geographic'); + Route::get('technology', [ReportController::class, 'technology'])->name('technology'); + Route::get('language', [ReportController::class, 'language'])->name('language'); + Route::get('new-vs-returning', [ReportController::class, 'newVsReturning'])->name('new-vs-returning'); + + Route::get('sources', [ReportController::class, 'sources'])->name('sources'); + Route::get('campaigns', [ReportController::class, 'campaigns'])->name('campaigns'); + Route::get('referrers', [ReportController::class, 'referrers'])->name('referrers'); + + Route::get('pages', [ReportController::class, 'pages'])->name('pages'); + Route::get('landing-pages', [ReportController::class, 'landingPages'])->name('landing-pages'); + Route::get('exit-pages', [ReportController::class, 'exitPages'])->name('exit-pages'); + + Route::get('events', [ReportController::class, 'events'])->name('events'); + Route::get('events/{eventName}', [ReportController::class, 'eventDetail'])->name('event-detail'); + }); }); Route::middleware('auth')->group(function () {