phase 3
This commit is contained in:
@@ -45,29 +45,29 @@ Individual report pages accessible from the dashboard sidebar.
|
|||||||
|
|
||||||
### 3a — Audience Reports
|
### 3a — Audience Reports
|
||||||
|
|
||||||
- [ ] **Geographic report** — visitors by country, region, city (table + world map visualization)
|
- [x] **Geographic report** — visitors by country, region, city (table + world map visualization)
|
||||||
- [ ] **Technology report** — breakdown by browser, OS, device type, screen resolution
|
- [x] **Technology report** — breakdown by browser, OS, device type, screen resolution
|
||||||
- [ ] **Language report** — visitors by browser language
|
- [x] **Language report** — visitors by browser language
|
||||||
- [ ] **New vs. returning visitors** — segment visitors who have more than one session
|
- [x] **New vs. returning visitors** — segment visitors who have more than one session
|
||||||
|
|
||||||
### 3b — Acquisition Reports
|
### 3b — Acquisition Reports
|
||||||
|
|
||||||
- [ ] **Traffic sources** — breakdown by referrer domain (direct, organic, referral, social)
|
- [x] **Traffic sources** — breakdown by referrer domain (direct, organic, referral, social)
|
||||||
- [ ] **Campaigns (UTM)** — table of sessions grouped by `utm_source`, `utm_medium`, `utm_campaign`
|
- [x] **Campaigns (UTM)** — table of sessions grouped by `utm_source`, `utm_medium`, `utm_campaign`
|
||||||
- [ ] **Referrers** — top referring URLs and domains
|
- [x] **Referrers** — top referring URLs and domains
|
||||||
|
|
||||||
### 3c — Behavior Reports
|
### 3c — Behavior Reports
|
||||||
|
|
||||||
- [ ] **Pages report** — top pages by views, unique views, avg. time on page, bounce rate
|
- [x] **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
|
- [x] **Landing pages** — top entry pages with session count and bounce rate
|
||||||
- [ ] **Exit pages** — top exit pages
|
- [x] **Exit pages** — top exit pages
|
||||||
- [ ] **User flow** — simplified Sankey or funnel showing common page sequences (can be deferred)
|
- [ ] **User flow** — simplified Sankey or funnel showing common page sequences (can be deferred)
|
||||||
|
|
||||||
### 3d — Events Report
|
### 3d — Events Report
|
||||||
|
|
||||||
- [ ] **Events overview** — table of events grouped by name, category, action
|
- [x] **Events overview** — table of events grouped by name, category, action
|
||||||
- [ ] **Event detail** — drill-down into a single event name with label/value breakdown
|
- [x] **Event detail** — drill-down into a single event name with label/value breakdown
|
||||||
- [ ] **Event trending** — chart of event counts over time
|
- [x] **Event trending** — chart of event counts over time
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
trait ParsesDateRange
|
||||||
|
{
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ParsesDateRange;
|
||||||
use App\Models\Website;
|
use App\Models\Website;
|
||||||
use App\Services\AnalyticsQueryService;
|
use App\Services\AnalyticsQueryService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
|
use ParsesDateRange;
|
||||||
|
|
||||||
public function show(Request $request, Website $website): View
|
public function show(Request $request, Website $website): View
|
||||||
{
|
{
|
||||||
$this->authorize('view', $website);
|
$this->authorize('view', $website);
|
||||||
@@ -35,30 +37,4 @@ class DashboardController extends Controller
|
|||||||
'active' => AnalyticsQueryService::activeVisitors($website),
|
'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];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ParsesDateRange;
|
||||||
|
use App\Models\Website;
|
||||||
|
use App\Services\AnalyticsQueryService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ReportController extends Controller
|
||||||
|
{
|
||||||
|
use ParsesDateRange;
|
||||||
|
|
||||||
|
// ---- Audience ----
|
||||||
|
|
||||||
|
public function geographic(Request $request, Website $website): View
|
||||||
|
{
|
||||||
|
$this->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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,6 +146,292 @@ class AnalyticsQueryService
|
|||||||
return $periods;
|
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 ----
|
// ---- Real-Time ----
|
||||||
|
|
||||||
public static function activeVisitors(Website $website, int $minutes = 5): int
|
public static function activeVisitors(Website $website, int $minutes = 5): int
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$currentRange = request('range', '7d');
|
$currentRange = request('range', '7d');
|
||||||
|
$currentRouteName = request()->route()->getName();
|
||||||
|
$currentRouteParams = request()->route()->parameters();
|
||||||
$presets = [
|
$presets = [
|
||||||
'today' => 'Today',
|
'today' => 'Today',
|
||||||
'7d' => 'Last 7 days',
|
'7d' => 'Last 7 days',
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
@foreach ($presets as $key => $label)
|
@foreach ($presets as $key => $label)
|
||||||
<a
|
<a
|
||||||
href="{{ route('dashboard.show', ['website' => $website, 'range' => $key]) }}"
|
href="{{ route($currentRouteName, $currentRouteParams + ['range' => $key]) }}"
|
||||||
class="block rounded-md px-3 py-2 text-sm {{ $currentRange === $key ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-gray-700 hover:bg-gray-100' }}"
|
class="block rounded-md px-3 py-2 text-sm {{ $currentRange === $key ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-gray-700 hover:bg-gray-100' }}"
|
||||||
>
|
>
|
||||||
{{ $label }}
|
{{ $label }}
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
|
|
||||||
<div class="my-2 border-t border-gray-100"></div>
|
<div class="my-2 border-t border-gray-100"></div>
|
||||||
|
|
||||||
<form action="{{ route('dashboard.show', $website) }}" method="GET" class="px-3 py-2 space-y-3">
|
<form action="{{ route($currentRouteName, $currentRouteParams) }}" method="GET" class="px-3 py-2 space-y-3">
|
||||||
<input type="hidden" name="range" value="custom">
|
<input type="hidden" name="range" value="custom">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-500 mb-1">From</label>
|
<label class="block text-xs font-medium text-gray-500 mb-1">From</label>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
@props(['website', 'from', 'to', 'title'])
|
||||||
|
|
||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="{{ route('dashboard.show', $website) }}" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ $title }}</h2>
|
||||||
|
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
|
||||||
|
</div>
|
||||||
|
<x-date-range-picker :website="$website" :from="$from" :to="$to" />
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-8">
|
||||||
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
|
<div class="flex gap-6">
|
||||||
|
{{-- Sidebar --}}
|
||||||
|
<nav class="hidden lg:block w-56 shrink-0">
|
||||||
|
<div class="bg-white shadow-sm sm:rounded-lg p-4 sticky top-8 space-y-6">
|
||||||
|
@php
|
||||||
|
$q = request()->only(['range', 'from', 'to']);
|
||||||
|
$active = fn(string $route) => request()->routeIs($route) ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-gray-600 hover:bg-gray-50';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Audience</h4>
|
||||||
|
<a href="{{ route('reports.geographic', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.geographic') }}">Geographic</a>
|
||||||
|
<a href="{{ route('reports.technology', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.technology') }}">Technology</a>
|
||||||
|
<a href="{{ route('reports.language', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.language') }}">Language</a>
|
||||||
|
<a href="{{ route('reports.new-vs-returning', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.new-vs-returning') }}">New vs Returning</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Acquisition</h4>
|
||||||
|
<a href="{{ route('reports.sources', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.sources') }}">Traffic Sources</a>
|
||||||
|
<a href="{{ route('reports.campaigns', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.campaigns') }}">Campaigns</a>
|
||||||
|
<a href="{{ route('reports.referrers', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.referrers') }}">Referrers</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Behavior</h4>
|
||||||
|
<a href="{{ route('reports.pages', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.pages') }}">Pages</a>
|
||||||
|
<a href="{{ route('reports.landing-pages', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.landing-pages') }}">Landing Pages</a>
|
||||||
|
<a href="{{ route('reports.exit-pages', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.exit-pages') }}">Exit Pages</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Events</h4>
|
||||||
|
<a href="{{ route('reports.events', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.events') }}">Overview</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{{-- Main Content --}}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@isset($scripts)
|
||||||
|
@push('scripts')
|
||||||
|
{{ $scripts }}
|
||||||
|
@endpush
|
||||||
|
@endisset
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@props(['headers', 'empty' => 'No data available for this period.'])
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
@foreach ($headers as $header)
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider {{ $loop->first ? '' : 'text-right' }}">
|
||||||
|
{{ $header }}
|
||||||
|
</th>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
{{ $slot }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if (trim((string) $slot) === '')
|
||||||
|
<div class="px-6 py-12 text-center text-sm text-gray-500">{{ $empty }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -92,6 +92,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Quick Report Links --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Reports</h3>
|
||||||
|
@php $q = request()->only(['range', 'from', 'to']); @endphp
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Audience</h4>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<a href="{{ route('reports.geographic', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Geographic</a>
|
||||||
|
<a href="{{ route('reports.technology', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Technology</a>
|
||||||
|
<a href="{{ route('reports.language', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Language</a>
|
||||||
|
<a href="{{ route('reports.new-vs-returning', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">New vs Returning</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Acquisition</h4>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<a href="{{ route('reports.sources', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Traffic Sources</a>
|
||||||
|
<a href="{{ route('reports.campaigns', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Campaigns</a>
|
||||||
|
<a href="{{ route('reports.referrers', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Referrers</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Behavior</h4>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<a href="{{ route('reports.pages', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Pages</a>
|
||||||
|
<a href="{{ route('reports.landing-pages', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Landing Pages</a>
|
||||||
|
<a href="{{ route('reports.exit-pages', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Exit Pages</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Events</h4>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<a href="{{ route('reports.events', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Events Overview</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="Campaigns (UTM)">
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
@if ($campaigns->isEmpty())
|
||||||
|
<div class="px-6 py-12 text-center text-sm text-gray-500">No campaign data available. Traffic with UTM parameters will appear here.</div>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Medium</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Campaign</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach ($campaigns as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->utm_source }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->utm_medium ?? '—' }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->utm_campaign ?? '—' }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->sessions) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="Referrers">
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
@if ($referrers->isEmpty())
|
||||||
|
<div class="px-6 py-12 text-center text-sm text-gray-500">No referrer data available for this period.</div>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Referrer Domain</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach ($referrers as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 font-medium">{{ $row->referrer_domain }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->sessions) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="Traffic Sources">
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
@if ($sources->isEmpty())
|
||||||
|
<div class="px-6 py-12 text-center text-sm text-gray-500">No traffic source data available for this period.</div>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Pages / Session</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach ($sources as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 font-medium">{{ $row->source }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->sessions) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ round($row->avg_pages, 1) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="Geographic Report">
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Visitors by Country</h3>
|
||||||
|
@if ($countries->isEmpty())
|
||||||
|
<p class="text-sm text-gray-500 py-8 text-center">No geographic data available for this period.</p>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">%</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@php $totalCountry = $countries->sum('visitors'); @endphp
|
||||||
|
@foreach ($countries as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->country }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->visitors) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500 text-right">{{ $totalCountry > 0 ? round(($row->visitors / $totalCountry) * 100, 1) : 0 }}%</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Visitors by City</h3>
|
||||||
|
@if ($cities->isEmpty())
|
||||||
|
<p class="text-sm text-gray-500 py-8 text-center">No city-level data available.</p>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">City</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Region</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach ($cities as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->city }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->region }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->country }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->visitors) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="Language Report">
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Visitors by Language</h3>
|
||||||
|
@if ($languages->isNotEmpty())
|
||||||
|
@php $total = $languages->sum('visitors'); @endphp
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($languages as $row)
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span class="text-gray-900">{{ $row->language }}</span>
|
||||||
|
<span class="text-gray-500">{{ number_format($row->visitors) }} ({{ $total > 0 ? round(($row->visitors / $total) * 100, 1) : 0 }}%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-100 rounded-full h-2">
|
||||||
|
<div class="bg-indigo-500 h-2 rounded-full" style="width: {{ $total > 0 ? ($row->visitors / $total) * 100 : 0 }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-gray-500 py-8 text-center">No language data available for this period.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="New vs Returning Visitors">
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
@if ($data['new'] === 0 && $data['returning'] === 0)
|
||||||
|
<p class="text-sm text-gray-500 py-8 text-center">No visitor data available for this period.</p>
|
||||||
|
@else
|
||||||
|
<div class="grid gap-6 sm:grid-cols-2 max-w-lg mx-auto">
|
||||||
|
<div class="rounded-lg border-2 border-indigo-200 bg-indigo-50 p-6 text-center">
|
||||||
|
<div class="text-3xl font-bold text-indigo-700">{{ $data['new_pct'] }}%</div>
|
||||||
|
<div class="text-sm font-medium text-indigo-600 mt-1">New Visitors</div>
|
||||||
|
<div class="text-xs text-indigo-400 mt-0.5">{{ number_format($data['new']) }} visitors</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border-2 border-emerald-200 bg-emerald-50 p-6 text-center">
|
||||||
|
<div class="text-3xl font-bold text-emerald-700">{{ $data['returning_pct'] }}%</div>
|
||||||
|
<div class="text-sm font-medium text-emerald-600 mt-1">Returning Visitors</div>
|
||||||
|
<div class="text-xs text-emerald-400 mt-0.5">{{ number_format($data['returning']) }} visitors</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 max-w-lg mx-auto">
|
||||||
|
<div class="w-full bg-gray-100 rounded-full h-4 overflow-hidden flex">
|
||||||
|
<div class="bg-indigo-500 h-4" style="width: {{ $data['new_pct'] }}%"></div>
|
||||||
|
<div class="bg-emerald-500 h-4" style="width: {{ $data['returning_pct'] }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||||
|
<span>New</span>
|
||||||
|
<span>Returning</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="Technology Report">
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{-- Device Type --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Device Type</h3>
|
||||||
|
@if ($devices->isNotEmpty())
|
||||||
|
@php $totalDevices = $devices->sum('visitors'); @endphp
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3 mb-4">
|
||||||
|
@foreach ($devices as $row)
|
||||||
|
<div class="rounded-lg border border-gray-200 p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{{ $totalDevices > 0 ? round(($row->visitors / $totalDevices) * 100, 1) : 0 }}%</div>
|
||||||
|
<div class="text-sm text-gray-500 capitalize mt-1">{{ $row->device_type ?? 'Unknown' }}</div>
|
||||||
|
<div class="text-xs text-gray-400">{{ number_format($row->visitors) }} visitors</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-gray-500 py-4 text-center">No device data available.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
{{-- Browsers --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Browsers</h3>
|
||||||
|
@if ($browsers->isNotEmpty())
|
||||||
|
@php $totalBrowsers = $browsers->sum('visitors'); @endphp
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($browsers as $row)
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span class="text-gray-900">{{ $row->browser }}</span>
|
||||||
|
<span class="text-gray-500">{{ number_format($row->visitors) }} ({{ $totalBrowsers > 0 ? round(($row->visitors / $totalBrowsers) * 100, 1) : 0 }}%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-100 rounded-full h-2">
|
||||||
|
<div class="bg-indigo-500 h-2 rounded-full" style="width: {{ $totalBrowsers > 0 ? ($row->visitors / $totalBrowsers) * 100 : 0 }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-gray-500 py-4 text-center">No browser data available.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Operating Systems --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Operating Systems</h3>
|
||||||
|
@if ($operatingSystems->isNotEmpty())
|
||||||
|
@php $totalOS = $operatingSystems->sum('visitors'); @endphp
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($operatingSystems as $row)
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span class="text-gray-900">{{ $row->os }}</span>
|
||||||
|
<span class="text-gray-500">{{ number_format($row->visitors) }} ({{ $totalOS > 0 ? round(($row->visitors / $totalOS) * 100, 1) : 0 }}%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-100 rounded-full h-2">
|
||||||
|
<div class="bg-emerald-500 h-2 rounded-full" style="width: {{ $totalOS > 0 ? ($row->visitors / $totalOS) * 100 : 0 }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-gray-500 py-4 text-center">No OS data available.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Screen Resolutions --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Screen Resolutions</h3>
|
||||||
|
@if ($resolutions->isNotEmpty())
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Resolution</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach ($resolutions as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 font-mono">{{ $row->screen_resolution }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->visitors) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-gray-500 py-4 text-center">No screen resolution data available.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="Exit Pages">
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
@if ($pages->isEmpty())
|
||||||
|
<div class="px-6 py-12 text-center text-sm text-gray-500">No exit page data available for this period.</div>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Exit Page</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Exits</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach ($pages as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 font-mono max-w-md truncate">{{ $row->path }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->exits) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="Landing Pages">
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
@if ($pages->isEmpty())
|
||||||
|
<div class="px-6 py-12 text-center text-sm text-gray-500">No landing page data available for this period.</div>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry Page</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach ($pages as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 font-mono max-w-md truncate">{{ $row->path }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->sessions) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="Pages Report">
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
@if ($pages->isEmpty())
|
||||||
|
<div class="px-6 py-12 text-center text-sm text-gray-500">No page view data available for this period.</div>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Page Path</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Views</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique Views</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach ($pages as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 font-mono max-w-md truncate">{{ $row->path }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->views) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->unique_views) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->avg_time !== null ? gmdate('i:s', (int) round($row->avg_time)) : '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" :title="'Event: ' . $eventName">
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{-- Trending Chart --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Trend</h3>
|
||||||
|
<div style="height: 240px;">
|
||||||
|
<canvas id="event-trend-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Detail Table --}}
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
@if ($details->isEmpty())
|
||||||
|
<div class="px-6 py-12 text-center text-sm text-gray-500">No detail breakdown available.</div>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Label</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Count</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Total Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach ($details as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->action ?? '—' }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->label ?? '—' }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->total) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->total_value ? number_format($row->total_value, 2) : '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-slot name="scripts">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const raw = @json($trending);
|
||||||
|
new Chart(document.getElementById('event-trend-chart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: raw.map(d => d.period),
|
||||||
|
datasets: [{
|
||||||
|
label: '{{ $eventName }}',
|
||||||
|
data: raw.map(d => d.total),
|
||||||
|
backgroundColor: 'rgba(99, 102, 241, 0.7)',
|
||||||
|
borderRadius: 4,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false }, ticks: { maxTicksLimit: 12, font: { size: 11 }, color: '#9ca3af' } },
|
||||||
|
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { precision: 0, font: { size: 11 }, color: '#9ca3af' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<x-report-layout :website="$website" :from="$from" :to="$to" title="Events Overview">
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
|
@if ($events->isEmpty())
|
||||||
|
<div class="px-6 py-12 text-center text-sm text-gray-500">No events tracked for this period.</div>
|
||||||
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event Name</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique Visitors</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Total Value</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@foreach ($events as $row)
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 font-medium">{{ $row->name }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->category ?? '—' }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->total) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->unique_visitors) }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->total_value ? number_format($row->total_value, 2) : '—' }}</td>
|
||||||
|
<td class="px-6 py-3 text-sm text-right">
|
||||||
|
<a href="{{ route('reports.event-detail', [$website, $row->name] + request()->only(['range', 'from', 'to'])) }}" class="text-indigo-600 hover:text-indigo-800">Details</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-report-layout>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use App\Http\Controllers\ReportController;
|
||||||
use App\Http\Controllers\WebsiteController;
|
use App\Http\Controllers\WebsiteController;
|
||||||
use Illuminate\Support\Facades\Route;
|
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}/dashboard', [DashboardController::class, 'show'])->name('dashboard.show');
|
||||||
Route::get('websites/{website}/realtime', [DashboardController::class, 'realtime'])->name('dashboard.realtime');
|
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 () {
|
Route::middleware('auth')->group(function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user