phase 8 and 9

This commit is contained in:
Stefanescu Mihai
2026-03-19 18:42:19 +02:00
parent d75cabfeec
commit 82ebb697f3
94 changed files with 4498 additions and 749 deletions
+25 -26
View File
@@ -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
---
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Console\Commands;
use App\Mail\AlertTriggered;
use App\Models\Alert;
use App\Services\AnalyticsQueryService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
class CheckAlerts extends Command
{
protected $signature = 'analytics:check-alerts';
protected $description = 'Evaluate active alerts and trigger notifications';
public function handle(): int
{
$alerts = Alert::with('website.user')
->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}");
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use App\Models\Alert;
use App\Models\Website;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AlertController extends Controller
{
public function index(Website $website): View
{
$this->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,
]);
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers;
use App\Models\Annotation;
use App\Models\Website;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AnnotationController extends Controller
{
public function index(Website $website): View
{
$this->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);
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\V1\EventResource;
use App\Http\Resources\V1\PageResource;
use App\Http\Resources\V1\ReferrerResource;
use App\Http\Resources\V1\StatsResource;
use App\Http\Resources\V1\VisitorBreakdownResource;
use App\Http\Resources\V1\WebsiteResource;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Carbon;
class WebsiteController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$websites = $request->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);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ApiTokenController extends Controller
{
public function index(Request $request): View
{
return view('api-tokens.index', [
'tokens' => $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.');
}
}
@@ -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];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Models\Segment;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
trait ParsesSegment
{
private function applySegment(Request $request, Website $website, AnalyticsQueryService $service): AnalyticsQueryService
{
$segmentId = $request->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);
}
}
@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\DashboardWidget;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CustomDashboardController extends Controller
{
use ParsesDateRange;
public function show(Request $request, Website $website): View
{
$this->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,
};
}
}
+9 -1
View File
@@ -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',
));
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ExportController extends Controller
{
use ParsesDateRange;
public function export(Request $request, Website $website, string $report): StreamedResponse
{
$this->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']],
]),
];
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\Funnel;
use App\Models\Website;
use App\Services\FunnelAnalysisService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FunnelController extends Controller
{
use ParsesDateRange;
public function index(Website $website): View
{
$this->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.');
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers;
use App\Models\Segment;
use App\Models\Website;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SegmentController extends Controller
{
public function index(Website $website): View
{
$this->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.');
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers;
use App\Models\Website;
use Illuminate\View\View;
class UtmBuilderController extends Controller
{
public function show(Website $website): View
{
$this->authorize('view', $website);
return view('utm-builder.show', [
'website' => $website,
]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'name' => $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,
];
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PageResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'path' => $this->path,
'views' => (int) $this->views,
'unique_views' => (int) $this->unique_views,
'avg_time_on_page' => round((float) ($this->avg_time ?? 0), 1),
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ReferrerResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'source' => $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),
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class StatsResource extends JsonResource
{
public function toArray(Request $request): array
{
$data = $this->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'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class VisitorBreakdownResource extends JsonResource
{
public function toArray(Request $request): array
{
$data = $this->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'],
];
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WebsiteResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'domain' => $this->domain,
'tracking_id' => $this->tracking_id,
'is_active' => $this->is_active,
'created_at' => $this->created_at->toIso8601String(),
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Mail;
use App\Models\Alert;
use App\Models\AlertNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class AlertTriggered extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Alert $alert,
public AlertNotification $notification,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "[Alert] {$this->alert->name} — {$this->alert->website->name}",
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.alert-triggered',
);
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Alert extends Model
{
use HasFactory;
protected $fillable = [
'website_id',
'name',
'metric',
'condition',
'threshold',
'period',
'channel',
'is_active',
'last_triggered_at',
];
protected function casts(): array
{
return [
'threshold' => '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',
];
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AlertNotification extends Model
{
protected $fillable = [
'alert_id',
'value',
'message',
'is_read',
];
protected function casts(): array
{
return [
'value' => 'decimal:2',
'is_read' => 'boolean',
];
}
public function alert(): BelongsTo
{
return $this->belongsTo(Alert::class);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Annotation extends Model
{
protected $fillable = [
'website_id',
'user_id',
'date',
'text',
'category',
];
protected function casts(): array
{
return [
'date' => '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',
];
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DashboardWidget extends Model
{
use HasFactory;
protected $fillable = [
'website_id',
'title',
'widget_type',
'config',
'sort_order',
'size',
];
protected function casts(): array
{
return [
'config' => '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',
];
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Funnel extends Model
{
use HasFactory;
protected $fillable = [
'website_id',
'name',
'steps',
'is_active',
];
protected function casts(): array
{
return [
'steps' => 'array',
'is_active' => 'boolean',
];
}
public function website(): BelongsTo
{
return $this->belongsTo(Website::class);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Segment extends Model
{
use HasFactory;
protected $fillable = [
'website_id',
'name',
'filters',
];
protected function casts(): array
{
return [
'filters' => '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');
}
}
+2 -1
View File
@@ -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<UserFactory> */
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
+25
View File
@@ -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);
+6
View File
@@ -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);
});
}
}
+44
View File
@@ -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();
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Services;
use App\Models\Event;
use App\Models\Funnel;
use App\Models\PageView;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class FunnelAnalysisService
{
public function __construct(
private readonly Funnel $funnel,
private readonly Carbon $from,
private readonly Carbon $to,
) {}
/**
* Analyze funnel: for each step, count how many unique visitors completed it
* (in order, within the same session or across sessions in the date range).
*
* Returns: [{label, type, value, visitors, drop_off, drop_off_pct, conversion_pct}]
*/
public function analyze(): array
{
$steps = $this->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');
}
}
+1
View File
@@ -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"
},
Generated
+64 -1
View File
@@ -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",
+84
View File
@@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => 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,
],
];
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('funnels', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('segments', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dashboard_widgets', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('alerts', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('annotations', function (Blueprint $table) {
$table->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');
}
};
+4
View File
@@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
[x-cloak] { display: none !important; }
.tabular-nums { font-variant-numeric: tabular-nums; }
+63
View File
@@ -0,0 +1,63 @@
<div class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Alert Name</label>
<input type="text" name="name" id="name" value="{{ old('name', $alert->name ?? '') }}" required
placeholder="e.g. Traffic spike alert"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="metric" class="block text-sm font-medium text-gray-700 mb-1">Metric</label>
<select name="metric" id="metric" class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@foreach (\App\Models\Alert::$metrics as $value => $label)
<option value="{{ $value }}" @selected(old('metric', $alert->metric ?? '') === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label for="condition" class="block text-sm font-medium text-gray-700 mb-1">Condition</label>
<select name="condition" id="condition" class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@foreach (\App\Models\Alert::$conditions as $value => $label)
<option value="{{ $value }}" @selected(old('condition', $alert->condition ?? '') === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<div>
<label for="threshold" class="block text-sm font-medium text-gray-700 mb-1">Threshold</label>
<input type="number" name="threshold" id="threshold" value="{{ old('threshold', $alert->threshold ?? '') }}" required step="0.01" min="0"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="e.g. 1000">
@error('threshold') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="period" class="block text-sm font-medium text-gray-700 mb-1">Check Period</label>
<select name="period" id="period" class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="daily" @selected(old('period', $alert->period ?? 'daily') === 'daily')>Daily</option>
<option value="hourly" @selected(old('period', $alert->period ?? '') === 'hourly')>Hourly</option>
</select>
</div>
<div>
<label for="channel" class="block text-sm font-medium text-gray-700 mb-1">Notification Channel</label>
<select name="channel" id="channel" class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="email" @selected(old('channel', $alert->channel ?? 'email') === 'email')>Email</option>
<option value="in_app" @selected(old('channel', $alert->channel ?? '') === 'in_app')>In-App Only</option>
<option value="both" @selected(old('channel', $alert->channel ?? '') === 'both')>Both</option>
</select>
</div>
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="inline-flex items-center px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
{{ isset($alert->id) ? 'Update Alert' : 'Create Alert' }}
</button>
<a href="{{ route('alerts.index', $website) }}" class="text-sm text-gray-500 hover:text-gray-700 transition">Cancel</a>
</div>
</div>
+10
View File
@@ -0,0 +1,10 @@
<x-analytics-layout :website="$website" title="Create Alert">
<div class="max-w-2xl">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<form method="POST" action="{{ route('alerts.store', $website) }}">
@csrf
@include('alerts._form', ['alert' => new \App\Models\Alert])
</form>
</div>
</div>
</x-analytics-layout>
+23
View File
@@ -0,0 +1,23 @@
<x-analytics-layout :website="$website" title="Edit Alert">
<div class="max-w-2xl">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<form method="POST" action="{{ route('alerts.update', [$website, $alert]) }}">
@csrf
@method('PUT')
@include('alerts._form', ['alert' => $alert])
</form>
</div>
<div class="mt-6 bg-white rounded-xl border border-red-200 p-6">
<h3 class="text-sm font-semibold text-red-700 mb-1">Danger Zone</h3>
<p class="text-sm text-gray-500 mb-4">Permanently delete this alert and all its notification history.</p>
<form method="POST" action="{{ route('alerts.destroy', [$website, $alert]) }}" onsubmit="return confirm('Delete this alert?')">
@csrf
@method('DELETE')
<button type="submit" class="inline-flex items-center px-4 py-2 bg-red-600 rounded-lg text-sm font-medium text-white hover:bg-red-500 transition">
Delete Alert
</button>
</form>
</div>
</div>
</x-analytics-layout>
+70
View File
@@ -0,0 +1,70 @@
<x-analytics-layout :website="$website" title="Alerts">
<x-slot name="headerActions">
<div class="flex items-center gap-2">
<a href="{{ route('alerts.notifications', $website) }}" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition relative">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" /></svg>
Notifications
@if ($unreadCount > 0)
<span class="absolute -top-1 -right-1 flex items-center justify-center w-5 h-5 rounded-full bg-red-500 text-white text-xs font-bold">{{ $unreadCount }}</span>
@endif
</a>
<a href="{{ route('alerts.create', $website) }}" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Create Alert
</a>
</div>
</x-slot>
@if (session('success'))
<div class="mb-6 rounded-xl bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm font-medium text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if ($alerts->isEmpty())
<x-empty-state
icon='<svg class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" /></svg>'
title="No alerts yet"
message="Create alerts to get notified when metrics spike, drop, or cross a threshold."
/>
@else
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<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">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Condition</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Channel</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Triggered</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach ($alerts as $alert)
<tr>
<td class="px-6 py-3 text-sm font-medium text-gray-900">{{ $alert->name }}</td>
<td class="px-6 py-3 text-sm text-gray-600">
{{ \App\Models\Alert::$metrics[$alert->metric] ?? $alert->metric }}
{{ \App\Models\Alert::$conditions[$alert->condition] ?? $alert->condition }}
{{ $alert->threshold }}
</td>
<td class="px-6 py-3 text-sm text-gray-600 capitalize">{{ $alert->channel }}</td>
<td class="px-6 py-3 text-sm text-gray-500">
{{ $alert->last_triggered_at ? $alert->last_triggered_at->diffForHumans() : 'Never' }}
</td>
<td class="px-6 py-3">
<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium {{ $alert->is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500' }}">
{{ $alert->is_active ? 'Active' : 'Paused' }}
</span>
</td>
<td class="px-6 py-3 text-right">
<a href="{{ route('alerts.edit', [$website, $alert]) }}" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">Edit</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</x-analytics-layout>
@@ -0,0 +1,32 @@
<x-analytics-layout :website="$website" title="Alert Notifications">
<x-slot name="headerActions">
<a href="{{ route('alerts.index', $website) }}" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
Back to Alerts
</a>
</x-slot>
@if ($notifications->isEmpty())
<x-empty-state title="No notifications" message="Alert notifications will appear here when triggered." />
@else
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="divide-y divide-gray-200">
@foreach ($notifications as $notification)
<div class="px-6 py-4">
<div class="flex items-start justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ $notification->alert->name }}</p>
<p class="text-sm text-gray-600 mt-0.5">{{ $notification->message }}</p>
</div>
<span class="text-xs text-gray-400 whitespace-nowrap ml-4">{{ $notification->created_at->diffForHumans() }}</span>
</div>
</div>
@endforeach
</div>
</div>
<div class="mt-4">
{{ $notifications->links() }}
</div>
@endif
</x-analytics-layout>
@@ -0,0 +1,89 @@
<x-analytics-layout :website="$website" title="Annotations">
@if (session('success'))
<div class="mb-6 rounded-xl bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm font-medium text-emerald-800">{{ session('success') }}</p>
</div>
@endif
<div class="grid gap-6 lg:grid-cols-3">
{{-- Add annotation form --}}
<div class="lg:col-span-1">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-900 mb-4">Add Annotation</h3>
<form method="POST" action="{{ route('annotations.store', $website) }}" class="space-y-4">
@csrf
<div>
<label for="date" class="block text-sm font-medium text-gray-700 mb-1">Date</label>
<input type="date" name="date" id="date" value="{{ old('date', now()->format('Y-m-d')) }}" required
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select name="category" id="category"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@foreach (\App\Models\Annotation::$categories as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label for="text" class="block text-sm font-medium text-gray-700 mb-1">Note</label>
<textarea name="text" id="text" rows="3" required maxlength="500"
placeholder="e.g. Launched new ad campaign"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">{{ old('text') }}</textarea>
</div>
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Add Annotation
</button>
</form>
</div>
</div>
{{-- Annotations list --}}
<div class="lg:col-span-2">
@if ($annotations->isEmpty())
<x-empty-state
icon='<svg class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /></svg>'
title="No annotations yet"
message="Add notes on specific dates to mark important events on your charts."
/>
@else
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="divide-y divide-gray-200">
@foreach ($annotations as $annotation)
<div class="flex items-start justify-between px-6 py-4">
<div class="flex items-start gap-3">
@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
<span class="inline-flex px-2 py-0.5 rounded text-xs font-medium {{ $categoryColors[$annotation->category] ?? $categoryColors['general'] }} mt-0.5">
{{ \App\Models\Annotation::$categories[$annotation->category] ?? $annotation->category }}
</span>
<div>
<p class="text-sm text-gray-900">{{ $annotation->text }}</p>
<p class="text-xs text-gray-400 mt-0.5">
{{ $annotation->date->format('M j, Y') }}
· {{ $annotation->user->name }}
</p>
</div>
</div>
<form method="POST" action="{{ route('annotations.destroy', [$website, $annotation]) }}" onsubmit="return confirm('Delete this annotation?')">
@csrf
@method('DELETE')
<button type="submit" class="text-sm text-red-600 hover:text-red-800 font-medium transition">Delete</button>
</form>
</div>
@endforeach
</div>
</div>
<div class="mt-4">{{ $annotations->links() }}</div>
@endif
</div>
</div>
</x-analytics-layout>
+161
View File
@@ -0,0 +1,161 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">API Tokens</h2>
</x-slot>
<div class="py-10">
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8 space-y-6">
@if (session('success'))
<div class="rounded-xl bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm font-medium text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if (session('newToken'))
<div class="rounded-xl bg-amber-50 border border-amber-200 p-5" x-data="{ copied: false }">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-amber-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-amber-800 mb-1">Copy your new API token now — it won't be shown again.</p>
<div class="flex items-center gap-2">
<code id="new-token" class="flex-1 block text-sm bg-white rounded-lg border border-amber-200 px-3 py-2 font-mono text-gray-900 break-all select-all">{{ session('newToken') }}</code>
<button
@click="navigator.clipboard.writeText(document.getElementById('new-token').textContent); copied = true; setTimeout(() => copied = false, 2000)"
class="shrink-0 inline-flex items-center gap-1 rounded-lg bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-500 transition"
>
<span x-show="!copied">Copy</span>
<span x-show="copied" x-cloak>Copied!</span>
</button>
</div>
</div>
</div>
</div>
@endif
{{-- Create token --}}
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-900 mb-1">Create API Token</h3>
<p class="text-sm text-gray-500 mb-4">Generate a token to access the analytics API programmatically.</p>
<form method="POST" action="{{ route('api-tokens.store') }}" class="flex items-end gap-3">
@csrf
<div class="flex-1">
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Token Name</label>
<input
type="text"
name="name"
id="name"
required
placeholder="e.g. My Dashboard Integration"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 transition shrink-0">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Create Token
</button>
</form>
</div>
{{-- Token list --}}
<div class="bg-white rounded-xl border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-base font-semibold text-gray-900">Your API Tokens</h3>
</div>
@if ($tokens->isEmpty())
<div class="py-12 text-center">
<svg class="mx-auto h-10 w-10 text-gray-300" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z" />
</svg>
<p class="mt-2 text-sm text-gray-400">No API tokens yet. Create one above to get started.</p>
</div>
@else
<div class="divide-y divide-gray-200">
@foreach ($tokens as $token)
<div class="flex items-center justify-between px-6 py-4">
<div>
<p class="text-sm font-medium text-gray-900">{{ $token->name }}</p>
<p class="text-xs text-gray-400 mt-0.5">
Created {{ $token->created_at->diffForHumans() }}
@if ($token->last_used_at)
· Last used {{ $token->last_used_at->diffForHumans() }}
@else
· Never used
@endif
</p>
</div>
<form method="POST" action="{{ route('api-tokens.destroy', $token->id) }}" onsubmit="return confirm('Revoke this token? Any integrations using it will stop working.')">
@csrf
@method('DELETE')
<button type="submit" class="text-sm text-red-600 hover:text-red-800 font-medium transition">Revoke</button>
</form>
</div>
@endforeach
</div>
@endif
</div>
{{-- API docs snippet --}}
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-900 mb-1">Quick Start</h3>
<p class="text-sm text-gray-500 mb-4">Use your token in the <code class="text-xs bg-gray-100 px-1.5 py-0.5 rounded">Authorization</code> header to access the API.</p>
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 text-sm overflow-x-auto"><code>curl -H "Authorization: Bearer YOUR_TOKEN" \
{{ url('/api/v1/websites') }}</code></pre>
<h4 class="text-sm font-semibold text-gray-900 mt-6 mb-3">Available Endpoints</h4>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 pr-4 font-medium text-gray-500">Method</th>
<th class="text-left py-2 pr-4 font-medium text-gray-500">Endpoint</th>
<th class="text-left py-2 font-medium text-gray-500">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr>
<td class="py-2 pr-4"><span class="inline-flex px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-xs font-mono font-medium">GET</span></td>
<td class="py-2 pr-4 font-mono text-xs text-gray-700">/api/v1/websites</td>
<td class="py-2 text-gray-500">List your websites</td>
</tr>
<tr>
<td class="py-2 pr-4"><span class="inline-flex px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-xs font-mono font-medium">GET</span></td>
<td class="py-2 pr-4 font-mono text-xs text-gray-700">/api/v1/websites/{id}/stats</td>
<td class="py-2 text-gray-500">Summary metrics + timeseries</td>
</tr>
<tr>
<td class="py-2 pr-4"><span class="inline-flex px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-xs font-mono font-medium">GET</span></td>
<td class="py-2 pr-4 font-mono text-xs text-gray-700">/api/v1/websites/{id}/pages</td>
<td class="py-2 text-gray-500">Top pages by views</td>
</tr>
<tr>
<td class="py-2 pr-4"><span class="inline-flex px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-xs font-mono font-medium">GET</span></td>
<td class="py-2 pr-4 font-mono text-xs text-gray-700">/api/v1/websites/{id}/referrers</td>
<td class="py-2 text-gray-500">Traffic sources</td>
</tr>
<tr>
<td class="py-2 pr-4"><span class="inline-flex px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-xs font-mono font-medium">GET</span></td>
<td class="py-2 pr-4 font-mono text-xs text-gray-700">/api/v1/websites/{id}/events</td>
<td class="py-2 text-gray-500">Events report</td>
</tr>
<tr>
<td class="py-2 pr-4"><span class="inline-flex px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-xs font-mono font-medium">GET</span></td>
<td class="py-2 pr-4 font-mono text-xs text-gray-700">/api/v1/websites/{id}/visitors</td>
<td class="py-2 text-gray-500">Audience breakdown</td>
</tr>
</tbody>
</table>
</div>
<p class="text-xs text-gray-400 mt-4">All endpoints accept optional <code class="bg-gray-100 px-1 py-0.5 rounded">from</code> and <code class="bg-gray-100 px-1 py-0.5 rounded">to</code> query params (YYYY-MM-DD). Default: last 7 days. Rate limit: 60 requests/minute per token.</p>
</div>
</div>
</div>
</x-app-layout>
@@ -0,0 +1,270 @@
@props(['website', 'from' => null, 'to' => null, 'title' => '', 'showDatePicker' => true])
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ? $title . ' — ' : '' }}{{ $website->name }} — {{ config('app.name', 'Analytics') }}</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased bg-gray-50 text-gray-900" x-data="{ sidebarOpen: false }">
<div class="flex min-h-screen">
{{-- Sidebar overlay (mobile) --}}
<div
x-show="sidebarOpen"
x-transition:enter="transition-opacity ease-linear duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-linear duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
@click="sidebarOpen = false"
></div>
{{-- Sidebar --}}
<aside
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
class="fixed inset-y-0 left-0 z-50 w-64 bg-gray-900 text-gray-300 transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:z-auto flex flex-col"
>
{{-- Logo + Website --}}
<div class="flex items-center gap-3 px-5 py-5 border-b border-gray-800">
<div class="flex-shrink-0 w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
</div>
<div class="min-w-0">
<a href="{{ route('websites.index') }}" class="text-sm font-semibold text-white truncate block hover:text-indigo-300 transition">{{ config('app.name', 'Analytics') }}</a>
</div>
</div>
{{-- Website selector --}}
<div class="px-3 pt-4 pb-2" x-data="{ wsOpen: false }">
<button @click="wsOpen = !wsOpen" class="w-full flex items-center justify-between gap-2 rounded-lg bg-gray-800 px-3 py-2.5 text-sm hover:bg-gray-750 transition">
<div class="flex items-center gap-2 min-w-0">
<span class="inline-flex items-center justify-center w-2 h-2 rounded-full {{ $website->is_active ? 'bg-green-400' : 'bg-gray-500' }}"></span>
<span class="truncate text-white font-medium">{{ $website->name }}</span>
</div>
<svg class="w-4 h-4 text-gray-400 shrink-0 transition-transform" :class="wsOpen && 'rotate-180'" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div x-show="wsOpen" x-transition @click.away="wsOpen = false" class="mt-1 rounded-lg bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700 max-h-64 overflow-y-auto">
@foreach (Auth::user()->websites ?? [] as $ws)
<a href="{{ route('dashboard.show', $ws) }}" class="flex items-center gap-2 px-3 py-2 text-sm {{ $ws->id === $website->id ? 'text-indigo-400 bg-gray-700/50' : 'text-gray-300 hover:bg-gray-700/50 hover:text-white' }} transition">
<span class="inline-flex items-center justify-center w-2 h-2 rounded-full {{ $ws->is_active ? 'bg-green-400' : 'bg-gray-500' }}"></span>
<span class="truncate">{{ $ws->name }}</span>
</a>
@endforeach
<div class="border-t border-gray-700 mt-1 pt-1">
<a href="{{ route('websites.create') }}" class="flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-white transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Add website
</a>
</div>
</div>
</div>
{{-- Navigation --}}
@php
$q = request()->only(['range', 'from', 'to']);
$active = fn(string ...$routes) => collect($routes)->contains(fn($r) => request()->routeIs($r));
$linkClass = fn(bool $isActive) => $isActive
? 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium bg-gray-800 text-white'
: 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-400 hover:bg-gray-800/60 hover:text-white transition';
@endphp
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-6">
{{-- Dashboard --}}
<div>
<a href="{{ route('dashboard.show', [$website] + $q) }}" class="{{ $linkClass($active('dashboard.show')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" />
</svg>
Dashboard
</a>
</div>
{{-- Audience --}}
<div>
<h4 class="px-3 mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">Audience</h4>
<a href="{{ route('reports.geographic', [$website] + $q) }}" class="{{ $linkClass($active('reports.geographic')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /></svg>
Geographic
</a>
<a href="{{ route('reports.technology', [$website] + $q) }}" class="{{ $linkClass($active('reports.technology')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25A2.25 2.25 0 0 1 5.25 3h13.5A2.25 2.25 0 0 1 21 5.25Z" /></svg>
Technology
</a>
<a href="{{ route('reports.language', [$website] + $q) }}" class="{{ $linkClass($active('reports.language')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m10.5 21 5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 0 1 6-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 0 1-3.827-5.802" /></svg>
Language
</a>
<a href="{{ route('reports.new-vs-returning', [$website] + $q) }}" class="{{ $linkClass($active('reports.new-vs-returning')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /></svg>
New vs Returning
</a>
</div>
{{-- Acquisition --}}
<div>
<h4 class="px-3 mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">Acquisition</h4>
<a href="{{ route('reports.sources', [$website] + $q) }}" class="{{ $linkClass($active('reports.sources')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" /></svg>
Traffic Sources
</a>
<a href="{{ route('reports.campaigns', [$website] + $q) }}" class="{{ $linkClass($active('reports.campaigns')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 0 1-1.44-4.282m3.102.069a18.03 18.03 0 0 1-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 0 1 8.835 2.535M10.34 6.66a23.847 23.847 0 0 0 8.835-2.535m0 0A23.74 23.74 0 0 0 18.795 3m.38 1.125a23.91 23.91 0 0 1 1.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 0 0 1.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 0 1 0 3.46" /></svg>
Campaigns
</a>
<a href="{{ route('reports.referrers', [$website] + $q) }}" class="{{ $linkClass($active('reports.referrers')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /></svg>
Referrers
</a>
</div>
{{-- Behavior --}}
<div>
<h4 class="px-3 mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">Behavior</h4>
<a href="{{ route('reports.pages', [$website] + $q) }}" class="{{ $linkClass($active('reports.pages')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>
Pages
</a>
<a href="{{ route('reports.landing-pages', [$website] + $q) }}" class="{{ $linkClass($active('reports.landing-pages')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>
Landing Pages
</a>
<a href="{{ route('reports.exit-pages', [$website] + $q) }}" class="{{ $linkClass($active('reports.exit-pages')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" /></svg>
Exit Pages
</a>
</div>
{{-- Events & Conversions --}}
<div>
<h4 class="px-3 mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">Events & Conversions</h4>
<a href="{{ route('reports.events', [$website] + $q) }}" class="{{ $linkClass($active('reports.events', 'reports.event-detail')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" /></svg>
Events
</a>
<a href="{{ route('reports.conversions', [$website] + $q) }}" class="{{ $linkClass($active('reports.conversions')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5" /></svg>
Conversions
</a>
<a href="{{ route('goals.index', $website) }}" class="{{ $linkClass($active('goals.*')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /></svg>
Goals
</a>
<a href="{{ route('funnels.index', $website) }}" class="{{ $linkClass($active('funnels.*')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" /></svg>
Funnels
</a>
</div>
{{-- Advanced --}}
<div>
<h4 class="px-3 mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">Advanced</h4>
<a href="{{ route('custom-dashboard.show', $website) }}" class="{{ $linkClass($active('custom-dashboard.*')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" /></svg>
Custom Dashboard
</a>
<a href="{{ route('segments.index', $website) }}" class="{{ $linkClass($active('segments.*')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>
Segments
</a>
<a href="{{ route('alerts.index', $website) }}" class="{{ $linkClass($active('alerts.*')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" /></svg>
Alerts
</a>
<a href="{{ route('annotations.index', $website) }}" class="{{ $linkClass($active('annotations.*')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /></svg>
Annotations
</a>
</div>
{{-- Tools --}}
<div>
<h4 class="px-3 mb-1 text-xs font-semibold uppercase tracking-wider text-gray-500">Tools</h4>
<a href="{{ route('utm-builder', $website) }}" class="{{ $linkClass($active('utm-builder')) }}">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /></svg>
UTM Builder
</a>
</div>
</nav>
{{-- Settings & User --}}
<div class="border-t border-gray-800 px-3 py-3 space-y-1">
<a href="{{ route('websites.snippet', $website) }}" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-400 hover:bg-gray-800/60 hover:text-white transition">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>
Tracking Code
</a>
<a href="{{ route('websites.edit', $website) }}" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-400 hover:bg-gray-800/60 hover:text-white transition">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
Site Settings
</a>
<a href="{{ route('api-tokens.index') }}" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-400 hover:bg-gray-800/60 hover:text-white transition">
<svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z" /></svg>
API Tokens
</a>
<div x-data="{ userOpen: false }" class="relative">
<button @click="userOpen = !userOpen" class="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-400 hover:bg-gray-800/60 hover:text-white transition">
<span class="flex items-center justify-center w-5 h-5 rounded-full bg-gray-700 text-xs font-medium text-gray-300 shrink-0">
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
</span>
<span class="truncate">{{ Auth::user()->name }}</span>
</button>
<div x-show="userOpen" x-transition @click.away="userOpen = false" class="absolute bottom-full left-0 mb-1 w-full rounded-lg bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
<a href="{{ route('profile.edit') }}" class="block px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition">Profile</a>
<a href="{{ route('websites.index') }}" class="block px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition">All Websites</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition">Log Out</button>
</form>
</div>
</div>
</div>
</aside>
{{-- Main content --}}
<div class="flex-1 flex flex-col min-w-0">
{{-- Top bar --}}
<header class="sticky top-0 z-30 flex items-center justify-between gap-4 border-b border-gray-200 bg-white px-4 sm:px-6 py-3">
<div class="flex items-center gap-3 min-w-0">
<button @click="sidebarOpen = true" class="lg:hidden text-gray-500 hover:text-gray-700 -ml-1">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<h1 class="text-lg font-semibold text-gray-900 truncate">{{ $title ?: 'Dashboard' }}</h1>
@isset($subtitle)
<span class="hidden sm:inline text-sm text-gray-400">{{ $subtitle }}</span>
@endisset
</div>
<div class="flex items-center gap-3 shrink-0">
@isset($headerActions)
{{ $headerActions }}
@endisset
@if ($showDatePicker && $from && $to)
<x-date-range-picker :website="$website" :from="$from" :to="$to" />
@endif
</div>
</header>
{{-- Page content --}}
<main class="flex-1 p-4 sm:p-6 overflow-x-hidden">
{{ $slot }}
</main>
</div>
</div>
@stack('scripts')
</body>
</html>
@@ -0,0 +1,19 @@
@props(['message' => 'No data available for this period.', 'compact' => false, 'icon' => 'chart'])
@php
$icons = [
'chart' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />',
'globe' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />',
'bolt' => '<path stroke-linecap="round" stroke-linejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />',
];
@endphp
<div class="{{ $compact ? 'py-8' : 'py-16' }} text-center">
@if (!$compact)
<svg class="mx-auto h-10 w-10 text-gray-300" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
{!! $icons[$icon] ?? $icons['chart'] !!}
</svg>
@endif
<p class="mt-2 text-sm text-gray-400">{{ $message }}</p>
{{ $slot ?? '' }}
</div>
@@ -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
<a href="{{ route('export', [$website] + $params) }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 text-xs font-medium text-gray-600 bg-white hover:bg-gray-50 transition"
title="Export as CSV">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>
CSV
</a>
@@ -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
<div class="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-sm transition-shadow">
<dt class="text-sm font-medium text-gray-500 truncate">{{ $label }}</dt>
<dd class="mt-2 flex items-baseline gap-2">
<span class="text-2xl font-bold text-gray-900 tracking-tight">{{ $formatted }}</span>
@if ($delta !== null)
<span class="inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-medium {{ $deltaClass }}">
{{ $deltaIcon }} {{ abs($delta) }}%
</span>
@endif
</dd>
</div>
@@ -1,77 +1,11 @@
@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>
<h4 class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Conversions</h4>
<a href="{{ route('reports.conversions', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.conversions') }}">Goals</a>
</div>
</div>
</nav>
{{-- Main Content --}}
<div class="flex-1 min-w-0">
<x-analytics-layout :website="$website" :from="$from" :to="$to" :title="$title">
{{ $slot }}
</div>
</div>
</div>
</div>
@isset($scripts)
@push('scripts')
{{ $scripts }}
@endpush
@endisset
</x-app-layout>
</x-analytics-layout>
@@ -0,0 +1,85 @@
<x-analytics-layout :website="$website" title="Manage Widgets">
<x-slot name="headerActions">
<a href="{{ route('custom-dashboard.show', $website) }}" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
View Dashboard
</a>
</x-slot>
@if (session('success'))
<div class="mb-6 rounded-xl bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm font-medium text-emerald-800">{{ session('success') }}</p>
</div>
@endif
<div class="grid gap-6 lg:grid-cols-2">
{{-- Add widget form --}}
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-900 mb-4">Add Widget</h3>
<form method="POST" action="{{ route('custom-dashboard.store', $website) }}" x-data="{ type: 'metric' }" class="space-y-4">
@csrf
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" name="title" id="title" required placeholder="e.g. Daily Visitors"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label for="widget_type" class="block text-sm font-medium text-gray-700 mb-1">Type</label>
<select name="widget_type" id="widget_type" x-model="type"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@foreach ($types as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div x-show="type === 'metric'" x-cloak>
<label for="config_metric" class="block text-sm font-medium text-gray-700 mb-1">Metric</label>
<select name="config[metric]" id="config_metric"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
@foreach ($metrics as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label for="size" class="block text-sm font-medium text-gray-700 mb-1">Size</label>
<select name="size" id="size"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="third">Small (1/3)</option>
<option value="half">Medium (1/2)</option>
<option value="full">Full Width</option>
</select>
</div>
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Add Widget
</button>
</form>
</div>
{{-- Current widgets --}}
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-900 mb-4">Current Widgets</h3>
@if ($widgets->isEmpty())
<p class="text-sm text-gray-400">No widgets yet. Add one using the form.</p>
@else
<div class="space-y-2">
@foreach ($widgets as $widget)
<div class="flex items-center justify-between rounded-lg border border-gray-200 px-4 py-3">
<div>
<p class="text-sm font-medium text-gray-900">{{ $widget->title }}</p>
<p class="text-xs text-gray-500">{{ \App\Models\DashboardWidget::$types[$widget->widget_type] ?? $widget->widget_type }} · {{ $widget->size }}</p>
</div>
<form method="POST" action="{{ route('custom-dashboard.destroy', [$website, $widget]) }}" onsubmit="return confirm('Remove this widget?')">
@csrf
@method('DELETE')
<button type="submit" class="text-sm text-red-600 hover:text-red-800 font-medium transition">Remove</button>
</form>
</div>
@endforeach
</div>
@endif
</div>
</div>
</x-analytics-layout>
@@ -0,0 +1,120 @@
<x-analytics-layout :website="$website" :from="$from" :to="$to" title="Custom Dashboard">
<x-slot name="headerActions">
<a href="{{ route('custom-dashboard.manage', $website) }}" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
Manage Widgets
</a>
</x-slot>
@if ($widgets->isEmpty())
<x-empty-state
icon='<svg class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" /></svg>'
title="No widgets yet"
message="Add widgets to build your custom dashboard."
>
<a href="{{ route('custom-dashboard.manage', $website) }}" class="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
Manage Widgets
</a>
</x-empty-state>
@else
<div class="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
@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
<div class="bg-white rounded-xl border border-gray-200 p-5 {{ $colSpan }}">
<h3 class="text-sm font-semibold text-gray-900 mb-3">{{ $widget->title }}</h3>
@if ($widget->widget_type === 'metric')
@php $metricLabel = \App\Models\DashboardWidget::$metrics[$widget->config['metric'] ?? 'page_views'] ?? 'Metric'; @endphp
<p class="text-3xl font-bold text-gray-900 tabular-nums">
@if (($widget->config['metric'] ?? '') === 'bounce_rate')
{{ $data }}%
@elseif (($widget->config['metric'] ?? '') === 'avg_duration')
{{ gmdate('i:s', (int) $data) }}
@else
{{ number_format($data) }}
@endif
</p>
<p class="text-xs text-gray-500 mt-1">{{ $metricLabel }}</p>
@elseif ($widget->widget_type === 'chart')
<div style="height: 200px">
<canvas id="widget-chart-{{ $widget->id }}"></canvas>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const ctx = document.getElementById('widget-chart-{{ $widget->id }}');
new Chart(ctx, {
type: 'line',
data: {
labels: @json($data->pluck('date')),
datasets: [{
label: 'Visitors',
data: @json($data->pluck('visitors')),
borderColor: '#6366f1',
backgroundColor: 'rgba(99,102,241,0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
}]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { beginAtZero: true } } }
});
});
</script>
@elseif (in_array($widget->widget_type, ['top_pages', 'top_sources', 'browsers', 'countries', 'events']))
@if ($data && $data->isNotEmpty())
<div class="space-y-2">
@foreach ($data->take(5) as $row)
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600 truncate mr-2">
{{ $row->path ?? $row->source ?? $row->browser ?? $row->country ?? $row->name ?? '—' }}
</span>
<span class="font-medium text-gray-900 tabular-nums shrink-0">
{{ number_format($row->views ?? $row->sessions ?? $row->visitors ?? $row->total ?? 0) }}
</span>
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-400">No data for this period.</p>
@endif
@elseif ($widget->widget_type === 'new_vs_returning')
<div class="flex items-center gap-6">
<div>
<p class="text-2xl font-bold text-indigo-600 tabular-nums">{{ $data['new_pct'] ?? 0 }}%</p>
<p class="text-xs text-gray-500">New</p>
</div>
<div>
<p class="text-2xl font-bold text-emerald-600 tabular-nums">{{ $data['returning_pct'] ?? 0 }}%</p>
<p class="text-xs text-gray-500">Returning</p>
</div>
</div>
@elseif ($widget->widget_type === 'conversions')
@if ($data && $data->isNotEmpty())
<div class="space-y-2">
@foreach ($data->take(5) as $goal)
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600 truncate mr-2">{{ $goal->name }}</span>
<span class="font-medium text-gray-900 tabular-nums shrink-0">{{ number_format($goal->conversions) }}</span>
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-400">No conversions for this period.</p>
@endif
@endif
</div>
@endforeach
</div>
@endif
</x-analytics-layout>
+126 -210
View File
@@ -1,108 +1,109 @@
<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('websites.index') }}" 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">
{{ $website->name }}
</h2>
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
</div>
<div class="flex items-center gap-4">
{{-- Real-time indicator --}}
<x-analytics-layout :website="$website" :from="$from" :to="$to" title="Dashboard">
<x-slot name="headerActions">
<div
x-data="realtimeWidget('{{ route('dashboard.realtime', $website) }}')"
x-data="realtimeWidget('{{ route('dashboard.realtime', $website) }}', {{ $activeVisitors }})"
x-init="start()"
class="flex items-center gap-2 rounded-md bg-green-50 px-3 py-1.5 text-sm"
class="hidden sm:flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1.5 text-sm ring-1 ring-emerald-200"
>
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
<span class="font-medium text-green-800">
<span x-text="count"></span> active now
</span>
</div>
<x-date-range-picker :website="$website" :from="$from" :to="$to" />
<div class="flex items-center gap-2">
<a href="{{ route('websites.snippet', $website) }}" class="text-sm text-gray-500 hover:text-gray-700">Snippet</a>
<a href="{{ route('websites.edit', $website) }}" class="text-sm text-gray-500 hover:text-gray-700">Settings</a>
</div>
</div>
<span class="font-medium text-emerald-800"><span x-text="count"></span> online</span>
</div>
</x-slot>
<div class="py-8">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
{{-- Metric Cards --}}
<div class="space-y-6">
{{-- Metric cards --}}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
<dt class="text-sm font-medium text-gray-500 truncate">Page Views</dt>
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ number_format($metrics['page_views']) }}</dd>
</div>
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
<dt class="text-sm font-medium text-gray-500 truncate">Unique Visitors</dt>
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ number_format($metrics['visitors']) }}</dd>
</div>
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
<dt class="text-sm font-medium text-gray-500 truncate">Sessions</dt>
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ number_format($metrics['sessions']) }}</dd>
</div>
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
<dt class="text-sm font-medium text-gray-500 truncate">Bounce Rate</dt>
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ $metrics['bounce_rate'] }}%</dd>
</div>
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
<dt class="text-sm font-medium text-gray-500 truncate">Avg. Duration</dt>
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ gmdate('i:s', (int) $metrics['avg_duration']) }}</dd>
</div>
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
<dt class="text-sm font-medium text-gray-500 truncate">Pages / Session</dt>
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ $metrics['avg_pages'] }}</dd>
</div>
<x-metric-card label="Page Views" :value="$metrics['page_views']" :previousValue="$prevMetrics['page_views']" />
<x-metric-card label="Unique Visitors" :value="$metrics['visitors']" :previousValue="$prevMetrics['visitors']" />
<x-metric-card label="Sessions" :value="$metrics['sessions']" :previousValue="$prevMetrics['sessions']" />
<x-metric-card label="Bounce Rate" :value="$metrics['bounce_rate']" :previousValue="$prevMetrics['bounce_rate']" format="percent" />
<x-metric-card label="Avg. Duration" :value="$metrics['avg_duration']" :previousValue="$prevMetrics['avg_duration']" format="duration" />
<x-metric-card label="Pages / Session" :value="$metrics['avg_pages']" :previousValue="$prevMetrics['avg_pages']" />
</div>
{{-- Visitors Chart --}}
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
{{-- Visitors chart --}}
<div class="bg-white rounded-xl border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">Visitors & Page Views</h3>
<div class="flex items-center gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1.5">
<span class="inline-block w-3 h-0.5 rounded bg-indigo-500"></span> Visitors
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-3 h-0.5 rounded bg-emerald-500"></span> Page Views
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-3 h-0.5 rounded bg-amber-400"></span> Sessions
</span>
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-0.5 rounded bg-indigo-500"></span>Visitors</span>
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-0.5 rounded bg-emerald-500"></span>Page Views</span>
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-0.5 rounded bg-amber-400"></span>Sessions</span>
</div>
</div>
@if (collect($chartData)->sum('page_views') > 0)
<div style="height: 300px;"><canvas id="visitors-chart"></canvas></div>
@else
<x-empty-state message="No traffic data for this period." compact />
@endif
</div>
{{-- Two-column: Top Pages + Top Sources --}}
<div class="grid gap-6 lg:grid-cols-2">
{{-- Top pages --}}
<div class="bg-white rounded-xl border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">Top Pages</h3>
<a href="{{ route('reports.pages', [$website] + request()->only(['range', 'from', 'to'])) }}" class="text-sm text-indigo-600 hover:text-indigo-800">View all</a>
</div>
@if ($topPages->isEmpty())
<x-empty-state message="No page data yet." compact />
@else
<div class="space-y-3">
@php $maxViews = $topPages->max('views') ?: 1; @endphp
@foreach ($topPages as $page)
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-700 truncate font-mono text-xs" title="{{ $page->path }}">{{ Str::limit($page->path, 50) }}</span>
<span class="text-gray-500 tabular-nums ml-2 shrink-0">{{ number_format($page->views) }}</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-1.5">
<div class="bg-indigo-500 h-1.5 rounded-full transition-all" style="width: {{ ($page->views / $maxViews) * 100 }}%"></div>
</div>
</div>
@endforeach
</div>
@endif
</div>
{{-- Top sources --}}
<div class="bg-white rounded-xl border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">Traffic Sources</h3>
<a href="{{ route('reports.sources', [$website] + request()->only(['range', 'from', 'to'])) }}" class="text-sm text-indigo-600 hover:text-indigo-800">View all</a>
</div>
@if ($topSources->isEmpty())
<x-empty-state message="No traffic source data yet." compact />
@else
<div class="flex gap-6">
<div class="w-40 h-40 shrink-0"><canvas id="sources-donut"></canvas></div>
<div class="flex-1 space-y-2 min-w-0">
@php $maxSess = $topSources->max('sessions') ?: 1; @endphp
@foreach ($topSources as $src)
<div class="flex justify-between text-sm">
<span class="text-gray-700 truncate">{{ $src->source }}</span>
<span class="text-gray-500 tabular-nums ml-2 shrink-0">{{ number_format($src->sessions) }}</span>
</div>
@endforeach
</div>
</div>
@endif
</div>
</div>
<div style="height: 320px;">
<canvas id="visitors-chart"></canvas>
</div>
</div>
{{-- Goal Completions --}}
{{-- Goal completions --}}
@if ($goalConversions->isNotEmpty())
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">Goal Completions</h3>
<div class="flex items-center gap-3">
<a href="{{ route('reports.conversions', [$website] + request()->only(['range', 'from', 'to'])) }}" class="text-sm text-indigo-600 hover:text-indigo-800">View report</a>
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-500 hover:text-gray-700">Manage goals</a>
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-500 hover:text-gray-700">Manage</a>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($goalConversions->where('is_active', true)->take(6) as $goal)
<div class="rounded-lg border border-gray-200 p-4">
@@ -120,105 +121,34 @@
</div>
@endforeach
</div>
{{-- Sparkline chart --}}
@if ($goalChartData->sum('completions') > 0)
<div class="mt-4" style="height: 120px;">
<canvas id="goal-sparkline"></canvas>
</div>
<div class="mt-4" style="height: 100px;"><canvas id="goal-sparkline"></canvas></div>
@endif
</div>
@endif
{{-- 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 & Conversions</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>
<a href="{{ route('reports.conversions', [$website] + $q) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Conversions</a>
<a href="{{ route('goals.index', $website) }}" class="block text-sm text-indigo-600 hover:text-indigo-800">Manage Goals</a>
</div>
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
const chartDefaults = {
font: { family: 'Inter, system-ui, sans-serif' },
color: '#9ca3af',
};
Chart.defaults.font = chartDefaults.font;
document.addEventListener('DOMContentLoaded', function () {
const raw = @json($chartData);
const labels = raw.map(d => d.period);
const visitors = raw.map(d => d.visitors);
const pageViews = raw.map(d => d.page_views);
const sessions = raw.map(d => d.sessions);
if (raw.some(d => d.page_views > 0)) {
new Chart(document.getElementById('visitors-chart'), {
type: 'line',
data: {
labels: labels,
labels: raw.map(d => d.period),
datasets: [
{
label: 'Visitors',
data: visitors,
borderColor: 'rgb(99, 102, 241)',
backgroundColor: 'rgba(99, 102, 241, 0.08)',
fill: true,
tension: 0.3,
pointRadius: labels.length > 60 ? 0 : 3,
borderWidth: 2,
},
{
label: 'Page Views',
data: pageViews,
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.05)',
fill: true,
tension: 0.3,
pointRadius: labels.length > 60 ? 0 : 3,
borderWidth: 2,
},
{
label: 'Sessions',
data: sessions,
borderColor: 'rgb(251, 191, 36)',
backgroundColor: 'rgba(251, 191, 36, 0.05)',
fill: true,
tension: 0.3,
pointRadius: labels.length > 60 ? 0 : 3,
borderWidth: 2,
},
{ label: 'Visitors', data: raw.map(d => d.visitors), borderColor: '#6366f1', backgroundColor: 'rgba(99,102,241,0.06)', fill: true, tension: 0.35, pointRadius: raw.length > 60 ? 0 : 3, borderWidth: 2 },
{ label: 'Page Views', data: raw.map(d => d.page_views), borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.04)', fill: true, tension: 0.35, pointRadius: raw.length > 60 ? 0 : 3, borderWidth: 2 },
{ label: 'Sessions', data: raw.map(d => d.sessions), borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.04)', fill: true, tension: 0.35, pointRadius: raw.length > 60 ? 0 : 3, borderWidth: 2 },
],
},
options: {
@@ -227,36 +157,35 @@
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(17, 24, 39, 0.9)',
padding: 12,
cornerRadius: 8,
titleFont: { size: 13 },
bodyFont: { size: 12 },
},
tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', padding: 12, cornerRadius: 8, titleFont: { size: 13 }, bodyFont: { size: 12 } },
},
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: {
font: { size: 11 },
color: '#9ca3af',
precision: 0,
},
},
x: { grid: { display: false }, ticks: { maxTicksLimit: 12, font: { size: 11 }, color: '#9ca3af' } },
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { font: { size: 11 }, color: '#9ca3af', precision: 0 } },
},
},
});
}
// Sources donut
const sourcesCanvas = document.getElementById('sources-donut');
if (sourcesCanvas) {
const srcData = @json($topSources);
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
new Chart(sourcesCanvas, {
type: 'doughnut',
data: {
labels: srcData.map(s => s.source),
datasets: [{ data: srcData.map(s => s.sessions), backgroundColor: colors.slice(0, srcData.length), borderWidth: 0, spacing: 2 }],
},
options: {
responsive: true,
maintainAspectRatio: true,
cutout: '65%',
plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', padding: 10, cornerRadius: 8 } },
},
});
}
// Goal sparkline
const goalCanvas = document.getElementById('goal-sparkline');
@@ -266,41 +195,28 @@
type: 'line',
data: {
labels: goalRaw.map(d => d.period),
datasets: [{
data: goalRaw.map(d => d.completions),
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
borderWidth: 2,
}],
datasets: [{ data: goalRaw.map(d => d.completions), borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.08)', fill: true, tension: 0.35, pointRadius: 0, borderWidth: 2 }],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { display: false },
y: { display: false, beginAtZero: true },
},
scales: { x: { display: false }, y: { display: false, beginAtZero: true } },
},
});
}
});
function realtimeWidget(url) {
function realtimeWidget(url, initial) {
return {
count: {{ $activeVisitors }},
count: initial,
start() {
setInterval(() => {
fetch(url)
.then(r => r.json())
.then(d => { this.count = d.active; })
.catch(() => {});
fetch(url).then(r => r.json()).then(d => { this.count = d.active; }).catch(() => {});
}, 30000);
},
};
}
</script>
@endpush
</x-app-layout>
</x-analytics-layout>
@@ -0,0 +1,17 @@
<x-mail::message>
# Alert Triggered: {{ $alert->name }}
{{ $notification->message }}
**Website:** {{ $alert->website->name }}
**Time:** {{ $notification->created_at->format('M j, Y g:i A') }}
<x-mail::button :url="route('dashboard.show', $alert->website)">
View Dashboard
</x-mail::button>
You can manage your alerts in the analytics dashboard settings.
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
+90
View File
@@ -0,0 +1,90 @@
<div
x-data="{
steps: @js($funnel->steps ?? [['type' => 'pageview', 'value' => '', 'label' => ''], ['type' => 'pageview', 'value' => '', 'label' => '']]),
addStep() {
this.steps.push({ type: 'pageview', value: '', label: '' });
},
removeStep(index) {
if (this.steps.length > 2) this.steps.splice(index, 1);
}
}"
class="space-y-6"
>
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Funnel Name</label>
<input type="text" name="name" id="name" value="{{ old('name', $funnel->name ?? '') }}" required
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="e.g. Checkout Flow">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Steps <span class="text-gray-400 font-normal">(min 2)</span></label>
<div class="space-y-3">
<template x-for="(step, index) in steps" :key="index">
<div class="flex items-start gap-3 bg-gray-50 rounded-lg p-3 border border-gray-200">
<div class="flex items-center justify-center w-7 h-7 rounded-full bg-indigo-100 text-indigo-700 text-xs font-bold shrink-0 mt-1" x-text="index + 1"></div>
<div class="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Type</label>
<select
:name="'steps[' + index + '][type]'"
x-model="step.type"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="pageview">Page View</option>
<option value="event">Event</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">
<span x-text="step.type === 'pageview' ? 'Path' : 'Event Name'"></span>
</label>
<input type="text"
:name="'steps[' + index + '][value]'"
x-model="step.value"
:placeholder="step.type === 'pageview' ? '/checkout' : 'purchase'"
required
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Label</label>
<input type="text"
:name="'steps[' + index + '][label]'"
x-model="step.label"
placeholder="e.g. Checkout Page"
required
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
</div>
<button type="button" @click="removeStep(index)" x-show="steps.length > 2"
class="mt-1 p-1 text-gray-400 hover:text-red-500 transition shrink-0">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
</template>
</div>
<button type="button" @click="addStep()"
class="mt-3 inline-flex items-center gap-1.5 text-sm text-indigo-600 hover:text-indigo-800 font-medium transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Add Step
</button>
@error('steps')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="inline-flex items-center px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
{{ isset($funnel->id) ? 'Update Funnel' : 'Create Funnel' }}
</button>
<a href="{{ route('funnels.index', $website) }}" class="text-sm text-gray-500 hover:text-gray-700 transition">Cancel</a>
</div>
</div>
+10
View File
@@ -0,0 +1,10 @@
<x-analytics-layout :website="$website" title="Create Funnel">
<div class="max-w-3xl">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<form method="POST" action="{{ route('funnels.store', $website) }}">
@csrf
@include('funnels._form', ['funnel' => new \App\Models\Funnel])
</form>
</div>
</div>
</x-analytics-layout>
+23
View File
@@ -0,0 +1,23 @@
<x-analytics-layout :website="$website" title="Edit Funnel">
<div class="max-w-3xl">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<form method="POST" action="{{ route('funnels.update', [$website, $funnel]) }}">
@csrf
@method('PUT')
@include('funnels._form', ['funnel' => $funnel])
</form>
</div>
<div class="mt-6 bg-white rounded-xl border border-red-200 p-6">
<h3 class="text-sm font-semibold text-red-700 mb-1">Danger Zone</h3>
<p class="text-sm text-gray-500 mb-4">Permanently delete this funnel.</p>
<form method="POST" action="{{ route('funnels.destroy', [$website, $funnel]) }}" onsubmit="return confirm('Delete this funnel?')">
@csrf
@method('DELETE')
<button type="submit" class="inline-flex items-center px-4 py-2 bg-red-600 rounded-lg text-sm font-medium text-white hover:bg-red-500 transition">
Delete Funnel
</button>
</form>
</div>
</div>
</x-analytics-layout>
+39
View File
@@ -0,0 +1,39 @@
<x-analytics-layout :website="$website" title="Funnels">
<x-slot name="headerActions">
<a href="{{ route('funnels.create', $website) }}" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Create Funnel
</a>
</x-slot>
@if (session('success'))
<div class="mb-6 rounded-xl bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm font-medium text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if ($funnels->isEmpty())
<x-empty-state
icon='<svg class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" /></svg>'
title="No funnels yet"
message="Create a funnel to visualize how visitors progress through a sequence of pages or events."
/>
@else
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($funnels as $funnel)
<a href="{{ route('funnels.show', [$website, $funnel]) }}" class="bg-white rounded-xl border border-gray-200 p-5 hover:border-indigo-300 hover:shadow-sm transition group">
<h3 class="text-sm font-semibold text-gray-900 group-hover:text-indigo-600 transition">{{ $funnel->name }}</h3>
<p class="mt-1 text-xs text-gray-500">{{ count($funnel->steps) }} steps</p>
<div class="mt-3 flex items-center gap-1">
@foreach ($funnel->steps as $i => $step)
<span class="text-xs text-gray-400 truncate max-w-[80px]">{{ $step['label'] }}</span>
@if (! $loop->last)
<svg class="w-3 h-3 text-gray-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>
@endif
@endforeach
</div>
</a>
@endforeach
</div>
@endif
</x-analytics-layout>
+113
View File
@@ -0,0 +1,113 @@
<x-analytics-layout :website="$website" :from="$from" :to="$to" :title="$funnel->name">
<x-slot name="headerActions">
<a href="{{ route('funnels.edit', [$website, $funnel]) }}" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" /></svg>
Edit
</a>
</x-slot>
@if (empty($steps))
<x-empty-state title="No data" message="No funnel data available for the selected period." />
@else
<div class="space-y-6">
{{-- Overall conversion --}}
@php
$overallRate = $steps[0]['visitors'] > 0
? round(($steps[count($steps) - 1]['visitors'] / $steps[0]['visitors']) * 100, 1)
: 0;
@endphp
<div class="grid gap-4 sm:grid-cols-3">
<div class="bg-white rounded-xl border border-gray-200 p-5">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Entered Funnel</p>
<p class="mt-1 text-2xl font-bold text-gray-900 tabular-nums">{{ number_format($steps[0]['visitors']) }}</p>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-5">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Completed</p>
<p class="mt-1 text-2xl font-bold text-gray-900 tabular-nums">{{ number_format($steps[count($steps) - 1]['visitors']) }}</p>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-5">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Overall Conversion</p>
<p class="mt-1 text-2xl font-bold text-gray-900 tabular-nums">{{ $overallRate }}%</p>
</div>
</div>
{{-- Funnel visualization --}}
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-900 mb-6">Step-by-Step Breakdown</h3>
<div class="space-y-0">
@foreach ($steps as $i => $step)
@php
$maxVisitors = $steps[0]['visitors'] ?: 1;
$widthPct = max(($step['visitors'] / $maxVisitors) * 100, 8);
@endphp
<div class="relative">
{{-- Bar --}}
<div class="flex items-center gap-4 py-3">
<div class="w-8 text-center">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full {{ $i === 0 ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-100 text-gray-600' }} text-xs font-bold">{{ $i + 1 }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-900 truncate">{{ $step['label'] }}</span>
<span class="text-sm font-semibold text-gray-900 tabular-nums ml-2">{{ number_format($step['visitors']) }}</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-8 overflow-hidden">
<div
class="h-8 rounded-full transition-all duration-500 {{ $i === 0 ? 'bg-indigo-500' : ($step['conversion_pct'] >= 50 ? 'bg-indigo-400' : 'bg-indigo-300') }}"
style="width: {{ $widthPct }}%"
>
<span class="flex items-center h-full px-3 text-xs font-semibold text-white whitespace-nowrap">
{{ $step['conversion_pct'] }}%
</span>
</div>
</div>
</div>
</div>
{{-- Drop-off indicator --}}
@if ($i > 0 && $step['drop_off'] > 0)
<div class="ml-12 -mt-2 mb-1 flex items-center gap-1.5 text-xs text-red-500">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 4.5-15 15m0 0h11.25m-11.25 0V8.25" /></svg>
{{ number_format($step['drop_off']) }} dropped off ({{ $step['drop_off_pct'] }}%)
</div>
@endif
</div>
@endforeach
</div>
</div>
{{-- Step details table --}}
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<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">Step</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</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">Drop-off</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Conversion</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach ($steps as $i => $step)
<tr>
<td class="px-6 py-3 text-sm text-gray-900 font-medium">{{ $step['label'] }}</td>
<td class="px-6 py-3 text-sm">
<span class="inline-flex px-2 py-0.5 rounded text-xs font-medium {{ $step['type'] === 'pageview' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700' }}">
{{ $step['type'] === 'pageview' ? 'Page' : 'Event' }}
</span>
</td>
<td class="px-6 py-3 text-sm text-gray-900 text-right tabular-nums">{{ number_format($step['visitors']) }}</td>
<td class="px-6 py-3 text-sm text-right tabular-nums {{ $step['drop_off'] > 0 ? 'text-red-600' : 'text-gray-400' }}">
{{ $i > 0 ? number_format($step['drop_off']) . ' (' . $step['drop_off_pct'] . '%)' : '—' }}
</td>
<td class="px-6 py-3 text-sm text-right tabular-nums font-medium text-gray-900">{{ $step['conversion_pct'] }}%</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</x-analytics-layout>
+4 -4
View File
@@ -23,7 +23,7 @@
</div>
{{-- URL Destination fields --}}
<div x-show="goalType === 'url_destination'" x-cloak class="rounded-lg border border-gray-200 p-4 space-y-4">
<div x-show="goalType === 'url_destination'" x-cloak class="rounded-xl border border-gray-200 p-4 space-y-4">
<h4 class="text-sm font-medium text-gray-700">URL Destination Conditions</h4>
<div>
<x-input-label for="conditions_path" value="Path" />
@@ -43,7 +43,7 @@
</div>
{{-- Event Match fields --}}
<div x-show="goalType === 'event_match'" x-cloak class="rounded-lg border border-gray-200 p-4 space-y-4">
<div x-show="goalType === 'event_match'" x-cloak class="rounded-xl border border-gray-200 p-4 space-y-4">
<h4 class="text-sm font-medium text-gray-700">Event Match Conditions</h4>
<div>
<x-input-label for="conditions_event_name" value="Event Name" />
@@ -58,7 +58,7 @@
</div>
{{-- Duration fields --}}
<div x-show="goalType === 'duration'" x-cloak class="rounded-lg border border-gray-200 p-4 space-y-4">
<div x-show="goalType === 'duration'" x-cloak class="rounded-xl border border-gray-200 p-4 space-y-4">
<h4 class="text-sm font-medium text-gray-700">Duration Condition</h4>
<div>
<x-input-label for="conditions_seconds" value="Minimum seconds on site" />
@@ -69,7 +69,7 @@
</div>
{{-- Pages per Session fields --}}
<div x-show="goalType === 'pages_per_session'" x-cloak class="rounded-lg border border-gray-200 p-4 space-y-4">
<div x-show="goalType === 'pages_per_session'" x-cloak class="rounded-xl border border-gray-200 p-4 space-y-4">
<h4 class="text-sm font-medium text-gray-700">Pages per Session Condition</h4>
<div>
<x-input-label for="conditions_page_count" value="Minimum pages viewed" />
+6 -18
View File
@@ -1,29 +1,17 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center gap-3">
<a href="{{ route('goals.index', $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">Create Goal</h2>
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
</div>
</x-slot>
<x-analytics-layout :website="$website" title="Create Goal" :showDatePicker="false">
<div class="py-12">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<div class="max-w-2xl">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<form method="POST" action="{{ route('goals.store', $website) }}">
@csrf
@include('goals._form', ['goal' => null])
<div class="mt-6 flex items-center gap-4">
<x-primary-button>Create Goal</x-primary-button>
<button type="submit" class="inline-flex items-center px-4 py-2 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 transition">Create Goal</button>
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</x-app-layout>
</x-analytics-layout>
+6 -18
View File
@@ -1,30 +1,18 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center gap-3">
<a href="{{ route('goals.index', $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">Edit Goal: {{ $goal->name }}</h2>
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
</div>
</x-slot>
<x-analytics-layout :website="$website" :title="'Edit Goal: ' . $goal->name" :showDatePicker="false">
<div class="py-12">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<div class="max-w-2xl">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<form method="POST" action="{{ route('goals.update', [$website, $goal]) }}">
@csrf
@method('PUT')
@include('goals._form', ['goal' => $goal])
<div class="mt-6 flex items-center gap-4">
<x-primary-button>Save Changes</x-primary-button>
<button type="submit" class="inline-flex items-center px-4 py-2 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 transition">Save Changes</button>
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</x-app-layout>
</x-analytics-layout>
+10 -27
View File
@@ -1,23 +1,12 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<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">Goals</h2>
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
</div>
<a href="{{ route('goals.create', $website) }}" class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 transition">
<x-analytics-layout :website="$website" title="Goals" :showDatePicker="false">
<x-slot name="headerActions">
<a href="{{ route('goals.create', $website) }}" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Add Goal
</a>
</div>
</x-slot>
<div class="py-12">
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
<div class="max-w-4xl mx-auto">
@if (session('success'))
<div class="mb-4 rounded-md bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">{{ session('success') }}</p>
@@ -25,22 +14,17 @@
@endif
@if ($goals->isEmpty())
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5" />
</svg>
<h3 class="mt-2 text-sm font-semibold text-gray-900">No goals</h3>
<p class="mt-1 text-sm text-gray-500">Create goals to track conversions on your website.</p>
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
<x-empty-state message="No goals. Create goals to track conversions on your website." icon="chart">
<div class="mt-6">
<a href="{{ route('goals.create', $website) }}" class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700">
Add Goal
</a>
</div>
</div>
</x-empty-state>
</div>
@else
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
@@ -87,5 +71,4 @@
</div>
@endif
</div>
</div>
</x-app-layout>
</x-analytics-layout>
+5 -9
View File
@@ -5,29 +5,25 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<title>{{ config('app.name', 'Analytics') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet" />
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
<div class="min-h-screen bg-gray-50">
@include('layouts.navigation')
<!-- Page Heading -->
@isset($header)
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<header class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto py-5 px-4 sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@endisset
<!-- Page Content -->
<main>
{{ $slot }}
</main>
+52 -68
View File
@@ -1,60 +1,51 @@
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
<!-- Primary Navigation Menu -->
<nav x-data="{ open: false }" class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<a href="{{ route('dashboard') }}">
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
</a>
</div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('websites.index')" :active="request()->routeIs('websites.*')">
{{ __('Websites') }}
</x-nav-link>
</div>
</div>
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ms-6">
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div>{{ Auth::user()->name }}</div>
<div class="ms-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
<div class="flex items-center gap-8">
<a href="{{ route('websites.index') }}" class="flex items-center gap-2.5">
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
</div>
<span class="font-semibold text-gray-900">{{ config('app.name', 'Analytics') }}</span>
</a>
<div class="hidden sm:flex items-center gap-1">
<a href="{{ route('websites.index') }}" class="px-3 py-2 rounded-lg text-sm font-medium {{ request()->routeIs('websites.*') ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50' }} transition">
Websites
</a>
<a href="{{ route('api-tokens.index') }}" class="px-3 py-2 rounded-lg text-sm font-medium {{ request()->routeIs('api-tokens.*') ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50' }} transition">
API
</a>
</div>
</div>
<div class="hidden sm:flex items-center">
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">
<span class="flex items-center justify-center w-7 h-7 rounded-full bg-indigo-100 text-xs font-semibold text-indigo-700">
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
</span>
<span>{{ Auth::user()->name }}</span>
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" fill="currentColor" />
</svg>
</button>
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-dropdown-link>
<!-- Authentication -->
<x-dropdown-link :href="route('profile.edit')">Profile</x-dropdown-link>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-dropdown-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
{{ __('Log Out') }}
</x-dropdown-link>
<x-dropdown-link :href="route('logout')" onclick="event.preventDefault(); this.closest('form').submit();">Log Out</x-dropdown-link>
</form>
</x-slot>
</x-dropdown>
</div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-lg text-gray-400 hover:text-gray-500 hover:bg-gray-100 transition">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -64,37 +55,30 @@
</div>
</div>
<!-- Responsive Navigation Menu -->
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<x-responsive-nav-link :href="route('websites.index')" :active="request()->routeIs('websites.*')">
{{ __('Websites') }}
</x-responsive-nav-link>
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden border-t border-gray-200">
<div class="pt-2 pb-3 space-y-1 px-4">
<a href="{{ route('websites.index') }}" class="block rounded-lg px-3 py-2 text-base font-medium {{ request()->routeIs('websites.*') ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600' }}">
Websites
</a>
<a href="{{ route('api-tokens.index') }}" class="block rounded-lg px-3 py-2 text-base font-medium {{ request()->routeIs('api-tokens.*') ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600' }}">
API Tokens
</a>
</div>
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="px-4">
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
<div class="pt-3 pb-3 border-t border-gray-200 px-4">
<div class="flex items-center gap-3 mb-3">
<span class="flex items-center justify-center w-8 h-8 rounded-full bg-indigo-100 text-sm font-semibold text-indigo-700">
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
</span>
<div>
<div class="text-sm font-medium text-gray-800">{{ Auth::user()->name }}</div>
<div class="text-xs text-gray-500">{{ Auth::user()->email }}</div>
</div>
<div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-responsive-nav-link>
<!-- Authentication -->
</div>
<a href="{{ route('profile.edit') }}" class="block rounded-lg px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">Profile</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
{{ __('Log Out') }}
</x-responsive-nav-link>
<button type="submit" class="block w-full text-left rounded-lg px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">Log Out</button>
</form>
</div>
</div>
</div>
</nav>
@@ -1,30 +1,30 @@
<x-report-layout :website="$website" :from="$from" :to="$to" title="Campaigns (UTM)">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="bg-white rounded-xl border border-gray-200">
@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>
<x-empty-state message="No campaign data available. Traffic with UTM parameters will appear here." />
@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>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Medium</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Campaign</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
<th class="px-4 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>
<td class="px-4 py-2.5 text-sm text-gray-900">{{ $row->utm_source }}</td>
<td class="px-4 py-2.5 text-sm text-gray-500">{{ $row->utm_medium ?? '—' }}</td>
<td class="px-4 py-2.5 text-sm text-gray-500">{{ $row->utm_campaign ?? '—' }}</td>
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ number_format($row->sessions) }}</td>
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
</tr>
@endforeach
</tbody>
@@ -1,8 +1,8 @@
<x-report-layout :website="$website" :from="$from" :to="$to" title="Referrers">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
@if ($referrers->isEmpty())
<div class="px-6 py-12 text-center text-sm text-gray-500">No referrer data available for this period.</div>
<x-empty-state message="No referrer data available for this period." />
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
@@ -1,34 +1,75 @@
<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>
<div class="bg-white rounded-xl border border-gray-200">
<x-empty-state message="No traffic source data available for this period." />
</div>
@else
@php
$chartSources = $sources->take(10);
@endphp
<div class="bg-white rounded-xl border border-gray-200 p-6 mb-6">
<h3 class="text-sm font-semibold text-gray-900 mb-4">Top Sources by Sessions</h3>
<div style="height: 280px;"><canvas id="sources-chart"></canvas></div>
</div>
<div class="bg-white rounded-xl border border-gray-200">
<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>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
<th class="px-4 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>
<td class="px-4 py-2.5 text-sm text-gray-900 font-medium">{{ $row->source }}</td>
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ number_format($row->sessions) }}</td>
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ round($row->avg_pages, 1) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</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 colors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#6366f1', '#10b981', '#f59e0b'];
const srcData = @json($chartSources->map(fn($s) => ['source' => $s->source, 'sessions' => $s->sessions])->values());
new Chart(document.getElementById('sources-chart'), {
type: 'bar',
data: {
labels: srcData.map(d => d.source),
datasets: [{ label: 'Sessions', data: srcData.map(d => d.sessions), backgroundColor: colors, borderRadius: 6 }]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', padding: 10, cornerRadius: 8 }
},
scales: {
x: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.04)' } },
y: { grid: { display: false } }
}
}
});
});
</script>
</x-slot>
@endif
</x-report-layout>
@@ -1,57 +1,180 @@
<x-report-layout :website="$website" :from="$from" :to="$to" title="Geographic Report">
<x-slot name="scripts">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-geo@4/build/index.umd.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async function() {
const countriesData = @json($countries->map(fn($c) => ['country' => $c->country, 'visitors' => $c->visitors])->values());
if (countriesData.length > 0) {
// Bar chart
const barCtx = document.getElementById('countriesChart');
if (barCtx) {
new Chart(barCtx, {
type: 'bar',
data: {
labels: countriesData.slice(0, 10).map(d => d.country),
datasets: [{
label: 'Visitors',
data: countriesData.slice(0, 10).map(d => d.visitors),
backgroundColor: '#6366f1',
borderRadius: 6
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', padding: 10, cornerRadius: 8 },
legend: { display: false }
},
scales: {
x: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.04)' } },
y: { grid: { display: false } }
}
}
});
}
// World map
const mapCtx = document.getElementById('worldMap');
if (mapCtx && window.ChartGeo) {
try {
const resp = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json');
const topoData = await resp.json();
const countries110 = ChartGeo.topojson.feature(topoData, topoData.objects.countries).features;
const countryMap = {};
countriesData.forEach(d => { countryMap[d.country] = d.visitors; });
const maxVisitors = Math.max(...countriesData.map(d => d.visitors));
new Chart(mapCtx, {
type: 'choropleth',
data: {
labels: countries110.map(f => f.properties.name),
datasets: [{
label: 'Visitors',
outline: countries110,
data: countries110.map(f => ({
feature: f,
value: countryMap[f.properties.name] || 0
})),
}]
},
options: {
showOutline: true,
showGraticule: false,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(17,24,39,0.95)',
padding: 10,
cornerRadius: 8,
callbacks: {
label: (ctx) => {
const val = ctx.raw?.value || 0;
return val > 0 ? `${ctx.label}: ${val.toLocaleString()} visitors` : `${ctx.label}: 0`;
}
}
}
},
scales: {
projection: {
axis: 'x',
projection: 'equalEarth',
},
color: {
axis: 'x',
quantize: 5,
legend: { display: false },
interpolate: (v) => {
const r = Math.round(99 + (255 - 99) * (1 - v));
const g = Math.round(102 + (255 - 102) * (1 - v));
const b = Math.round(241 + (255 - 241) * (1 - v));
return `rgb(${r},${g},${b})`;
},
}
}
}
});
} catch (e) {
document.getElementById('worldMapContainer')?.classList.add('hidden');
}
}
}
});
</script>
</x-slot>
<div class="space-y-6">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
{{-- World Map --}}
@if ($countries->isNotEmpty())
<div id="worldMapContainer" class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-900 mb-4">World Map</h3>
<div style="height: 380px;"><canvas id="worldMap"></canvas></div>
</div>
@endif
{{-- Countries --}}
<div class="bg-white rounded-xl border border-gray-200 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>
<x-empty-state icon="globe" message="No geographic data available for this period." />
@else
<div class="grid gap-6 lg:grid-cols-2">
<div style="height: 320px;"><canvas id="countriesChart"></canvas></div>
<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>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
<th class="px-4 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>
<td class="px-4 py-2.5 text-sm text-gray-900">{{ $row->country }}</td>
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ number_format($row->visitors) }}</td>
<td class="px-4 py-2.5 text-sm text-gray-500 text-right tabular-nums">{{ $totalCountry > 0 ? round(($row->visitors / $totalCountry) * 100, 1) : 0 }}%</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
{{-- Cities --}}
<div class="bg-white rounded-xl border border-gray-200 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>
<x-empty-state icon="globe" message="No city-level data available." />
@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>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">City</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Region</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
<th class="px-4 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>
<td class="px-4 py-2.5 text-sm text-gray-900">{{ $row->city }}</td>
<td class="px-4 py-2.5 text-sm text-gray-500">{{ $row->region }}</td>
<td class="px-4 py-2.5 text-sm text-gray-500">{{ $row->country }}</td>
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ number_format($row->visitors) }}</td>
</tr>
@endforeach
</tbody>
@@ -1,6 +1,6 @@
<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">
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden 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
@@ -18,7 +18,7 @@
@endforeach
</div>
@else
<p class="text-sm text-gray-500 py-8 text-center">No language data available for this period.</p>
<x-empty-state message="No language data available for this period." />
@endif
</div>
@@ -1,21 +1,63 @@
<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">
<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 newCount = {{ $data['new'] }};
const returningCount = {{ $data['returning'] }};
if (newCount > 0 || returningCount > 0) {
const ctx = document.getElementById('newVsReturningChart');
if (ctx) {
new Chart(ctx, {
type: 'pie',
data: {
labels: ['New Visitors', 'Returning Visitors'],
datasets: [{
data: [newCount, returningCount],
backgroundColor: ['#6366f1', '#10b981'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
backgroundColor: 'rgba(17,24,39,0.95)',
padding: 10,
cornerRadius: 8
}
}
}
});
}
}
});
</script>
</x-slot>
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden 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>
<x-empty-state message="No visitor data available for this period." />
@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="grid gap-6 lg:grid-cols-2">
<div class="h-64">
<canvas id="newVsReturningChart"></canvas>
</div>
<div class="grid gap-6 sm:grid-cols-2">
<div class="rounded-xl 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="rounded-xl 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>
<div class="mt-6 max-w-lg mx-auto">
<div class="w-full bg-gray-100 rounded-full h-4 overflow-hidden flex">
@@ -1,28 +1,68 @@
<x-report-layout :website="$website" :from="$from" :to="$to" title="Technology Report">
<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 devicesData = @json($devices->map(fn($d) => ['label' => $d->device_type ?? 'Unknown', 'visitors' => $d->visitors])->values());
if (devicesData.length > 0) {
const ctx = document.getElementById('deviceChart');
if (ctx) {
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
new Chart(ctx, {
type: 'doughnut',
data: {
labels: devicesData.map(d => d.label),
datasets: [{
data: devicesData.map(d => d.visitors),
backgroundColor: colors.slice(0, devicesData.length),
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
backgroundColor: 'rgba(17,24,39,0.95)',
padding: 10,
cornerRadius: 8
}
}
}
});
}
}
});
</script>
</x-slot>
<div class="space-y-6">
{{-- Device Type --}}
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden 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">
<div class="grid gap-6 lg:grid-cols-2">
<div class="h-64">
<canvas id="deviceChart"></canvas>
</div>
<div class="grid gap-4 sm:grid-cols-3">
@foreach ($devices as $row)
<div class="rounded-lg border border-gray-200 p-4 text-center">
<div class="rounded-xl 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>
</div>
@else
<p class="text-sm text-gray-500 py-4 text-center">No device data available.</p>
<x-empty-state message="No device data available." />
@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">
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
<h3 class="text-base font-semibold text-gray-900 mb-4">Browsers</h3>
@if ($browsers->isNotEmpty())
@php $totalBrowsers = $browsers->sum('visitors'); @endphp
@@ -40,12 +80,11 @@
@endforeach
</div>
@else
<p class="text-sm text-gray-500 py-4 text-center">No browser data available.</p>
<x-empty-state message="No browser data available." />
@endif
</div>
{{-- Operating Systems --}}
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden 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
@@ -63,13 +102,12 @@
@endforeach
</div>
@else
<p class="text-sm text-gray-500 py-4 text-center">No OS data available.</p>
<x-empty-state message="No OS data available." />
@endif
</div>
</div>
{{-- Screen Resolutions --}}
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
<h3 class="text-base font-semibold text-gray-900 mb-4">Screen Resolutions</h3>
@if ($resolutions->isNotEmpty())
<div class="overflow-x-auto">
@@ -91,7 +129,7 @@
</table>
</div>
@else
<p class="text-sm text-gray-500 py-4 text-center">No screen resolution data available.</p>
<x-empty-state message="No screen resolution data available." />
@endif
</div>
</div>
@@ -1,8 +1,8 @@
<x-report-layout :website="$website" :from="$from" :to="$to" title="Exit Pages">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
@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>
<x-empty-state message="No exit page data available for this period." />
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
@@ -1,8 +1,8 @@
<x-report-layout :website="$website" :from="$from" :to="$to" title="Landing Pages">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
@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>
<x-empty-state message="No landing page data available for this period." />
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
@@ -1,9 +1,26 @@
<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>
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
<x-empty-state message="No page view data available for this period." />
</div>
@else
@php
$chartPages = $pages->take(10);
$chartLabels = $chartPages->pluck('path')->map(fn ($p) => strlen($p) > 30 ? substr($p, 0, 27) . '...' : $p)->values()->toArray();
$chartData = $chartPages->pluck('views')->values()->toArray();
@endphp
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-900">Top 10 Pages by Views</h2>
</div>
<div class="p-6">
<div style="height: 280px;"><canvas id="pages-chart"></canvas></div>
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
@@ -26,7 +43,45 @@
</tbody>
</table>
</div>
@endif
</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() {
new Chart(document.getElementById('pages-chart'), {
type: 'bar',
data: {
labels: @json($chartLabels),
datasets: [{
label: 'Views',
data: @json($chartData),
backgroundColor: ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#6366f1', '#10b981', '#f59e0b'],
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(17, 24, 39, 0.9)',
titleColor: '#f9fafb',
bodyColor: '#e5e7eb',
borderColor: 'rgba(75, 85, 99, 0.5)',
borderWidth: 1
}
},
scales: {
x: { grid: { color: 'rgba(0,0,0,0.05)' } },
y: { grid: { display: false } }
}
}
});
});
</script>
</x-slot>
@endif
</x-report-layout>
@@ -8,22 +8,22 @@
$overallRate = $totalSessions > 0 ? round(($totalCompletions / $totalSessions) * 100, 2) : 0;
@endphp
<div class="grid gap-4 sm:grid-cols-3">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
<div class="bg-white rounded-xl border border-gray-200 p-5">
<dt class="text-sm font-medium text-gray-500">Total Completions</dt>
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ number_format($totalCompletions) }}</dd>
</div>
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
<div class="bg-white rounded-xl border border-gray-200 p-5">
<dt class="text-sm font-medium text-gray-500">Overall Conversion Rate</dt>
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ $overallRate }}%</dd>
</div>
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
<div class="bg-white rounded-xl border border-gray-200 p-5">
<dt class="text-sm font-medium text-gray-500">Total Goal Value</dt>
<dd class="mt-1 text-2xl font-bold text-gray-900">${{ number_format($totalValue, 2) }}</dd>
</div>
</div>
{{-- Completions Chart --}}
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-900 mb-4">Goal Completions Over Time</h3>
<div style="height: 240px;">
<canvas id="conversions-chart"></canvas>
@@ -31,12 +31,11 @@
</div>
{{-- Per-Goal Table --}}
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="bg-white rounded-xl border border-gray-200">
@if ($goals->isEmpty())
<div class="px-6 py-12 text-center text-sm text-gray-500">
No goals configured.
<a href="{{ route('goals.create', $website) }}" class="text-indigo-600 hover:text-indigo-800">Create one</a>.
</div>
<x-empty-state message="No goals configured.">
<a href="{{ route('goals.create', $website) }}" class="text-sm text-indigo-600 hover:text-indigo-800">Create one</a>
</x-empty-state>
@else
@php
$typeLabels = [
@@ -1,18 +1,16 @@
<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">
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden 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">
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
@if ($details->isEmpty())
<div class="px-6 py-12 text-center text-sm text-gray-500">No detail breakdown available.</div>
<x-empty-state message="No detail breakdown available." />
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
@@ -59,7 +57,14 @@
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(17,24,39,0.95)',
padding: 10,
cornerRadius: 8,
},
},
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' } },
@@ -1,8 +1,8 @@
<x-report-layout :website="$website" :from="$from" :to="$to" title="Events Overview">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
@if ($events->isEmpty())
<div class="px-6 py-12 text-center text-sm text-gray-500">No events tracked for this period.</div>
<x-empty-state message="No events tracked for this period." />
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
+87
View File
@@ -0,0 +1,87 @@
<div
x-data="{
filters: @js($segment->filters ?? [['field' => 'country', 'operator' => '=', 'value' => '']]),
fields: [
{ value: 'country', label: 'Country' },
{ value: 'region', label: 'Region' },
{ value: 'city', label: 'City' },
{ value: 'browser', label: 'Browser' },
{ value: 'os', label: 'Operating System' },
{ value: 'device_type', label: 'Device Type' },
{ value: 'language', label: 'Language' },
{ value: 'screen_resolution', label: 'Screen Resolution' },
],
operators: [
{ value: '=', label: 'equals' },
{ value: '!=', label: 'not equals' },
{ value: 'contains', label: 'contains' },
{ value: 'starts_with', label: 'starts with' },
],
addFilter() {
this.filters.push({ field: 'country', operator: '=', value: '' });
},
removeFilter(index) {
if (this.filters.length > 1) this.filters.splice(index, 1);
}
}"
class="space-y-6"
>
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Segment Name</label>
<input type="text" name="name" id="name" value="{{ old('name', $segment->name ?? '') }}" required
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="e.g. Mobile users from Germany">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Filters <span class="text-gray-400 font-normal">(all must match)</span></label>
<div class="space-y-3">
<template x-for="(filter, index) in filters" :key="index">
<div class="flex items-center gap-3 bg-gray-50 rounded-lg p-3 border border-gray-200">
<select :name="'filters[' + index + '][field]'" x-model="filter.field"
class="rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<template x-for="f in fields" :key="f.value">
<option :value="f.value" x-text="f.label"></option>
</template>
</select>
<select :name="'filters[' + index + '][operator]'" x-model="filter.operator"
class="rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<template x-for="o in operators" :key="o.value">
<option :value="o.value" x-text="o.label"></option>
</template>
</select>
<input type="text" :name="'filters[' + index + '][value]'" x-model="filter.value" required
placeholder="Value" class="flex-1 rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<button type="button" @click="removeFilter(index)" x-show="filters.length > 1"
class="p-1 text-gray-400 hover:text-red-500 transition shrink-0">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
</template>
</div>
<button type="button" @click="addFilter()"
class="mt-3 inline-flex items-center gap-1.5 text-sm text-indigo-600 hover:text-indigo-800 font-medium transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Add Filter
</button>
@error('filters')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="inline-flex items-center px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
{{ isset($segment->id) ? 'Update Segment' : 'Create Segment' }}
</button>
<a href="{{ route('segments.index', $website) }}" class="text-sm text-gray-500 hover:text-gray-700 transition">Cancel</a>
</div>
</div>
+10
View File
@@ -0,0 +1,10 @@
<x-analytics-layout :website="$website" title="Create Segment">
<div class="max-w-3xl">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<form method="POST" action="{{ route('segments.store', $website) }}">
@csrf
@include('segments._form', ['segment' => new \App\Models\Segment])
</form>
</div>
</div>
</x-analytics-layout>
+23
View File
@@ -0,0 +1,23 @@
<x-analytics-layout :website="$website" title="Edit Segment">
<div class="max-w-3xl">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<form method="POST" action="{{ route('segments.update', [$website, $segment]) }}">
@csrf
@method('PUT')
@include('segments._form', ['segment' => $segment])
</form>
</div>
<div class="mt-6 bg-white rounded-xl border border-red-200 p-6">
<h3 class="text-sm font-semibold text-red-700 mb-1">Danger Zone</h3>
<p class="text-sm text-gray-500 mb-4">Permanently delete this segment.</p>
<form method="POST" action="{{ route('segments.destroy', [$website, $segment]) }}" onsubmit="return confirm('Delete this segment?')">
@csrf
@method('DELETE')
<button type="submit" class="inline-flex items-center px-4 py-2 bg-red-600 rounded-lg text-sm font-medium text-white hover:bg-red-500 transition">
Delete Segment
</button>
</form>
</div>
</div>
</x-analytics-layout>
+47
View File
@@ -0,0 +1,47 @@
<x-analytics-layout :website="$website" title="Segments">
<x-slot name="headerActions">
<a href="{{ route('segments.create', $website) }}" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Create Segment
</a>
</x-slot>
@if (session('success'))
<div class="mb-6 rounded-xl bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm font-medium text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if ($segments->isEmpty())
<x-empty-state
icon='<svg class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>'
title="No segments yet"
message="Create a segment to filter reports by visitor attributes like country, device, or browser."
/>
@else
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($segments as $segment)
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-start justify-between">
<h3 class="text-sm font-semibold text-gray-900">{{ $segment->name }}</h3>
<a href="{{ route('segments.edit', [$website, $segment]) }}" class="text-gray-400 hover:text-gray-600 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" /></svg>
</a>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
@foreach ($segment->filters as $filter)
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-gray-100 text-xs text-gray-600">
{{ $filter['field'] }} {{ $filter['operator'] }} {{ $filter['value'] }}
</span>
@endforeach
</div>
<div class="mt-3">
<a href="{{ route('dashboard.show', [$website, 'segment' => $segment->id]) }}" class="text-xs font-medium text-indigo-600 hover:text-indigo-800 transition">
Apply to Dashboard &rarr;
</a>
</div>
</div>
@endforeach
</div>
@endif
</x-analytics-layout>
+102
View File
@@ -0,0 +1,102 @@
<x-analytics-layout :website="$website" title="UTM Builder">
<div class="max-w-3xl" x-data="utmBuilder('{{ $website->domain }}')">
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-5">
<div>
<h3 class="text-base font-semibold text-gray-900">Campaign URL Builder</h3>
<p class="text-sm text-gray-500 mt-1">Generate URLs with UTM parameters to track your marketing campaigns.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Website URL <span class="text-red-500">*</span></label>
<input type="url" x-model="url" required placeholder="https://{{ $website->domain }}/landing-page"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Campaign Source <span class="text-red-500">*</span></label>
<input type="text" x-model="source" required placeholder="e.g. google, newsletter, facebook"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="text-xs text-gray-400 mt-1">Where the traffic originates</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Campaign Medium <span class="text-red-500">*</span></label>
<input type="text" x-model="medium" required placeholder="e.g. cpc, email, social"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="text-xs text-gray-400 mt-1">Marketing medium type</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Campaign Name <span class="text-red-500">*</span></label>
<input type="text" x-model="campaign" required placeholder="e.g. spring_sale, product_launch"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Campaign Term <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="text" x-model="term" placeholder="e.g. running+shoes"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="text-xs text-gray-400 mt-1">Paid search keywords</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Campaign Content <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="text" x-model="content" placeholder="e.g. logolink, textlink"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="text-xs text-gray-400 mt-1">Differentiate ads or links</p>
</div>
</div>
</div>
{{-- Generated URL --}}
<div class="mt-6 bg-white rounded-xl border border-gray-200 p-6" x-show="generatedUrl" x-cloak>
<h3 class="text-sm font-semibold text-gray-900 mb-2">Generated URL</h3>
<div class="flex items-center gap-2">
<code class="flex-1 block text-sm bg-gray-50 rounded-lg border border-gray-200 px-3 py-2.5 font-mono text-gray-800 break-all select-all" x-text="generatedUrl" id="generated-url"></code>
<button
@click="copyUrl()"
class="shrink-0 inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-3 py-2.5 text-sm font-medium text-white hover:bg-indigo-500 transition"
>
<span x-show="!copied">Copy</span>
<span x-show="copied" x-cloak>Copied!</span>
</button>
</div>
</div>
</div>
<script>
function utmBuilder(domain) {
return {
url: '',
source: '',
medium: '',
campaign: '',
term: '',
content: '',
copied: false,
get generatedUrl() {
if (!this.url || !this.source || !this.medium || !this.campaign) return '';
try {
const u = new URL(this.url);
u.searchParams.set('utm_source', this.source);
u.searchParams.set('utm_medium', this.medium);
u.searchParams.set('utm_campaign', this.campaign);
if (this.term) u.searchParams.set('utm_term', this.term);
if (this.content) u.searchParams.set('utm_content', this.content);
return u.toString();
} catch {
return '';
}
},
copyUrl() {
navigator.clipboard.writeText(this.generatedUrl);
this.copied = true;
setTimeout(() => this.copied = false, 2000);
}
};
}
</script>
</x-analytics-layout>
+4 -2
View File
@@ -7,7 +7,7 @@
<div class="py-12">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="bg-white overflow-hidden rounded-xl border border-gray-200">
<div class="p-6">
<form method="POST" action="{{ route('websites.store') }}">
@csrf
@@ -26,7 +26,9 @@
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Add Website') }}</x-primary-button>
<button type="submit" class="bg-indigo-600 rounded-lg px-4 py-2 text-white hover:bg-indigo-500 transition">
{{ __('Add Website') }}
</button>
<a href="{{ route('websites.index') }}" class="text-sm text-gray-600 hover:text-gray-900">{{ __('Cancel') }}</a>
</div>
</form>
+5 -3
View File
@@ -7,7 +7,7 @@
<div class="py-12">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="bg-white overflow-hidden rounded-xl border border-gray-200">
<div class="p-6">
<form method="POST" action="{{ route('websites.update', $website) }}">
@csrf
@@ -34,14 +34,16 @@
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save Changes') }}</x-primary-button>
<button type="submit" class="bg-indigo-600 rounded-lg px-4 py-2 text-white hover:bg-indigo-500 transition">
{{ __('Save Changes') }}
</button>
<a href="{{ route('dashboard.show', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">{{ __('Cancel') }}</a>
</div>
</form>
</div>
</div>
<div class="mt-6 bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="mt-6 bg-red-50 overflow-hidden rounded-xl border border-red-200">
<div class="p-6">
<h3 class="text-lg font-medium text-red-600 mb-4">{{ __('Danger Zone') }}</h3>
<p class="text-sm text-gray-600 mb-4">{{ __('Deleting a website will permanently remove all its analytics data. This action cannot be undone.') }}</p>
+26 -35
View File
@@ -1,64 +1,55 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Websites') }}
</h2>
<a href="{{ route('websites.create') }}" class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">
{{ __('Add Website') }}
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Websites</h2>
<a href="{{ route('websites.create') }}" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Add Website
</a>
</div>
</x-slot>
<div class="py-12">
<div class="py-10">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@if (session('success'))
<div class="mb-4 rounded-md bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">{{ session('success') }}</p>
<div class="mb-6 rounded-xl bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm font-medium text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if ($websites->isEmpty())
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<div class="bg-white rounded-xl border border-gray-200 p-16 text-center">
<svg class="mx-auto h-14 w-14 text-gray-300" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<h3 class="mt-2 text-sm font-semibold text-gray-900">{{ __('No websites') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ __('Get started by adding your first website.') }}</p>
<h3 class="mt-4 text-lg font-semibold text-gray-900">No websites yet</h3>
<p class="mt-1 text-sm text-gray-500">Get started by adding your first website to track.</p>
<div class="mt-6">
<a href="{{ route('websites.create') }}" class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700">
{{ __('Add Website') }}
<a href="{{ route('websites.create') }}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 transition">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
Add Website
</a>
</div>
</div>
</div>
@else
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($websites as $website)
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 truncate">
<a href="{{ route('dashboard.show', $website) }}" class="hover:text-indigo-600">
{{ $website->name }}
</a>
</h3>
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {{ $website->is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' }}">
<a href="{{ route('dashboard.show', $website) }}" class="group bg-white rounded-xl border border-gray-200 p-6 hover:border-indigo-300 hover:shadow-md transition-all">
<div class="flex items-start justify-between mb-3">
<div class="min-w-0">
<h3 class="text-base font-semibold text-gray-900 truncate group-hover:text-indigo-600 transition-colors">{{ $website->name }}</h3>
<p class="text-sm text-gray-500 mt-0.5">{{ $website->domain }}</p>
</div>
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium {{ $website->is_active ? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200' : 'bg-gray-50 text-gray-500 ring-1 ring-gray-200' }}">
<span class="w-1.5 h-1.5 rounded-full {{ $website->is_active ? 'bg-emerald-500' : 'bg-gray-400' }}"></span>
{{ $website->is_active ? 'Active' : 'Inactive' }}
</span>
</div>
<p class="text-sm text-gray-500 mb-1">{{ $website->domain }}</p>
<p class="text-xs text-gray-400 font-mono">{{ $website->tracking_id }}</p>
<div class="mt-4 flex items-center gap-3 text-sm">
<a href="{{ route('dashboard.show', $website) }}" class="text-indigo-600 hover:text-indigo-800">Dashboard</a>
<a href="{{ route('websites.snippet', $website) }}" class="text-indigo-600 hover:text-indigo-800">Snippet</a>
<a href="{{ route('websites.edit', $website) }}" class="text-gray-600 hover:text-gray-800">Edit</a>
</div>
</div>
<p class="text-xs text-gray-400 font-mono mt-2">{{ $website->tracking_id }}</p>
<div class="mt-4 pt-3 border-t border-gray-100 flex items-center gap-4 text-sm" onclick="event.stopPropagation();">
<span class="text-indigo-600 group-hover:text-indigo-700 font-medium">Dashboard →</span>
</div>
</a>
@endforeach
</div>
@endif
+10
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\V1\WebsiteController as ApiWebsiteController;
use App\Http\Controllers\CollectController;
use App\Http\Controllers\TrackingScriptController;
use Illuminate\Support\Facades\Route;
@@ -17,3 +18,12 @@ Route::middleware('collect.cors')->group(function () {
->middleware('throttle:collect')
->name('collect.pixel');
});
Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:api'])->name('api.v1.')->group(function () {
Route::get('websites', [ApiWebsiteController::class, 'index'])->name('websites.index');
Route::get('websites/{website}/stats', [ApiWebsiteController::class, 'stats'])->name('websites.stats');
Route::get('websites/{website}/pages', [ApiWebsiteController::class, 'pages'])->name('websites.pages');
Route::get('websites/{website}/referrers', [ApiWebsiteController::class, 'referrers'])->name('websites.referrers');
Route::get('websites/{website}/events', [ApiWebsiteController::class, 'events'])->name('websites.events');
Route::get('websites/{website}/visitors', [ApiWebsiteController::class, 'visitors'])->name('websites.visitors');
});
+1
View File
@@ -10,3 +10,4 @@ Artisan::command('inspire', function () {
Schedule::command('analytics:aggregate')->dailyAt('02:00');
Schedule::command('analytics:purge --force')->dailyAt('03:00');
Schedule::command('analytics:check-alerts')->hourly();
+59
View File
@@ -51,12 +51,71 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::put('{goal}', [\App\Http\Controllers\GoalController::class, 'update'])->name('update');
Route::delete('{goal}', [\App\Http\Controllers\GoalController::class, 'destroy'])->name('destroy');
});
// Funnels
Route::prefix('websites/{website}/funnels')->name('funnels.')->group(function () {
Route::get('/', [\App\Http\Controllers\FunnelController::class, 'index'])->name('index');
Route::get('create', [\App\Http\Controllers\FunnelController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\FunnelController::class, 'store'])->name('store');
Route::get('{funnel}', [\App\Http\Controllers\FunnelController::class, 'show'])->name('show');
Route::get('{funnel}/edit', [\App\Http\Controllers\FunnelController::class, 'edit'])->name('edit');
Route::put('{funnel}', [\App\Http\Controllers\FunnelController::class, 'update'])->name('update');
Route::delete('{funnel}', [\App\Http\Controllers\FunnelController::class, 'destroy'])->name('destroy');
});
// Segments
Route::prefix('websites/{website}/segments')->name('segments.')->group(function () {
Route::get('/', [\App\Http\Controllers\SegmentController::class, 'index'])->name('index');
Route::get('create', [\App\Http\Controllers\SegmentController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\SegmentController::class, 'store'])->name('store');
Route::get('{segment}/edit', [\App\Http\Controllers\SegmentController::class, 'edit'])->name('edit');
Route::put('{segment}', [\App\Http\Controllers\SegmentController::class, 'update'])->name('update');
Route::delete('{segment}', [\App\Http\Controllers\SegmentController::class, 'destroy'])->name('destroy');
});
// Custom Dashboard
Route::prefix('websites/{website}/custom-dashboard')->name('custom-dashboard.')->group(function () {
Route::get('/', [\App\Http\Controllers\CustomDashboardController::class, 'show'])->name('show');
Route::get('manage', [\App\Http\Controllers\CustomDashboardController::class, 'manage'])->name('manage');
Route::post('widgets', [\App\Http\Controllers\CustomDashboardController::class, 'store'])->name('store');
Route::post('reorder', [\App\Http\Controllers\CustomDashboardController::class, 'reorder'])->name('reorder');
Route::delete('widgets/{widget}', [\App\Http\Controllers\CustomDashboardController::class, 'destroy'])->name('destroy');
});
// Alerts
Route::prefix('websites/{website}/alerts')->name('alerts.')->group(function () {
Route::get('/', [\App\Http\Controllers\AlertController::class, 'index'])->name('index');
Route::get('create', [\App\Http\Controllers\AlertController::class, 'create'])->name('create');
Route::post('/', [\App\Http\Controllers\AlertController::class, 'store'])->name('store');
Route::get('notifications', [\App\Http\Controllers\AlertController::class, 'notifications'])->name('notifications');
Route::get('{alert}/edit', [\App\Http\Controllers\AlertController::class, 'edit'])->name('edit');
Route::put('{alert}', [\App\Http\Controllers\AlertController::class, 'update'])->name('update');
Route::delete('{alert}', [\App\Http\Controllers\AlertController::class, 'destroy'])->name('destroy');
});
// Annotations
Route::prefix('websites/{website}/annotations')->name('annotations.')->group(function () {
Route::get('/', [\App\Http\Controllers\AnnotationController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\AnnotationController::class, 'store'])->name('store');
Route::delete('{annotation}', [\App\Http\Controllers\AnnotationController::class, 'destroy'])->name('destroy');
Route::get('range', [\App\Http\Controllers\AnnotationController::class, 'forRange'])->name('range');
});
// Data Export
Route::get('websites/{website}/export/{report}', [\App\Http\Controllers\ExportController::class, 'export'])->name('export');
// UTM Builder
Route::get('websites/{website}/utm-builder', [\App\Http\Controllers\UtmBuilderController::class, 'show'])->name('utm-builder');
});
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('/api-tokens', [\App\Http\Controllers\ApiTokenController::class, 'index'])->name('api-tokens.index');
Route::post('/api-tokens', [\App\Http\Controllers\ApiTokenController::class, 'store'])->name('api-tokens.store');
Route::delete('/api-tokens/{tokenId}', [\App\Http\Controllers\ApiTokenController::class, 'destroy'])->name('api-tokens.destroy');
});
require __DIR__.'/auth.php';
+1 -1
View File
@@ -12,7 +12,7 @@ export default {
theme: {
extend: {
fontFamily: {
sans: ['Figtree', ...defaultTheme.fontFamily.sans],
sans: ['Inter', 'Figtree', ...defaultTheme.fontFamily.sans],
},
},
},