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 @@
+ {{-- Quick Report Links --}}
+
+
Reports
+ @php $q = request()->only(['range', 'from', 'to']); @endphp
+
+
+
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
+
+
+
+
+ | Source |
+ Medium |
+ Campaign |
+ Sessions |
+ Bounce Rate |
+ Avg. Duration |
+
+
+
+ @foreach ($campaigns as $row)
+
+ | {{ $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)) }} |
+
+ @endforeach
+
+
+
+ @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
+
+
+
+
+ | Referrer Domain |
+ Sessions |
+ Bounce Rate |
+ Avg. Duration |
+
+
+
+ @foreach ($referrers as $row)
+
+ | {{ $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)) }} |
+
+ @endforeach
+
+
+
+ @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
+
+
+
+
+ | Source |
+ Sessions |
+ Bounce Rate |
+ Avg. Duration |
+ Pages / Session |
+
+
+
+ @foreach ($sources as $row)
+
+ | {{ $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) }} |
+
+ @endforeach
+
+
+
+ @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
+
+
+
+
+ | Country |
+ Visitors |
+ % |
+
+
+
+ @php $totalCountry = $countries->sum('visitors'); @endphp
+ @foreach ($countries as $row)
+
+ | {{ $row->country }} |
+ {{ number_format($row->visitors) }} |
+ {{ $totalCountry > 0 ? round(($row->visitors / $totalCountry) * 100, 1) : 0 }}% |
+
+ @endforeach
+
+
+
+ @endif
+
+
+
+
Visitors by City
+ @if ($cities->isEmpty())
+
No city-level data available.
+ @else
+
+
+
+
+ | City |
+ Region |
+ Country |
+ Visitors |
+
+
+
+ @foreach ($cities as $row)
+
+ | {{ $row->city }} |
+ {{ $row->region }} |
+ {{ $row->country }} |
+ {{ number_format($row->visitors) }} |
+
+ @endforeach
+
+
+
+ @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())
+
+
+
+
+ | Resolution |
+ Visitors |
+
+
+
+ @foreach ($resolutions as $row)
+
+ | {{ $row->screen_resolution }} |
+ {{ number_format($row->visitors) }} |
+
+ @endforeach
+
+
+
+ @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
+
+
+
+
+ | Exit Page |
+ Exits |
+
+
+
+ @foreach ($pages as $row)
+
+ | {{ $row->path }} |
+ {{ number_format($row->exits) }} |
+
+ @endforeach
+
+
+
+ @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
+
+
+
+
+ | Entry Page |
+ Sessions |
+ Bounce Rate |
+ Avg. Duration |
+
+
+
+ @foreach ($pages as $row)
+
+ | {{ $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)) }} |
+
+ @endforeach
+
+
+
+ @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
+
+
+
+
+ | Page Path |
+ Views |
+ Unique Views |
+ Avg. Time |
+
+
+
+ @foreach ($pages as $row)
+
+ | {{ $row->path }} |
+ {{ number_format($row->views) }} |
+ {{ number_format($row->unique_views) }} |
+ {{ $row->avg_time !== null ? gmdate('i:s', (int) round($row->avg_time)) : '—' }} |
+
+ @endforeach
+
+
+
+ @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 --}}
+
+
+ {{-- Detail Table --}}
+
+ @if ($details->isEmpty())
+
No detail breakdown available.
+ @else
+
+
+
+
+ | Action |
+ Label |
+ Count |
+ Total Value |
+
+
+
+ @foreach ($details as $row)
+
+ | {{ $row->action ?? '—' }} |
+ {{ $row->label ?? '—' }} |
+ {{ number_format($row->total) }} |
+ {{ $row->total_value ? number_format($row->total_value, 2) : '—' }} |
+
+ @endforeach
+
+
+
+ @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
+
+
+
+
+ | Event Name |
+ Category |
+ Total |
+ Unique Visitors |
+ Total Value |
+ |
+
+
+
+ @foreach ($events as $row)
+
+ | {{ $row->name }} |
+ {{ $row->category ?? '—' }} |
+ {{ number_format($row->total) }} |
+ {{ number_format($row->unique_visitors) }} |
+ {{ $row->total_value ? number_format($row->total_value, 2) : '—' }} |
+
+ Details
+ |
+
+ @endforeach
+
+
+
+ @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 () {