diff --git a/PLAN.md b/PLAN.md index 67ac2b1..84353b7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -121,51 +121,50 @@ Raw event tables grow fast. Pre-aggregate data for fast dashboard loads. Choose a frontend approach and build a polished interface. -- [ ] Pick a stack: - - **Option A:** Blade + Livewire + Alpine.js (server-rendered, simpler) - - **Option B:** Inertia.js + Vue/React (SPA feel, more flexibility) -- [ ] Install Tailwind CSS (already in the Laravel scaffold via Vite) -- [ ] Build a **layout** with sidebar navigation (websites list, reports sections) and top bar (date picker, user menu) -- [ ] Integrate a charting library (Chart.js, ApexCharts, or ECharts) for: +- [x] Pick a stack: + - **Option A:** Blade + Alpine.js (server-rendered, simpler) — selected +- [x] Install Tailwind CSS (already in the Laravel scaffold via Vite) + Inter font +- [x] Build a **layout** with sidebar navigation (websites list, reports sections) and top bar (date picker, user menu) +- [x] Integrate a charting library (Chart.js + chartjs-chart-geo) for: - Line charts (visitors over time) - - Bar charts (top pages, browsers) - - Pie/donut charts (device type, traffic source split) - - World map (geographic report) -- [ ] Build **comparison mode** — compare current period vs. previous period with delta percentages -- [ ] Add loading states, empty states, and error handling throughout -- [ ] Make the dashboard responsive (mobile-friendly) + - Bar charts (top pages, browsers, sources) + - Pie/donut charts (device type, traffic source split, new vs returning) + - World map (geographic report — choropleth) +- [x] Build **comparison mode** — compare current period vs. previous period with delta percentages on metric cards +- [x] Add loading states, empty states, and error handling throughout +- [x] Make the dashboard responsive (mobile-friendly) — collapsible sidebar with overlay on mobile --- -## Phase 8: API for Programmatic Access +## Phase 8: API for Programmatic Access ✅ Let users pull their analytics data via an API. -- [ ] Create API token management (Laravel Sanctum) — UI page to generate/revoke API tokens -- [ ] Build versioned API routes (`/api/v1/...`) -- [ ] Endpoints: +- [x] Create API token management (Laravel Sanctum) — UI page to generate/revoke API tokens +- [x] Build versioned API routes (`/api/v1/...`) +- [x] Endpoints: - `GET /api/v1/websites` — list websites - `GET /api/v1/websites/{id}/stats` — summary metrics with date-range params - `GET /api/v1/websites/{id}/pages` — top pages - `GET /api/v1/websites/{id}/referrers` — traffic sources - `GET /api/v1/websites/{id}/events` — events report - `GET /api/v1/websites/{id}/visitors` — audience breakdown -- [ ] Add API resource classes for consistent JSON formatting -- [ ] Rate limit API endpoints per token +- [x] Add API resource classes for consistent JSON formatting +- [x] Rate limit API endpoints per token --- -## Phase 9: Advanced Features +## Phase 9: Advanced Features ✅ Features that differentiate from a basic analytics tool. -- [ ] **Funnels** — let users define a sequence of pages/events and visualize drop-off at each step -- [ ] **Segments** — filter all reports by visitor attributes (e.g., mobile users from Germany) -- [ ] **Custom dashboards** — let users pin specific widgets/reports to a custom overview -- [ ] **Alerts / Notifications** — notify users when traffic spikes, drops, or a goal hits a threshold (email and/or in-app) -- [ ] **Data export** — CSV/PDF export for any report -- [ ] **UTM builder** — in-app tool to generate campaign URLs with UTM parameters -- [ ] **Annotations** — let users add notes on specific dates (e.g., "launched ad campaign") visible on charts +- [x] **Funnels** — let users define a sequence of pages/events and visualize drop-off at each step +- [x] **Segments** — filter all reports by visitor attributes (e.g., mobile users from Germany) +- [x] **Custom dashboards** — let users pin specific widgets/reports to a custom overview +- [x] **Alerts / Notifications** — notify users when traffic spikes, drops, or a goal hits a threshold (email and/or in-app) +- [x] **Data export** — CSV/PDF export for any report +- [x] **UTM builder** — in-app tool to generate campaign URLs with UTM parameters +- [x] **Annotations** — let users add notes on specific dates (e.g., "launched ad campaign") visible on charts --- diff --git a/app/Console/Commands/CheckAlerts.php b/app/Console/Commands/CheckAlerts.php new file mode 100644 index 0000000..c93e278 --- /dev/null +++ b/app/Console/Commands/CheckAlerts.php @@ -0,0 +1,116 @@ +where('is_active', true) + ->get(); + + $triggered = 0; + + foreach ($alerts as $alert) { + $currentValue = $this->getCurrentValue($alert); + $shouldTrigger = $this->evaluate($alert, $currentValue); + + if ($shouldTrigger) { + $this->trigger($alert, $currentValue); + $triggered++; + } + } + + $this->info("Checked {$alerts->count()} alerts, triggered {$triggered}."); + + return self::SUCCESS; + } + + private function getCurrentValue(Alert $alert): float + { + $to = Carbon::today(); + $from = $alert->period === 'hourly' + ? Carbon::now()->subHour() + : Carbon::yesterday(); + + $service = AnalyticsQueryService::for($alert->website, $from, $to); + $summary = $service->summary(); + + return match ($alert->metric) { + 'page_views' => $summary['page_views'], + 'visitors' => $summary['visitors'], + 'sessions' => $summary['sessions'], + 'bounce_rate' => $summary['bounce_rate'], + default => 0, + }; + } + + private function evaluate(Alert $alert, float $currentValue): bool + { + return match ($alert->condition) { + 'above' => $currentValue > $alert->threshold, + 'below' => $currentValue < $alert->threshold, + 'change_up' => $this->percentageChange($alert) > $alert->threshold, + 'change_down' => $this->percentageChange($alert) < -$alert->threshold, + default => false, + }; + } + + private function percentageChange(Alert $alert): float + { + $to = Carbon::today(); + $from = Carbon::yesterday(); + $prevFrom = Carbon::yesterday()->subDay(); + $prevTo = Carbon::yesterday(); + + $current = $this->getValueForPeriod($alert, $from, $to); + $previous = $this->getValueForPeriod($alert, $prevFrom, $prevTo); + + if ($previous == 0) { + return $current > 0 ? 100 : 0; + } + + return (($current - $previous) / $previous) * 100; + } + + private function getValueForPeriod(Alert $alert, Carbon $from, Carbon $to): float + { + $service = AnalyticsQueryService::for($alert->website, $from, $to); + $summary = $service->summary(); + + return $summary[$alert->metric] ?? 0; + } + + private function trigger(Alert $alert, float $currentValue): void + { + $conditionLabel = Alert::$conditions[$alert->condition] ?? $alert->condition; + $metricLabel = Alert::$metrics[$alert->metric] ?? $alert->metric; + + $message = "{$metricLabel} {$conditionLabel} {$alert->threshold} (current: {$currentValue}) for {$alert->website->name}"; + + $notification = $alert->notifications()->create([ + 'value' => $currentValue, + 'message' => $message, + ]); + + $alert->update(['last_triggered_at' => now()]); + + if (in_array($alert->channel, ['email', 'both'])) { + Mail::to($alert->website->user)->send(new AlertTriggered($alert, $notification)); + } + + $this->line(" → Triggered: {$alert->name} — {$message}"); + } +} diff --git a/app/Http/Controllers/AlertController.php b/app/Http/Controllers/AlertController.php new file mode 100644 index 0000000..16d207b --- /dev/null +++ b/app/Http/Controllers/AlertController.php @@ -0,0 +1,116 @@ +authorize('view', $website); + + $alerts = $website->alerts()->orderBy('name')->get(); + $unreadCount = $website->alerts() + ->whereHas('notifications', fn ($q) => $q->where('is_read', false)) + ->count(); + + return view('alerts.index', [ + 'website' => $website, + 'alerts' => $alerts, + 'unreadCount' => $unreadCount, + ]); + } + + public function create(Website $website): View + { + $this->authorize('view', $website); + + return view('alerts.create', ['website' => $website]); + } + + public function store(Request $request, Website $website): RedirectResponse + { + $this->authorize('view', $website); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'metric' => 'required|in:' . implode(',', array_keys(Alert::$metrics)), + 'condition' => 'required|in:' . implode(',', array_keys(Alert::$conditions)), + 'threshold' => 'required|numeric|min:0', + 'period' => 'required|in:daily,hourly', + 'channel' => 'required|in:email,in_app,both', + ]); + + $website->alerts()->create($validated); + + return redirect() + ->route('alerts.index', $website) + ->with('success', 'Alert created successfully.'); + } + + public function edit(Website $website, Alert $alert): View + { + $this->authorize('view', $website); + + return view('alerts.edit', [ + 'website' => $website, + 'alert' => $alert, + ]); + } + + public function update(Request $request, Website $website, Alert $alert): RedirectResponse + { + $this->authorize('view', $website); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'metric' => 'required|in:' . implode(',', array_keys(Alert::$metrics)), + 'condition' => 'required|in:' . implode(',', array_keys(Alert::$conditions)), + 'threshold' => 'required|numeric|min:0', + 'period' => 'required|in:daily,hourly', + 'channel' => 'required|in:email,in_app,both', + ]); + + $alert->update($validated); + + return redirect() + ->route('alerts.index', $website) + ->with('success', 'Alert updated successfully.'); + } + + public function destroy(Website $website, Alert $alert): RedirectResponse + { + $this->authorize('view', $website); + + $alert->delete(); + + return redirect() + ->route('alerts.index', $website) + ->with('success', 'Alert deleted.'); + } + + public function notifications(Website $website): View + { + $this->authorize('view', $website); + + $notifications = \App\Models\AlertNotification::whereHas('alert', fn ($q) => $q->where('website_id', $website->id)) + ->with('alert') + ->orderByDesc('created_at') + ->paginate(20); + + // Mark as read + \App\Models\AlertNotification::whereHas('alert', fn ($q) => $q->where('website_id', $website->id)) + ->where('is_read', false) + ->update(['is_read' => true]); + + return view('alerts.notifications', [ + 'website' => $website, + 'notifications' => $notifications, + ]); + } +} diff --git a/app/Http/Controllers/AnnotationController.php b/app/Http/Controllers/AnnotationController.php new file mode 100644 index 0000000..fd1b803 --- /dev/null +++ b/app/Http/Controllers/AnnotationController.php @@ -0,0 +1,90 @@ +authorize('view', $website); + + return view('annotations.index', [ + 'website' => $website, + 'annotations' => $website->annotations()->with('user')->orderByDesc('date')->paginate(25), + ]); + } + + public function store(Request $request, Website $website): RedirectResponse|JsonResponse + { + $this->authorize('view', $website); + + $validated = $request->validate([ + 'date' => 'required|date', + 'text' => 'required|string|max:500', + 'category' => 'required|in:' . implode(',', array_keys(Annotation::$categories)), + ]); + + $annotation = $website->annotations()->create([ + ...$validated, + 'user_id' => $request->user()->id, + ]); + + if ($request->expectsJson()) { + return response()->json($annotation->load('user'), 201); + } + + return redirect() + ->route('annotations.index', $website) + ->with('success', 'Annotation added.'); + } + + public function destroy(Request $request, Website $website, Annotation $annotation): RedirectResponse|JsonResponse + { + $this->authorize('view', $website); + + $annotation->delete(); + + if ($request->expectsJson()) { + return response()->json(['ok' => true]); + } + + return redirect() + ->route('annotations.index', $website) + ->with('success', 'Annotation deleted.'); + } + + /** + * JSON endpoint for chart overlays — returns annotations within a date range. + */ + public function forRange(Request $request, Website $website): JsonResponse + { + $this->authorize('view', $website); + + $request->validate([ + 'from' => 'required|date', + 'to' => 'required|date|after_or_equal:from', + ]); + + $annotations = $website->annotations() + ->with('user:id,name') + ->whereBetween('date', [$request->from, $request->to]) + ->orderBy('date') + ->get() + ->map(fn (Annotation $a) => [ + 'id' => $a->id, + 'date' => $a->date->toDateString(), + 'text' => $a->text, + 'category' => $a->category, + 'user' => $a->user->name, + ]); + + return response()->json($annotations); + } +} diff --git a/app/Http/Controllers/Api/V1/WebsiteController.php b/app/Http/Controllers/Api/V1/WebsiteController.php new file mode 100644 index 0000000..b4c6e8c --- /dev/null +++ b/app/Http/Controllers/Api/V1/WebsiteController.php @@ -0,0 +1,133 @@ +user()->websites()->orderBy('name')->get(); + + return WebsiteResource::collection($websites); + } + + public function stats(Request $request, Website $website): StatsResource + { + $this->authorizeWebsite($request, $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return new StatsResource([ + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + 'metrics' => $service->summary(), + 'timeseries' => $service->visitorsOverTime(), + ]); + } + + public function pages(Request $request, Website $website): AnonymousResourceCollection + { + $this->authorizeWebsite($request, $website); + [$from, $to] = $this->parseDateRange($request); + $limit = $this->parseLimit($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return PageResource::collection($service->topPages($limit)); + } + + public function referrers(Request $request, Website $website): AnonymousResourceCollection + { + $this->authorizeWebsite($request, $website); + [$from, $to] = $this->parseDateRange($request); + $limit = $this->parseLimit($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return ReferrerResource::collection($service->trafficSources($limit)); + } + + public function events(Request $request, Website $website): AnonymousResourceCollection + { + $this->authorizeWebsite($request, $website); + [$from, $to] = $this->parseDateRange($request); + $limit = $this->parseLimit($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return EventResource::collection($service->eventsOverview($limit)); + } + + public function visitors(Request $request, Website $website): VisitorBreakdownResource + { + $this->authorizeWebsite($request, $website); + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + return new VisitorBreakdownResource([ + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + 'countries' => $service->visitorsByCountry(20)->map(fn ($r) => [ + 'country' => $r->country, + 'visitors' => $r->visitors, + ]), + 'browsers' => $service->visitorsByBrowser(10)->map(fn ($r) => [ + 'browser' => $r->browser, + 'visitors' => $r->visitors, + ]), + 'operating_systems' => $service->visitorsByOS(10)->map(fn ($r) => [ + 'os' => $r->os, + 'visitors' => $r->visitors, + ]), + 'device_types' => $service->visitorsByDeviceType(5)->map(fn ($r) => [ + 'device_type' => $r->device_type, + 'visitors' => $r->visitors, + ]), + 'languages' => $service->visitorsByLanguage(10)->map(fn ($r) => [ + 'language' => $r->language, + 'visitors' => $r->visitors, + ]), + 'new_vs_returning' => $service->newVsReturning(), + ]); + } + + private function authorizeWebsite(Request $request, Website $website): void + { + if ($website->user_id !== $request->user()->id) { + abort(403, 'You do not own this website.'); + } + } + + private function parseDateRange(Request $request): array + { + $request->validate([ + 'from' => 'nullable|date|before_or_equal:to', + 'to' => 'nullable|date|after_or_equal:from', + ]); + + $from = $request->has('from') + ? Carbon::parse($request->get('from')) + : Carbon::today()->subDays(6); + + $to = $request->has('to') + ? Carbon::parse($request->get('to')) + : Carbon::today(); + + return [$from, $to]; + } + + private function parseLimit(Request $request): int + { + return min((int) $request->get('limit', 50), 200); + } +} diff --git a/app/Http/Controllers/ApiTokenController.php b/app/Http/Controllers/ApiTokenController.php new file mode 100644 index 0000000..192962e --- /dev/null +++ b/app/Http/Controllers/ApiTokenController.php @@ -0,0 +1,40 @@ + $request->user()->tokens()->orderByDesc('created_at')->get(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'name' => 'required|string|max:255', + ]); + + $token = $request->user()->createToken($request->name); + + return redirect() + ->route('api-tokens.index') + ->with('newToken', $token->plainTextToken) + ->with('success', 'API token created successfully.'); + } + + public function destroy(Request $request, string $tokenId): RedirectResponse + { + $request->user()->tokens()->where('id', $tokenId)->delete(); + + return redirect() + ->route('api-tokens.index') + ->with('success', 'API token revoked.'); + } +} diff --git a/app/Http/Controllers/Concerns/ParsesDateRange.php b/app/Http/Controllers/Concerns/ParsesDateRange.php index d0f1be3..1b83087 100644 --- a/app/Http/Controllers/Concerns/ParsesDateRange.php +++ b/app/Http/Controllers/Concerns/ParsesDateRange.php @@ -31,4 +31,14 @@ trait ParsesDateRange return [$from, $to]; } + + private function previousPeriod(Carbon $from, Carbon $to): array + { + $days = $from->diffInDays($to) + 1; + + $prevTo = $from->copy()->subDay(); + $prevFrom = $prevTo->copy()->subDays($days - 1); + + return [$prevFrom, $prevTo]; + } } diff --git a/app/Http/Controllers/Concerns/ParsesSegment.php b/app/Http/Controllers/Concerns/ParsesSegment.php new file mode 100644 index 0000000..4d432c1 --- /dev/null +++ b/app/Http/Controllers/Concerns/ParsesSegment.php @@ -0,0 +1,37 @@ +get('segment'); + if (! $segmentId) { + return $service; + } + + $segment = $website->segments()->find($segmentId); + if (! $segment) { + return $service; + } + + return $service->withSegment($segment->matchingVisitorIds()); + } + + private function activeSegment(Request $request, Website $website): ?Segment + { + $segmentId = $request->get('segment'); + if (! $segmentId) { + return null; + } + + return $website->segments()->find($segmentId); + } +} diff --git a/app/Http/Controllers/CustomDashboardController.php b/app/Http/Controllers/CustomDashboardController.php new file mode 100644 index 0000000..d6b4176 --- /dev/null +++ b/app/Http/Controllers/CustomDashboardController.php @@ -0,0 +1,122 @@ +authorize('view', $website); + + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + $widgets = $website->dashboardWidgets()->orderBy('sort_order')->get(); + + $widgetData = []; + foreach ($widgets as $widget) { + $widgetData[$widget->id] = $this->resolveWidgetData($widget, $service); + } + + return view('custom-dashboard.show', [ + 'website' => $website, + 'widgets' => $widgets, + 'widgetData' => $widgetData, + 'from' => $from, + 'to' => $to, + ]); + } + + public function manage(Website $website): View + { + $this->authorize('view', $website); + + return view('custom-dashboard.manage', [ + 'website' => $website, + 'widgets' => $website->dashboardWidgets()->orderBy('sort_order')->get(), + 'types' => DashboardWidget::$types, + 'metrics' => DashboardWidget::$metrics, + ]); + } + + public function store(Request $request, Website $website): RedirectResponse + { + $this->authorize('view', $website); + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'widget_type' => 'required|string|in:' . implode(',', array_keys(DashboardWidget::$types)), + 'size' => 'required|in:full,half,third', + 'config' => 'nullable|array', + 'config.metric' => 'nullable|string', + 'config.limit' => 'nullable|integer|min:1|max:50', + ]); + + $maxSort = $website->dashboardWidgets()->max('sort_order') ?? 0; + $validated['sort_order'] = $maxSort + 1; + + $website->dashboardWidgets()->create($validated); + + return redirect() + ->route('custom-dashboard.manage', $website) + ->with('success', 'Widget added.'); + } + + public function reorder(Request $request, Website $website): RedirectResponse + { + $this->authorize('view', $website); + + $request->validate([ + 'order' => 'required|array', + 'order.*' => 'integer|exists:dashboard_widgets,id', + ]); + + foreach ($request->order as $position => $widgetId) { + $website->dashboardWidgets()->where('id', $widgetId)->update(['sort_order' => $position]); + } + + return redirect() + ->route('custom-dashboard.manage', $website) + ->with('success', 'Widget order updated.'); + } + + public function destroy(Website $website, DashboardWidget $widget): RedirectResponse + { + $this->authorize('view', $website); + + $widget->delete(); + + return redirect() + ->route('custom-dashboard.manage', $website) + ->with('success', 'Widget removed.'); + } + + private function resolveWidgetData(DashboardWidget $widget, AnalyticsQueryService $service): mixed + { + $config = $widget->config ?? []; + $limit = $config['limit'] ?? 5; + + return match ($widget->widget_type) { + 'metric' => $service->summary()[$config['metric'] ?? 'page_views'] ?? 0, + 'chart' => $service->visitorsOverTime(), + 'top_pages' => $service->topPages($limit), + 'top_sources' => $service->trafficSources($limit), + 'events' => $service->eventsOverview($limit), + 'conversions' => $service->conversionsReport(), + 'new_vs_returning' => $service->newVsReturning(), + 'browsers' => $service->visitorsByBrowser($limit), + 'countries' => $service->visitorsByCountry($limit), + default => null, + }; + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 5a02e76..e98362c 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -18,17 +18,25 @@ class DashboardController extends Controller $this->authorize('view', $website); [$from, $to] = $this->parseDateRange($request); + [$prevFrom, $prevTo] = $this->previousPeriod($from, $to); $service = AnalyticsQueryService::for($website, $from, $to); + $prevService = AnalyticsQueryService::for($website, $prevFrom, $prevTo); + $metrics = $service->summary(); + $prevMetrics = $prevService->summary(); $chartData = $service->visitorsOverTime(); $activeVisitors = AnalyticsQueryService::activeVisitors($website); $goalConversions = $service->conversionsReport(); $goalChartData = $service->goalCompletionsOverTime(); + $topPages = $service->topPages(5); + $topSources = $service->trafficSources(5); + return view('dashboard.show', compact( - 'website', 'metrics', 'chartData', 'activeVisitors', + 'website', 'metrics', 'prevMetrics', 'chartData', 'activeVisitors', 'goalConversions', 'goalChartData', 'from', 'to', + 'topPages', 'topSources', )); } diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php new file mode 100644 index 0000000..e0975ef --- /dev/null +++ b/app/Http/Controllers/ExportController.php @@ -0,0 +1,209 @@ +authorize('view', $website); + + [$from, $to] = $this->parseDateRange($request); + $service = AnalyticsQueryService::for($website, $from, $to); + + [$headers, $rows] = match ($report) { + 'pages' => $this->pagesExport($service), + 'landing-pages' => $this->landingPagesExport($service), + 'exit-pages' => $this->exitPagesExport($service), + 'sources' => $this->sourcesExport($service), + 'referrers' => $this->referrersExport($service), + 'campaigns' => $this->campaignsExport($service), + 'events' => $this->eventsExport($service), + 'countries' => $this->countriesExport($service), + 'browsers' => $this->browsersExport($service), + 'languages' => $this->languagesExport($service), + 'summary' => $this->summaryExport($service, $from, $to), + default => abort(404, 'Unknown report type.'), + }; + + $filename = "{$website->domain}_{$report}_{$from->format('Y-m-d')}_to_{$to->format('Y-m-d')}.csv"; + + return $this->streamCsv($filename, $headers, $rows); + } + + private function streamCsv(string $filename, array $headers, iterable $rows): StreamedResponse + { + return response()->streamDownload(function () use ($headers, $rows) { + $handle = fopen('php://output', 'w'); + fputcsv($handle, $headers); + foreach ($rows as $row) { + fputcsv($handle, $row); + } + fclose($handle); + }, $filename, [ + 'Content-Type' => 'text/csv', + ]); + } + + private function pagesExport(AnalyticsQueryService $service): array + { + $data = $service->topPages(500); + + return [ + ['Path', 'Views', 'Unique Views', 'Avg Time on Page (s)'], + $data->map(fn ($r) => [ + $r->path, + $r->views, + $r->unique_views, + round($r->avg_time ?? 0, 1), + ]), + ]; + } + + private function landingPagesExport(AnalyticsQueryService $service): array + { + $data = $service->landingPages(500); + + return [ + ['Path', 'Entrances', 'Bounce Rate (%)'], + $data->map(fn ($r) => [ + $r->entry_page ?? $r->path ?? '—', + $r->entrances ?? $r->sessions ?? 0, + $r->bounce_rate ?? 0, + ]), + ]; + } + + private function exitPagesExport(AnalyticsQueryService $service): array + { + $data = $service->exitPages(500); + + return [ + ['Path', 'Exits'], + $data->map(fn ($r) => [ + $r->exit_page ?? $r->path ?? '—', + $r->exits ?? 0, + ]), + ]; + } + + private function sourcesExport(AnalyticsQueryService $service): array + { + $data = $service->trafficSources(500); + + return [ + ['Source', 'Sessions', 'Bounces', 'Avg Duration (s)', 'Avg Pages'], + $data->map(fn ($r) => [ + $r->source, + $r->sessions, + $r->bounces, + round($r->avg_duration ?? 0, 1), + round($r->avg_pages ?? 0, 1), + ]), + ]; + } + + private function referrersExport(AnalyticsQueryService $service): array + { + $data = $service->referrers(500); + + return [ + ['Referrer Domain', 'Sessions', 'Bounces', 'Avg Duration (s)'], + $data->map(fn ($r) => [ + $r->referrer_domain, + $r->sessions, + $r->bounces, + round($r->avg_duration ?? 0, 1), + ]), + ]; + } + + private function campaignsExport(AnalyticsQueryService $service): array + { + $data = $service->campaigns(500); + + return [ + ['UTM Source', 'UTM Medium', 'UTM Campaign', 'Sessions', 'Bounces', 'Avg Duration (s)'], + $data->map(fn ($r) => [ + $r->utm_source, + $r->utm_medium, + $r->utm_campaign, + $r->sessions, + $r->bounces, + round($r->avg_duration ?? 0, 1), + ]), + ]; + } + + private function eventsExport(AnalyticsQueryService $service): array + { + $data = $service->eventsOverview(500); + + return [ + ['Event Name', 'Category', 'Total', 'Unique Visitors', 'Total Value'], + $data->map(fn ($r) => [ + $r->name, + $r->category, + $r->total, + $r->unique_visitors, + $r->total_value, + ]), + ]; + } + + private function countriesExport(AnalyticsQueryService $service): array + { + $data = $service->visitorsByCountry(500); + + return [ + ['Country', 'Visitors'], + $data->map(fn ($r) => [$r->country, $r->visitors]), + ]; + } + + private function browsersExport(AnalyticsQueryService $service): array + { + $data = $service->visitorsByBrowser(100); + + return [ + ['Browser', 'Visitors'], + $data->map(fn ($r) => [$r->browser, $r->visitors]), + ]; + } + + private function languagesExport(AnalyticsQueryService $service): array + { + $data = $service->visitorsByLanguage(100); + + return [ + ['Language', 'Visitors'], + $data->map(fn ($r) => [$r->language, $r->visitors]), + ]; + } + + private function summaryExport(AnalyticsQueryService $service, $from, $to): array + { + $summary = $service->summary(); + + return [ + ['Metric', 'Value'], + collect([ + ['Period', $from->format('Y-m-d') . ' to ' . $to->format('Y-m-d')], + ['Page Views', $summary['page_views']], + ['Unique Visitors', $summary['visitors']], + ['Sessions', $summary['sessions']], + ['Bounce Rate (%)', $summary['bounce_rate']], + ['Avg Duration (s)', $summary['avg_duration']], + ['Pages / Session', $summary['avg_pages']], + ]), + ]; + } +} diff --git a/app/Http/Controllers/FunnelController.php b/app/Http/Controllers/FunnelController.php new file mode 100644 index 0000000..1690ec8 --- /dev/null +++ b/app/Http/Controllers/FunnelController.php @@ -0,0 +1,110 @@ +authorize('view', $website); + + return view('funnels.index', [ + 'website' => $website, + 'funnels' => $website->funnels()->orderBy('name')->get(), + ]); + } + + public function create(Website $website): View + { + $this->authorize('view', $website); + + return view('funnels.create', [ + 'website' => $website, + ]); + } + + public function store(Request $request, Website $website): RedirectResponse + { + $this->authorize('view', $website); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'steps' => 'required|array|min:2', + 'steps.*.type' => 'required|in:pageview,event', + 'steps.*.value' => 'required|string|max:255', + 'steps.*.label' => 'required|string|max:255', + ]); + + $website->funnels()->create($validated); + + return redirect() + ->route('funnels.index', $website) + ->with('success', 'Funnel created successfully.'); + } + + public function show(Request $request, Website $website, Funnel $funnel): View + { + $this->authorize('view', $website); + + [$from, $to] = $this->parseDateRange($request); + $service = new FunnelAnalysisService($funnel, $from, $to); + + return view('funnels.show', [ + 'website' => $website, + 'funnel' => $funnel, + 'steps' => $service->analyze(), + 'from' => $from, + 'to' => $to, + ]); + } + + public function edit(Website $website, Funnel $funnel): View + { + $this->authorize('view', $website); + + return view('funnels.edit', [ + 'website' => $website, + 'funnel' => $funnel, + ]); + } + + public function update(Request $request, Website $website, Funnel $funnel): RedirectResponse + { + $this->authorize('view', $website); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'steps' => 'required|array|min:2', + 'steps.*.type' => 'required|in:pageview,event', + 'steps.*.value' => 'required|string|max:255', + 'steps.*.label' => 'required|string|max:255', + ]); + + $funnel->update($validated); + + return redirect() + ->route('funnels.show', [$website, $funnel]) + ->with('success', 'Funnel updated successfully.'); + } + + public function destroy(Website $website, Funnel $funnel): RedirectResponse + { + $this->authorize('view', $website); + + $funnel->delete(); + + return redirect() + ->route('funnels.index', $website) + ->with('success', 'Funnel deleted.'); + } +} diff --git a/app/Http/Controllers/SegmentController.php b/app/Http/Controllers/SegmentController.php new file mode 100644 index 0000000..a1cf673 --- /dev/null +++ b/app/Http/Controllers/SegmentController.php @@ -0,0 +1,88 @@ +authorize('view', $website); + + return view('segments.index', [ + 'website' => $website, + 'segments' => $website->segments()->orderBy('name')->get(), + ]); + } + + public function create(Website $website): View + { + $this->authorize('view', $website); + + return view('segments.create', ['website' => $website]); + } + + public function store(Request $request, Website $website): RedirectResponse + { + $this->authorize('view', $website); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'filters' => 'required|array|min:1', + 'filters.*.field' => 'required|string|in:country,region,city,browser,os,device_type,language,screen_resolution', + 'filters.*.operator' => 'required|string|in:=,!=,contains,starts_with', + 'filters.*.value' => 'required|string|max:255', + ]); + + $website->segments()->create($validated); + + return redirect() + ->route('segments.index', $website) + ->with('success', 'Segment created successfully.'); + } + + public function edit(Website $website, Segment $segment): View + { + $this->authorize('view', $website); + + return view('segments.edit', [ + 'website' => $website, + 'segment' => $segment, + ]); + } + + public function update(Request $request, Website $website, Segment $segment): RedirectResponse + { + $this->authorize('view', $website); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'filters' => 'required|array|min:1', + 'filters.*.field' => 'required|string|in:country,region,city,browser,os,device_type,language,screen_resolution', + 'filters.*.operator' => 'required|string|in:=,!=,contains,starts_with', + 'filters.*.value' => 'required|string|max:255', + ]); + + $segment->update($validated); + + return redirect() + ->route('segments.index', $website) + ->with('success', 'Segment updated successfully.'); + } + + public function destroy(Website $website, Segment $segment): RedirectResponse + { + $this->authorize('view', $website); + + $segment->delete(); + + return redirect() + ->route('segments.index', $website) + ->with('success', 'Segment deleted.'); + } +} diff --git a/app/Http/Controllers/UtmBuilderController.php b/app/Http/Controllers/UtmBuilderController.php new file mode 100644 index 0000000..cd9393b --- /dev/null +++ b/app/Http/Controllers/UtmBuilderController.php @@ -0,0 +1,18 @@ +authorize('view', $website); + + return view('utm-builder.show', [ + 'website' => $website, + ]); + } +} diff --git a/app/Http/Resources/V1/EventResource.php b/app/Http/Resources/V1/EventResource.php new file mode 100644 index 0000000..1edbc76 --- /dev/null +++ b/app/Http/Resources/V1/EventResource.php @@ -0,0 +1,20 @@ + $this->name, + 'category' => $this->category, + 'total' => (int) $this->total, + 'unique_visitors' => (int) $this->unique_visitors, + 'total_value' => $this->total_value ? round((float) $this->total_value, 4) : null, + ]; + } +} diff --git a/app/Http/Resources/V1/PageResource.php b/app/Http/Resources/V1/PageResource.php new file mode 100644 index 0000000..062bc2f --- /dev/null +++ b/app/Http/Resources/V1/PageResource.php @@ -0,0 +1,19 @@ + $this->path, + 'views' => (int) $this->views, + 'unique_views' => (int) $this->unique_views, + 'avg_time_on_page' => round((float) ($this->avg_time ?? 0), 1), + ]; + } +} diff --git a/app/Http/Resources/V1/ReferrerResource.php b/app/Http/Resources/V1/ReferrerResource.php new file mode 100644 index 0000000..6452068 --- /dev/null +++ b/app/Http/Resources/V1/ReferrerResource.php @@ -0,0 +1,20 @@ + $this->source, + 'sessions' => (int) $this->sessions, + 'bounce_rate' => $this->sessions > 0 ? round(($this->bounces / $this->sessions) * 100, 1) : 0, + 'avg_duration' => round((float) ($this->avg_duration ?? 0), 1), + 'avg_pages' => round((float) ($this->avg_pages ?? 0), 1), + ]; + } +} diff --git a/app/Http/Resources/V1/StatsResource.php b/app/Http/Resources/V1/StatsResource.php new file mode 100644 index 0000000..f3c9de5 --- /dev/null +++ b/app/Http/Resources/V1/StatsResource.php @@ -0,0 +1,30 @@ +resource; + + return [ + 'period' => [ + 'from' => $data['from'], + 'to' => $data['to'], + ], + 'metrics' => [ + 'page_views' => $data['metrics']['page_views'], + 'visitors' => $data['metrics']['visitors'], + 'sessions' => $data['metrics']['sessions'], + 'bounce_rate' => $data['metrics']['bounce_rate'], + 'avg_duration' => $data['metrics']['avg_duration'], + 'avg_pages_per_session' => $data['metrics']['avg_pages'], + ], + 'timeseries' => $data['timeseries'], + ]; + } +} diff --git a/app/Http/Resources/V1/VisitorBreakdownResource.php b/app/Http/Resources/V1/VisitorBreakdownResource.php new file mode 100644 index 0000000..da5da97 --- /dev/null +++ b/app/Http/Resources/V1/VisitorBreakdownResource.php @@ -0,0 +1,27 @@ +resource; + + return [ + 'period' => [ + 'from' => $data['from'], + 'to' => $data['to'], + ], + 'countries' => $data['countries'], + 'browsers' => $data['browsers'], + 'operating_systems' => $data['operating_systems'], + 'device_types' => $data['device_types'], + 'languages' => $data['languages'], + 'new_vs_returning' => $data['new_vs_returning'], + ]; + } +} diff --git a/app/Http/Resources/V1/WebsiteResource.php b/app/Http/Resources/V1/WebsiteResource.php new file mode 100644 index 0000000..821f246 --- /dev/null +++ b/app/Http/Resources/V1/WebsiteResource.php @@ -0,0 +1,21 @@ + $this->id, + 'name' => $this->name, + 'domain' => $this->domain, + 'tracking_id' => $this->tracking_id, + 'is_active' => $this->is_active, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Mail/AlertTriggered.php b/app/Mail/AlertTriggered.php new file mode 100644 index 0000000..5425715 --- /dev/null +++ b/app/Mail/AlertTriggered.php @@ -0,0 +1,35 @@ +alert->name} — {$this->alert->website->name}", + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.alert-triggered', + ); + } +} diff --git a/app/Models/Alert.php b/app/Models/Alert.php new file mode 100644 index 0000000..bde7a76 --- /dev/null +++ b/app/Models/Alert.php @@ -0,0 +1,58 @@ + 'decimal:2', + 'is_active' => 'boolean', + 'last_triggered_at' => 'datetime', + ]; + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } + + public function notifications(): HasMany + { + return $this->hasMany(AlertNotification::class); + } + + public static array $metrics = [ + 'page_views' => 'Page Views', + 'visitors' => 'Unique Visitors', + 'sessions' => 'Sessions', + 'bounce_rate' => 'Bounce Rate', + ]; + + public static array $conditions = [ + 'above' => 'goes above', + 'below' => 'drops below', + 'change_up' => '% increase exceeds', + 'change_down' => '% decrease exceeds', + ]; +} diff --git a/app/Models/AlertNotification.php b/app/Models/AlertNotification.php new file mode 100644 index 0000000..515b007 --- /dev/null +++ b/app/Models/AlertNotification.php @@ -0,0 +1,29 @@ + 'decimal:2', + 'is_read' => 'boolean', + ]; + } + + public function alert(): BelongsTo + { + return $this->belongsTo(Alert::class); + } +} diff --git a/app/Models/Annotation.php b/app/Models/Annotation.php new file mode 100644 index 0000000..4c5bfbe --- /dev/null +++ b/app/Models/Annotation.php @@ -0,0 +1,41 @@ + 'date', + ]; + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public static array $categories = [ + 'general' => 'General', + 'deployment' => 'Deployment', + 'campaign' => 'Campaign', + 'incident' => 'Incident', + ]; +} diff --git a/app/Models/DashboardWidget.php b/app/Models/DashboardWidget.php new file mode 100644 index 0000000..5980936 --- /dev/null +++ b/app/Models/DashboardWidget.php @@ -0,0 +1,55 @@ + 'array', + 'sort_order' => 'integer', + ]; + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } + + public static array $types = [ + 'metric' => 'Single Metric', + 'chart' => 'Visitors Chart', + 'top_pages' => 'Top Pages', + 'top_sources' => 'Top Sources', + 'events' => 'Events Overview', + 'conversions' => 'Goal Conversions', + 'new_vs_returning' => 'New vs Returning', + 'browsers' => 'Browsers', + 'countries' => 'Countries', + ]; + + public static array $metrics = [ + 'page_views' => 'Page Views', + 'visitors' => 'Unique Visitors', + 'sessions' => 'Sessions', + 'bounce_rate' => 'Bounce Rate', + 'avg_duration' => 'Avg. Duration', + 'avg_pages' => 'Pages / Session', + ]; +} diff --git a/app/Models/Funnel.php b/app/Models/Funnel.php new file mode 100644 index 0000000..d6c88e5 --- /dev/null +++ b/app/Models/Funnel.php @@ -0,0 +1,32 @@ + 'array', + 'is_active' => 'boolean', + ]; + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } +} diff --git a/app/Models/Segment.php b/app/Models/Segment.php new file mode 100644 index 0000000..369983f --- /dev/null +++ b/app/Models/Segment.php @@ -0,0 +1,59 @@ + 'array', + ]; + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } + + /** + * Get the visitor IDs that match this segment's filters. + */ + public function matchingVisitorIds(): \Illuminate\Support\Collection + { + $query = Visitor::where('website_id', $this->website_id); + + foreach ($this->filters as $filter) { + $field = $filter['field']; + $operator = $filter['operator'] ?? '='; + $value = $filter['value']; + + $allowedFields = ['country', 'region', 'city', 'browser', 'os', 'device_type', 'language', 'screen_resolution']; + if (! in_array($field, $allowedFields)) { + continue; + } + + match ($operator) { + '=' => $query->where($field, $value), + '!=' => $query->where($field, '!=', $value), + 'contains' => $query->where($field, 'like', "%{$value}%"), + 'starts_with' => $query->where($field, 'like', "{$value}%"), + default => null, + }; + } + + return $query->pluck('id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index b0463c6..3628cf5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,11 +8,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. diff --git a/app/Models/Website.php b/app/Models/Website.php index b6fba31..11aa3af 100644 --- a/app/Models/Website.php +++ b/app/Models/Website.php @@ -73,6 +73,31 @@ class Website extends Model return $this->hasMany(GoalConversion::class); } + public function funnels(): HasMany + { + return $this->hasMany(Funnel::class); + } + + public function segments(): HasMany + { + return $this->hasMany(Segment::class); + } + + public function dashboardWidgets(): HasMany + { + return $this->hasMany(DashboardWidget::class); + } + + public function alerts(): HasMany + { + return $this->hasMany(Alert::class); + } + + public function annotations(): HasMany + { + return $this->hasMany(Annotation::class); + } + public function dailyAggregates(): HasMany { return $this->hasMany(DailyAggregate::class); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1a02609..303b6ec 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -19,5 +19,11 @@ class AppServiceProvider extends ServiceProvider RateLimiter::for('collect', function (Request $request) { return Limit::perMinute(120)->by($request->ip()); }); + + RateLimiter::for('api', function (Request $request) { + $key = $request->user()?->id ?: $request->ip(); + + return Limit::perMinute(60)->by($key); + }); } } diff --git a/app/Services/AnalyticsQueryService.php b/app/Services/AnalyticsQueryService.php index 7e60dae..3ba575d 100644 --- a/app/Services/AnalyticsQueryService.php +++ b/app/Services/AnalyticsQueryService.php @@ -16,6 +16,8 @@ class AnalyticsQueryService { private readonly Carbon $todayStart; + private ?Collection $segmentVisitorIds = null; + public function __construct( private readonly Website $website, private readonly Carbon $from, @@ -29,6 +31,27 @@ class AnalyticsQueryService return new self($website, $from->startOfDay(), $to->endOfDay()); } + public function withSegment(?Collection $visitorIds): self + { + $this->segmentVisitorIds = $visitorIds; + + return $this; + } + + public function isSegmented(): bool + { + return $this->segmentVisitorIds !== null; + } + + private function applySegmentFilter($query, string $column = 'visitor_id') + { + if ($this->segmentVisitorIds !== null) { + $query->whereIn($column, $this->segmentVisitorIds); + } + + return $query; + } + /** * Split the requested date range into two parts: * - "aggregate range": completed days that can be served from aggregate tables @@ -76,6 +99,13 @@ class AnalyticsQueryService public function totalPageViews(): int { + if ($this->isSegmented()) { + return $this->applySegmentFilter( + PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$this->from, $this->to]) + )->count(); + } + $total = 0; [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); @@ -96,6 +126,13 @@ class AnalyticsQueryService public function uniqueVisitors(): int { + if ($this->isSegmented()) { + return $this->applySegmentFilter( + PageView::where('website_id', $this->website->id) + ->whereBetween('viewed_at', [$this->from, $this->to]) + )->distinct('visitor_id')->count('visitor_id'); + } + $total = 0; [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); @@ -117,6 +154,13 @@ class AnalyticsQueryService public function totalSessions(): int { + if ($this->isSegmented()) { + return $this->applySegmentFilter( + AnalyticsSession::where('website_id', $this->website->id) + ->whereBetween('started_at', [$this->from, $this->to]) + )->count(); + } + $total = 0; [$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange(); diff --git a/app/Services/FunnelAnalysisService.php b/app/Services/FunnelAnalysisService.php new file mode 100644 index 0000000..9706ee2 --- /dev/null +++ b/app/Services/FunnelAnalysisService.php @@ -0,0 +1,84 @@ +funnel->steps; + if (empty($steps)) { + return []; + } + + $websiteId = $this->funnel->website_id; + + // Step 1: get all visitor IDs who completed the first step + $currentVisitorIds = $this->visitorIdsForStep($steps[0], $websiteId); + $firstStepCount = $currentVisitorIds->count(); + + $results = []; + foreach ($steps as $i => $step) { + if ($i === 0) { + $stepVisitors = $firstStepCount; + } else { + $stepVisitorIds = $this->visitorIdsForStep($step, $websiteId); + $currentVisitorIds = $currentVisitorIds->intersect($stepVisitorIds); + $stepVisitors = $currentVisitorIds->count(); + } + + $prevCount = $i > 0 ? $results[$i - 1]['visitors'] : $stepVisitors; + $dropOff = $prevCount - $stepVisitors; + $dropOffPct = $prevCount > 0 ? round(($dropOff / $prevCount) * 100, 1) : 0; + $conversionPct = $firstStepCount > 0 ? round(($stepVisitors / $firstStepCount) * 100, 1) : 0; + + $results[] = [ + 'label' => $step['label'] ?? $step['value'], + 'type' => $step['type'], + 'value' => $step['value'], + 'visitors' => $stepVisitors, + 'drop_off' => $dropOff, + 'drop_off_pct' => $dropOffPct, + 'conversion_pct' => $conversionPct, + ]; + } + + return $results; + } + + private function visitorIdsForStep(array $step, int $websiteId): Collection + { + if ($step['type'] === 'pageview') { + return PageView::where('website_id', $websiteId) + ->whereBetween('viewed_at', [$this->from, $this->to]) + ->where('path', $step['value']) + ->distinct() + ->pluck('visitor_id'); + } + + return Event::where('website_id', $websiteId) + ->whereBetween('occurred_at', [$this->from, $this->to]) + ->where('name', $step['value']) + ->distinct() + ->pluck('visitor_id'); + } +} diff --git a/composer.json b/composer.json index b42db3d..a9cabe0 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "jaybizzle/crawler-detect": "^1.3", "laravel/fortify": "^1.36", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "stevebauman/location": "^7.6" }, diff --git a/composer.lock b/composer.lock index 7156cff..b8a6191 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9d9708b2d4e7cc126272d691e95fa963", + "content-hash": "ea6b8a5091bcb1b3c932f36a03f8f62f", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1684,6 +1684,69 @@ }, "time": "2026-03-01T09:02:38+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-02-07T17:19:31+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.10", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/migrations/2026_03_19_150129_create_personal_access_tokens_table.php b/database/migrations/2026_03_19_150129_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_03_19_150129_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_03_19_200001_create_funnels_table.php b/database/migrations/2026_03_19_200001_create_funnels_table.php new file mode 100644 index 0000000..6fcb0f4 --- /dev/null +++ b/database/migrations/2026_03_19_200001_create_funnels_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('website_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->json('steps'); // [{type: "pageview"|"event", value: "/checkout", label: "Checkout Page"}, ...] + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('funnels'); + } +}; diff --git a/database/migrations/2026_03_19_200002_create_segments_table.php b/database/migrations/2026_03_19_200002_create_segments_table.php new file mode 100644 index 0000000..3f3ac38 --- /dev/null +++ b/database/migrations/2026_03_19_200002_create_segments_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('website_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->json('filters'); // [{field: "country", operator: "=", value: "DE"}, {field: "device_type", operator: "=", value: "mobile"}] + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('segments'); + } +}; diff --git a/database/migrations/2026_03_19_200003_create_dashboard_widgets_table.php b/database/migrations/2026_03_19_200003_create_dashboard_widgets_table.php new file mode 100644 index 0000000..863bb83 --- /dev/null +++ b/database/migrations/2026_03_19_200003_create_dashboard_widgets_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('website_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('widget_type'); // metric, chart, top_pages, top_sources, events, conversions + $table->json('config')->nullable(); // {metric: "page_views", chart_type: "line", limit: 5, ...} + $table->unsignedSmallInteger('sort_order')->default(0); + $table->string('size', 10)->default('half'); // full, half, third + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('dashboard_widgets'); + } +}; diff --git a/database/migrations/2026_03_19_200004_create_alerts_table.php b/database/migrations/2026_03_19_200004_create_alerts_table.php new file mode 100644 index 0000000..f56a4f7 --- /dev/null +++ b/database/migrations/2026_03_19_200004_create_alerts_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('website_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('metric'); // page_views, visitors, sessions, bounce_rate, goal_conversions + $table->string('condition'); // above, below, change_up, change_down + $table->decimal('threshold', 12, 2); + $table->string('period')->default('daily'); // daily, hourly + $table->string('channel')->default('email'); // email, in_app, both + $table->boolean('is_active')->default(true); + $table->timestamp('last_triggered_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('alert_notifications', function (Blueprint $table) { + $table->id(); + $table->foreignId('alert_id')->constrained()->cascadeOnDelete(); + $table->decimal('value', 12, 2); + $table->text('message'); + $table->boolean('is_read')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('alert_notifications'); + Schema::dropIfExists('alerts'); + } +}; diff --git a/database/migrations/2026_03_19_200005_create_annotations_table.php b/database/migrations/2026_03_19_200005_create_annotations_table.php new file mode 100644 index 0000000..bac2839 --- /dev/null +++ b/database/migrations/2026_03_19_200005_create_annotations_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('website_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->date('date'); + $table->string('text'); + $table->string('category')->default('general'); // general, deployment, campaign, incident + $table->timestamps(); + + $table->index(['website_id', 'date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('annotations'); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index b5c61c9..0bd0ebc 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,3 +1,7 @@ @tailwind base; @tailwind components; @tailwind utilities; + +[x-cloak] { display: none !important; } + +.tabular-nums { font-variant-numeric: tabular-nums; } diff --git a/resources/views/alerts/_form.blade.php b/resources/views/alerts/_form.blade.php new file mode 100644 index 0000000..32abb6a --- /dev/null +++ b/resources/views/alerts/_form.blade.php @@ -0,0 +1,63 @@ +
+
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + + @error('threshold')

{{ $message }}

@enderror +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + Cancel +
+
diff --git a/resources/views/alerts/create.blade.php b/resources/views/alerts/create.blade.php new file mode 100644 index 0000000..2a8a62b --- /dev/null +++ b/resources/views/alerts/create.blade.php @@ -0,0 +1,10 @@ + +
+
+
+ @csrf + @include('alerts._form', ['alert' => new \App\Models\Alert]) +
+
+
+
diff --git a/resources/views/alerts/edit.blade.php b/resources/views/alerts/edit.blade.php new file mode 100644 index 0000000..89e89e7 --- /dev/null +++ b/resources/views/alerts/edit.blade.php @@ -0,0 +1,23 @@ + +
+
+
+ @csrf + @method('PUT') + @include('alerts._form', ['alert' => $alert]) +
+
+ +
+

Danger Zone

+

Permanently delete this alert and all its notification history.

+
+ @csrf + @method('DELETE') + +
+
+
+
diff --git a/resources/views/alerts/index.blade.php b/resources/views/alerts/index.blade.php new file mode 100644 index 0000000..e2ed56f --- /dev/null +++ b/resources/views/alerts/index.blade.php @@ -0,0 +1,70 @@ + + + + + + @if (session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if ($alerts->isEmpty()) + + @else +
+ + + + + + + + + + + + + @foreach ($alerts as $alert) + + + + + + + + + @endforeach + +
NameConditionChannelLast TriggeredStatus
{{ $alert->name }} + {{ \App\Models\Alert::$metrics[$alert->metric] ?? $alert->metric }} + {{ \App\Models\Alert::$conditions[$alert->condition] ?? $alert->condition }} + {{ $alert->threshold }} + {{ $alert->channel }} + {{ $alert->last_triggered_at ? $alert->last_triggered_at->diffForHumans() : 'Never' }} + + + {{ $alert->is_active ? 'Active' : 'Paused' }} + + + Edit +
+
+ @endif +
diff --git a/resources/views/alerts/notifications.blade.php b/resources/views/alerts/notifications.blade.php new file mode 100644 index 0000000..d69e074 --- /dev/null +++ b/resources/views/alerts/notifications.blade.php @@ -0,0 +1,32 @@ + + + + + Back to Alerts + + + + @if ($notifications->isEmpty()) + + @else +
+
+ @foreach ($notifications as $notification) +
+
+
+

{{ $notification->alert->name }}

+

{{ $notification->message }}

+
+ {{ $notification->created_at->diffForHumans() }} +
+
+ @endforeach +
+
+ +
+ {{ $notifications->links() }} +
+ @endif +
diff --git a/resources/views/annotations/index.blade.php b/resources/views/annotations/index.blade.php new file mode 100644 index 0000000..d99f2f2 --- /dev/null +++ b/resources/views/annotations/index.blade.php @@ -0,0 +1,89 @@ + + @if (session('success')) +
+

{{ session('success') }}

+
+ @endif + +
+ {{-- Add annotation form --}} +
+
+

Add Annotation

+
+ @csrf +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + {{-- Annotations list --}} +
+ @if ($annotations->isEmpty()) + + @else +
+
+ @foreach ($annotations as $annotation) +
+
+ @php + $categoryColors = [ + 'general' => 'bg-gray-100 text-gray-600', + 'deployment' => 'bg-blue-100 text-blue-700', + 'campaign' => 'bg-purple-100 text-purple-700', + 'incident' => 'bg-red-100 text-red-700', + ]; + @endphp + + {{ \App\Models\Annotation::$categories[$annotation->category] ?? $annotation->category }} + +
+

{{ $annotation->text }}

+

+ {{ $annotation->date->format('M j, Y') }} + · {{ $annotation->user->name }} +

+
+
+
+ @csrf + @method('DELETE') + +
+
+ @endforeach +
+
+
{{ $annotations->links() }}
+ @endif +
+
+
diff --git a/resources/views/api-tokens/index.blade.php b/resources/views/api-tokens/index.blade.php new file mode 100644 index 0000000..ae418cf --- /dev/null +++ b/resources/views/api-tokens/index.blade.php @@ -0,0 +1,161 @@ + + +

API Tokens

+
+ +
+
+ @if (session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if (session('newToken')) +
+
+ + + +
+

Copy your new API token now — it won't be shown again.

+
+ {{ session('newToken') }} + +
+
+
+
+ @endif + + {{-- Create token --}} +
+

Create API Token

+

Generate a token to access the analytics API programmatically.

+ +
+ @csrf +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+
+ + {{-- Token list --}} +
+
+

Your API Tokens

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

No API tokens yet. Create one above to get started.

+
+ @else +
+ @foreach ($tokens as $token) +
+
+

{{ $token->name }}

+

+ Created {{ $token->created_at->diffForHumans() }} + @if ($token->last_used_at) + · Last used {{ $token->last_used_at->diffForHumans() }} + @else + · Never used + @endif +

+
+
+ @csrf + @method('DELETE') + +
+
+ @endforeach +
+ @endif +
+ + {{-- API docs snippet --}} +
+

Quick Start

+

Use your token in the Authorization header to access the API.

+ +
curl -H "Authorization: Bearer YOUR_TOKEN" \
+  {{ url('/api/v1/websites') }}
+ +

Available Endpoints

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/api/v1/websitesList your websites
GET/api/v1/websites/{id}/statsSummary metrics + timeseries
GET/api/v1/websites/{id}/pagesTop pages by views
GET/api/v1/websites/{id}/referrersTraffic sources
GET/api/v1/websites/{id}/eventsEvents report
GET/api/v1/websites/{id}/visitorsAudience breakdown
+
+ +

All endpoints accept optional from and to query params (YYYY-MM-DD). Default: last 7 days. Rate limit: 60 requests/minute per token.

+
+
+
+
diff --git a/resources/views/components/analytics-layout.blade.php b/resources/views/components/analytics-layout.blade.php new file mode 100644 index 0000000..f6d2bd2 --- /dev/null +++ b/resources/views/components/analytics-layout.blade.php @@ -0,0 +1,270 @@ +@props(['website', 'from' => null, 'to' => null, 'title' => '', 'showDatePicker' => true]) + + + + + + + + {{ $title ? $title . ' — ' : '' }}{{ $website->name }} — {{ config('app.name', 'Analytics') }} + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ {{-- Sidebar overlay (mobile) --}} +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+ {{-- Top bar --}} +
+
+ +

{{ $title ?: 'Dashboard' }}

+ @isset($subtitle) + + @endisset +
+ +
+ @isset($headerActions) + {{ $headerActions }} + @endisset + + @if ($showDatePicker && $from && $to) + + @endif +
+
+ + {{-- Page content --}} +
+ {{ $slot }} +
+
+
+ + @stack('scripts') + + diff --git a/resources/views/components/empty-state.blade.php b/resources/views/components/empty-state.blade.php new file mode 100644 index 0000000..a660781 --- /dev/null +++ b/resources/views/components/empty-state.blade.php @@ -0,0 +1,19 @@ +@props(['message' => 'No data available for this period.', 'compact' => false, 'icon' => 'chart']) + +@php + $icons = [ + 'chart' => '', + 'globe' => '', + 'bolt' => '', + ]; +@endphp + +
+ @if (!$compact) + + {!! $icons[$icon] ?? $icons['chart'] !!} + + @endif +

{{ $message }}

+ {{ $slot ?? '' }} +
diff --git a/resources/views/components/export-button.blade.php b/resources/views/components/export-button.blade.php new file mode 100644 index 0000000..2ff6bce --- /dev/null +++ b/resources/views/components/export-button.blade.php @@ -0,0 +1,15 @@ +@props(['website', 'report', 'from' => null, 'to' => null]) + +@php + $params = ['report' => $report]; + if ($from) $params['from'] = $from instanceof \Carbon\Carbon ? $from->toDateString() : $from; + if ($to) $params['to'] = $to instanceof \Carbon\Carbon ? $to->toDateString() : $to; + if (request('range')) $params['range'] = request('range'); +@endphp + + + + CSV + diff --git a/resources/views/components/metric-card.blade.php b/resources/views/components/metric-card.blade.php new file mode 100644 index 0000000..d77b14d --- /dev/null +++ b/resources/views/components/metric-card.blade.php @@ -0,0 +1,43 @@ +@props(['label', 'value', 'previousValue' => null, 'format' => 'number', 'suffix' => '']) + +@php + $formatted = match($format) { + 'duration' => gmdate('i:s', (int) $value), + 'percent' => $value . '%', + default => number_format($value) . $suffix, + }; + + $delta = null; + $deltaClass = ''; + $deltaIcon = ''; + if ($previousValue !== null && is_numeric($previousValue)) { + if ($previousValue > 0) { + $delta = round((($value - $previousValue) / $previousValue) * 100, 1); + } elseif ($value > 0) { + $delta = 100; + } + + if ($delta !== null) { + $isNegativeBetter = in_array($format, ['percent']) && str_contains(strtolower($label), 'bounce'); + $isPositive = $delta > 0; + $isGood = $isNegativeBetter ? !$isPositive : $isPositive; + + $deltaClass = $delta == 0 + ? 'text-gray-500 bg-gray-50' + : ($isGood ? 'text-emerald-700 bg-emerald-50' : 'text-red-700 bg-red-50'); + $deltaIcon = $delta > 0 ? '↑' : ($delta < 0 ? '↓' : '→'); + } + } +@endphp + +
+
{{ $label }}
+
+ {{ $formatted }} + @if ($delta !== null) + + {{ $deltaIcon }} {{ abs($delta) }}% + + @endif +
+
diff --git a/resources/views/components/report-layout.blade.php b/resources/views/components/report-layout.blade.php index 6d59941..9aa15ee 100644 --- a/resources/views/components/report-layout.blade.php +++ b/resources/views/components/report-layout.blade.php @@ -1,77 +1,11 @@ @props(['website', 'from', 'to', 'title']) - - -
-
- - - - - -

{{ $title }}

- {{ $website->domain }} -
- -
-
- -
-
-
- {{-- Sidebar --}} - - - {{-- Main Content --}} -
- {{ $slot }} -
-
-
-
+ + {{ $slot }} @isset($scripts) @push('scripts') {{ $scripts }} @endpush @endisset -
+ diff --git a/resources/views/custom-dashboard/manage.blade.php b/resources/views/custom-dashboard/manage.blade.php new file mode 100644 index 0000000..b081508 --- /dev/null +++ b/resources/views/custom-dashboard/manage.blade.php @@ -0,0 +1,85 @@ + + + + + View Dashboard + + + + @if (session('success')) +
+

{{ session('success') }}

+
+ @endif + +
+ {{-- Add widget form --}} +
+

Add Widget

+
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {{-- Current widgets --}} +
+

Current Widgets

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

No widgets yet. Add one using the form.

+ @else +
+ @foreach ($widgets as $widget) +
+
+

{{ $widget->title }}

+

{{ \App\Models\DashboardWidget::$types[$widget->widget_type] ?? $widget->widget_type }} · {{ $widget->size }}

+
+
+ @csrf + @method('DELETE') + +
+
+ @endforeach +
+ @endif +
+
+
diff --git a/resources/views/custom-dashboard/show.blade.php b/resources/views/custom-dashboard/show.blade.php new file mode 100644 index 0000000..c1116e4 --- /dev/null +++ b/resources/views/custom-dashboard/show.blade.php @@ -0,0 +1,120 @@ + + + + + Manage Widgets + + + + @if ($widgets->isEmpty()) + + + Manage Widgets + + + @else +
+ @foreach ($widgets as $widget) + @php + $colSpan = match($widget->size) { + 'full' => 'sm:col-span-2 lg:col-span-3', + 'half' => 'sm:col-span-1 lg:col-span-2' , + default => '', + }; + $data = $widgetData[$widget->id] ?? null; + @endphp +
+

{{ $widget->title }}

+ + @if ($widget->widget_type === 'metric') + @php $metricLabel = \App\Models\DashboardWidget::$metrics[$widget->config['metric'] ?? 'page_views'] ?? 'Metric'; @endphp +

+ @if (($widget->config['metric'] ?? '') === 'bounce_rate') + {{ $data }}% + @elseif (($widget->config['metric'] ?? '') === 'avg_duration') + {{ gmdate('i:s', (int) $data) }} + @else + {{ number_format($data) }} + @endif +

+

{{ $metricLabel }}

+ + @elseif ($widget->widget_type === 'chart') +
+ +
+ + + @elseif (in_array($widget->widget_type, ['top_pages', 'top_sources', 'browsers', 'countries', 'events'])) + @if ($data && $data->isNotEmpty()) +
+ @foreach ($data->take(5) as $row) +
+ + {{ $row->path ?? $row->source ?? $row->browser ?? $row->country ?? $row->name ?? '—' }} + + + {{ number_format($row->views ?? $row->sessions ?? $row->visitors ?? $row->total ?? 0) }} + +
+ @endforeach +
+ @else +

No data for this period.

+ @endif + + @elseif ($widget->widget_type === 'new_vs_returning') +
+
+

{{ $data['new_pct'] ?? 0 }}%

+

New

+
+
+

{{ $data['returning_pct'] ?? 0 }}%

+

Returning

+
+
+ + @elseif ($widget->widget_type === 'conversions') + @if ($data && $data->isNotEmpty()) +
+ @foreach ($data->take(5) as $goal) +
+ {{ $goal->name }} + {{ number_format($goal->conversions) }} +
+ @endforeach +
+ @else +

No conversions for this period.

+ @endif + @endif +
+ @endforeach +
+ @endif +
diff --git a/resources/views/dashboard/show.blade.php b/resources/views/dashboard/show.blade.php index 7894374..29d4e41 100644 --- a/resources/views/dashboard/show.blade.php +++ b/resources/views/dashboard/show.blade.php @@ -1,306 +1,222 @@ - - -
-
- - - - - -

- {{ $website->name }} -

- {{ $website->domain }} -
- -
- {{-- Real-time indicator --}} -
- - - - - - active now - -
- - - - -
+ + + -
-
+
+ {{-- Metric cards --}} +
+ + + + + + +
- {{-- Metric Cards --}} -
-
-
Page Views
-
{{ number_format($metrics['page_views']) }}
-
-
-
Unique Visitors
-
{{ number_format($metrics['visitors']) }}
-
-
-
Sessions
-
{{ number_format($metrics['sessions']) }}
-
-
-
Bounce Rate
-
{{ $metrics['bounce_rate'] }}%
-
-
-
Avg. Duration
-
{{ gmdate('i:s', (int) $metrics['avg_duration']) }}
-
-
-
Pages / Session
-
{{ $metrics['avg_pages'] }}
+ {{-- Visitors chart --}} +
+
+

Visitors & Page Views

+
+ Visitors + Page Views + Sessions
+ @if (collect($chartData)->sum('page_views') > 0) +
+ @else + + @endif +
- {{-- Visitors Chart --}} -
+ {{-- Two-column: Top Pages + Top Sources --}} +
+ {{-- Top pages --}} +
-

Visitors & Page Views

-
- - Visitors - - - Page Views - - - Sessions - -
+

Top Pages

+ View all
- -
- -
-
- - {{-- Goal Completions --}} - @if ($goalConversions->isNotEmpty()) -
-
-

Goal Completions

- -
- -
- @foreach ($goalConversions->where('is_active', true)->take(6) as $goal) -
-
- {{ $goal->name }} - {{ $goal->conversion_rate }}% + @if ($topPages->isEmpty()) + + @else +
+ @php $maxViews = $topPages->max('views') ?: 1; @endphp + @foreach ($topPages as $page) +
+
+ {{ Str::limit($page->path, 50) }} + {{ number_format($page->views) }}
-
{{ number_format($goal->completions) }}
- @if ($goal->total_value) -
${{ number_format($goal->total_value, 2) }} value
- @endif -
-
+
+
@endforeach
- - {{-- Sparkline chart --}} - @if ($goalChartData->sum('completions') > 0) -
- -
- @endif -
- @endif - - {{-- Quick Report Links --}} -
-

Reports

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

Audience

- -
-
-

Acquisition

- -
-
-

Behavior

- -
-
-

Events & Conversions

- -
-
+ @endif
+ {{-- Top sources --}} +
+
+

Traffic Sources

+ View all +
+ @if ($topSources->isEmpty()) + + @else +
+
+
+ @php $maxSess = $topSources->max('sessions') ?: 1; @endphp + @foreach ($topSources as $src) +
+ {{ $src->source }} + {{ number_format($src->sessions) }} +
+ @endforeach +
+
+ @endif +
+ + {{-- Goal completions --}} + @if ($goalConversions->isNotEmpty()) +
+
+

Goal Completions

+ +
+
+ @foreach ($goalConversions->where('is_active', true)->take(6) as $goal) +
+
+ {{ $goal->name }} + {{ $goal->conversion_rate }}% +
+
{{ number_format($goal->completions) }}
+ @if ($goal->total_value) +
${{ number_format($goal->total_value, 2) }} value
+ @endif +
+
+
+
+ @endforeach +
+ @if ($goalChartData->sum('completions') > 0) +
+ @endif +
+ @endif
@push('scripts') @endpush - + diff --git a/resources/views/emails/alert-triggered.blade.php b/resources/views/emails/alert-triggered.blade.php new file mode 100644 index 0000000..702cd35 --- /dev/null +++ b/resources/views/emails/alert-triggered.blade.php @@ -0,0 +1,17 @@ + +# Alert Triggered: {{ $alert->name }} + +{{ $notification->message }} + +**Website:** {{ $alert->website->name }} +**Time:** {{ $notification->created_at->format('M j, Y g:i A') }} + + +View Dashboard + + +You can manage your alerts in the analytics dashboard settings. + +Thanks,
+{{ config('app.name') }} +
diff --git a/resources/views/funnels/_form.blade.php b/resources/views/funnels/_form.blade.php new file mode 100644 index 0000000..7471038 --- /dev/null +++ b/resources/views/funnels/_form.blade.php @@ -0,0 +1,90 @@ +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ + + + @error('steps') +

{{ $message }}

+ @enderror +
+ +
+ + Cancel +
+
diff --git a/resources/views/funnels/create.blade.php b/resources/views/funnels/create.blade.php new file mode 100644 index 0000000..360d409 --- /dev/null +++ b/resources/views/funnels/create.blade.php @@ -0,0 +1,10 @@ + +
+
+
+ @csrf + @include('funnels._form', ['funnel' => new \App\Models\Funnel]) +
+
+
+
diff --git a/resources/views/funnels/edit.blade.php b/resources/views/funnels/edit.blade.php new file mode 100644 index 0000000..12eb3fc --- /dev/null +++ b/resources/views/funnels/edit.blade.php @@ -0,0 +1,23 @@ + +
+
+
+ @csrf + @method('PUT') + @include('funnels._form', ['funnel' => $funnel]) +
+
+ +
+

Danger Zone

+

Permanently delete this funnel.

+
+ @csrf + @method('DELETE') + +
+
+
+
diff --git a/resources/views/funnels/index.blade.php b/resources/views/funnels/index.blade.php new file mode 100644 index 0000000..e092ee3 --- /dev/null +++ b/resources/views/funnels/index.blade.php @@ -0,0 +1,39 @@ + + + + + Create Funnel + + + + @if (session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if ($funnels->isEmpty()) + + @else + + @endif +
diff --git a/resources/views/funnels/show.blade.php b/resources/views/funnels/show.blade.php new file mode 100644 index 0000000..98234f0 --- /dev/null +++ b/resources/views/funnels/show.blade.php @@ -0,0 +1,113 @@ + + + + + Edit + + + + @if (empty($steps)) + + @else +
+ {{-- Overall conversion --}} + @php + $overallRate = $steps[0]['visitors'] > 0 + ? round(($steps[count($steps) - 1]['visitors'] / $steps[0]['visitors']) * 100, 1) + : 0; + @endphp +
+
+

Entered Funnel

+

{{ number_format($steps[0]['visitors']) }}

+
+
+

Completed

+

{{ number_format($steps[count($steps) - 1]['visitors']) }}

+
+
+

Overall Conversion

+

{{ $overallRate }}%

+
+
+ + {{-- Funnel visualization --}} +
+

Step-by-Step Breakdown

+ +
+ @foreach ($steps as $i => $step) + @php + $maxVisitors = $steps[0]['visitors'] ?: 1; + $widthPct = max(($step['visitors'] / $maxVisitors) * 100, 8); + @endphp +
+ {{-- Bar --}} +
+
+ {{ $i + 1 }} +
+
+
+ {{ $step['label'] }} + {{ number_format($step['visitors']) }} +
+
+
+ + {{ $step['conversion_pct'] }}% + +
+
+
+
+ + {{-- Drop-off indicator --}} + @if ($i > 0 && $step['drop_off'] > 0) +
+ + {{ number_format($step['drop_off']) }} dropped off ({{ $step['drop_off_pct'] }}%) +
+ @endif +
+ @endforeach +
+
+ + {{-- Step details table --}} +
+ + + + + + + + + + + + @foreach ($steps as $i => $step) + + + + + + + + @endforeach + +
StepTypeVisitorsDrop-offConversion
{{ $step['label'] }} + + {{ $step['type'] === 'pageview' ? 'Page' : 'Event' }} + + {{ number_format($step['visitors']) }} + {{ $i > 0 ? number_format($step['drop_off']) . ' (' . $step['drop_off_pct'] . '%)' : '—' }} + {{ $step['conversion_pct'] }}%
+
+
+ @endif +
diff --git a/resources/views/goals/_form.blade.php b/resources/views/goals/_form.blade.php index f17c59d..be585b9 100644 --- a/resources/views/goals/_form.blade.php +++ b/resources/views/goals/_form.blade.php @@ -23,7 +23,7 @@
{{-- URL Destination fields --}} -
+

URL Destination Conditions

@@ -43,7 +43,7 @@
{{-- Event Match fields --}} -
+

Event Match Conditions

@@ -58,7 +58,7 @@
{{-- Duration fields --}} -
+

Duration Condition

@@ -69,7 +69,7 @@
{{-- Pages per Session fields --}} -
+

Pages per Session Condition

diff --git a/resources/views/goals/create.blade.php b/resources/views/goals/create.blade.php index bead744..e5c436e 100644 --- a/resources/views/goals/create.blade.php +++ b/resources/views/goals/create.blade.php @@ -1,29 +1,17 @@ - - -
- - - - - -

Create Goal

- {{ $website->domain }} -
-
+ -
-
-
-
- @csrf - @include('goals._form', ['goal' => null]) +
+
+ + @csrf + @include('goals._form', ['goal' => null]) -
- Create Goal - Cancel -
- -
+
+ + Cancel +
+
- + + diff --git a/resources/views/goals/edit.blade.php b/resources/views/goals/edit.blade.php index b39d5ac..8856ffe 100644 --- a/resources/views/goals/edit.blade.php +++ b/resources/views/goals/edit.blade.php @@ -1,30 +1,18 @@ - - -
- - - - - -

Edit Goal: {{ $goal->name }}

- {{ $website->domain }} -
-
+ -
-
-
-
- @csrf - @method('PUT') - @include('goals._form', ['goal' => $goal]) +
+
+ + @csrf + @method('PUT') + @include('goals._form', ['goal' => $goal]) -
- Save Changes - Cancel -
- -
+
+ + Cancel +
+
- + + diff --git a/resources/views/goals/index.blade.php b/resources/views/goals/index.blade.php index beaf4f0..60236be 100644 --- a/resources/views/goals/index.blade.php +++ b/resources/views/goals/index.blade.php @@ -1,91 +1,74 @@ - - -
-
- - - - - -

Goals

- {{ $website->domain }} -
- - Add Goal - -
+ + + + + Add Goal + -
-
- @if (session('success')) -
-

{{ session('success') }}

-
- @endif +
+ @if (session('success')) +
+

{{ session('success') }}

+
+ @endif - @if ($goals->isEmpty()) -
-
- - - -

No goals

-

Create goals to track conversions on your website.

- + @if ($goals->isEmpty()) + - @else -
-
- - + + + @else +
+
+
+ + + + + + + + + + + @php + $typeLabels = [ + 'url_destination' => 'URL Destination', + 'event_match' => 'Event Match', + 'duration' => 'Duration', + 'pages_per_session' => 'Pages / Session', + ]; + @endphp + @foreach ($goals as $goal) - - - - - + + + + + - - - @php - $typeLabels = [ - 'url_destination' => 'URL Destination', - 'event_match' => 'Event Match', - 'duration' => 'Duration', - 'pages_per_session' => 'Pages / Session', - ]; - @endphp - @foreach ($goals as $goal) - - - - - - - - @endforeach - -
NameTypeValueStatus
NameTypeValueStatus{{ $goal->name }}{{ $typeLabels[$goal->type] ?? $goal->type }}{{ $goal->default_value ? number_format($goal->default_value, 2) : '—' }} + + {{ $goal->is_active ? 'Active' : 'Inactive' }} + + + Edit +
+ @csrf + @method('DELETE') + +
+
{{ $goal->name }}{{ $typeLabels[$goal->type] ?? $goal->type }}{{ $goal->default_value ? number_format($goal->default_value, 2) : '—' }} - - {{ $goal->is_active ? 'Active' : 'Inactive' }} - - - Edit -
- @csrf - @method('DELETE') - -
-
-
+ @endforeach + +
- @endif -
+
+ @endif
- + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index bf3dd4c..d18494a 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -5,29 +5,25 @@ - {{ config('app.name', 'Laravel') }} + {{ config('app.name', 'Analytics') }} - - + - @vite(['resources/css/app.css', 'resources/js/app.js']) -
+
@include('layouts.navigation') - @isset($header) -
-
+
+
{{ $header }}
@endisset -
{{ $slot }}
diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index d73eff1..d04c335 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -1,60 +1,51 @@ -