phase 8 and 9
This commit is contained in:
@@ -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.
|
Choose a frontend approach and build a polished interface.
|
||||||
|
|
||||||
- [ ] Pick a stack:
|
- [x] Pick a stack:
|
||||||
- **Option A:** Blade + Livewire + Alpine.js (server-rendered, simpler)
|
- **Option A:** Blade + Alpine.js (server-rendered, simpler) — selected
|
||||||
- **Option B:** Inertia.js + Vue/React (SPA feel, more flexibility)
|
- [x] Install Tailwind CSS (already in the Laravel scaffold via Vite) + Inter font
|
||||||
- [ ] Install Tailwind CSS (already in the Laravel scaffold via Vite)
|
- [x] Build a **layout** with sidebar navigation (websites list, reports sections) and top bar (date picker, user menu)
|
||||||
- [ ] 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:
|
||||||
- [ ] Integrate a charting library (Chart.js, ApexCharts, or ECharts) for:
|
|
||||||
- Line charts (visitors over time)
|
- Line charts (visitors over time)
|
||||||
- Bar charts (top pages, browsers)
|
- Bar charts (top pages, browsers, sources)
|
||||||
- Pie/donut charts (device type, traffic source split)
|
- Pie/donut charts (device type, traffic source split, new vs returning)
|
||||||
- World map (geographic report)
|
- World map (geographic report — choropleth)
|
||||||
- [ ] Build **comparison mode** — compare current period vs. previous period with delta percentages
|
- [x] Build **comparison mode** — compare current period vs. previous period with delta percentages on metric cards
|
||||||
- [ ] Add loading states, empty states, and error handling throughout
|
- [x] Add loading states, empty states, and error handling throughout
|
||||||
- [ ] Make the dashboard responsive (mobile-friendly)
|
- [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.
|
Let users pull their analytics data via an API.
|
||||||
|
|
||||||
- [ ] Create API token management (Laravel Sanctum) — UI page to generate/revoke API tokens
|
- [x] Create API token management (Laravel Sanctum) — UI page to generate/revoke API tokens
|
||||||
- [ ] Build versioned API routes (`/api/v1/...`)
|
- [x] Build versioned API routes (`/api/v1/...`)
|
||||||
- [ ] Endpoints:
|
- [x] Endpoints:
|
||||||
- `GET /api/v1/websites` — list websites
|
- `GET /api/v1/websites` — list websites
|
||||||
- `GET /api/v1/websites/{id}/stats` — summary metrics with date-range params
|
- `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}/pages` — top pages
|
||||||
- `GET /api/v1/websites/{id}/referrers` — traffic sources
|
- `GET /api/v1/websites/{id}/referrers` — traffic sources
|
||||||
- `GET /api/v1/websites/{id}/events` — events report
|
- `GET /api/v1/websites/{id}/events` — events report
|
||||||
- `GET /api/v1/websites/{id}/visitors` — audience breakdown
|
- `GET /api/v1/websites/{id}/visitors` — audience breakdown
|
||||||
- [ ] Add API resource classes for consistent JSON formatting
|
- [x] Add API resource classes for consistent JSON formatting
|
||||||
- [ ] Rate limit API endpoints per token
|
- [x] Rate limit API endpoints per token
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 9: Advanced Features
|
## Phase 9: Advanced Features ✅
|
||||||
|
|
||||||
Features that differentiate from a basic analytics tool.
|
Features that differentiate from a basic analytics tool.
|
||||||
|
|
||||||
- [ ] **Funnels** — let users define a sequence of pages/events and visualize drop-off at each step
|
- [x] **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)
|
- [x] **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
|
- [x] **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)
|
- [x] **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
|
- [x] **Data export** — CSV/PDF export for any report
|
||||||
- [ ] **UTM builder** — in-app tool to generate campaign URLs with UTM parameters
|
- [x] **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] **Annotations** — let users add notes on specific dates (e.g., "launched ad campaign") visible on charts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,17 +18,25 @@ class DashboardController extends Controller
|
|||||||
$this->authorize('view', $website);
|
$this->authorize('view', $website);
|
||||||
|
|
||||||
[$from, $to] = $this->parseDateRange($request);
|
[$from, $to] = $this->parseDateRange($request);
|
||||||
|
[$prevFrom, $prevTo] = $this->previousPeriod($from, $to);
|
||||||
|
|
||||||
$service = AnalyticsQueryService::for($website, $from, $to);
|
$service = AnalyticsQueryService::for($website, $from, $to);
|
||||||
|
$prevService = AnalyticsQueryService::for($website, $prevFrom, $prevTo);
|
||||||
|
|
||||||
$metrics = $service->summary();
|
$metrics = $service->summary();
|
||||||
|
$prevMetrics = $prevService->summary();
|
||||||
$chartData = $service->visitorsOverTime();
|
$chartData = $service->visitorsOverTime();
|
||||||
$activeVisitors = AnalyticsQueryService::activeVisitors($website);
|
$activeVisitors = AnalyticsQueryService::activeVisitors($website);
|
||||||
$goalConversions = $service->conversionsReport();
|
$goalConversions = $service->conversionsReport();
|
||||||
$goalChartData = $service->goalCompletionsOverTime();
|
$goalChartData = $service->goalCompletionsOverTime();
|
||||||
|
|
||||||
|
$topPages = $service->topPages(5);
|
||||||
|
$topSources = $service->trafficSources(5);
|
||||||
|
|
||||||
return view('dashboard.show', compact(
|
return view('dashboard.show', compact(
|
||||||
'website', 'metrics', 'chartData', 'activeVisitors',
|
'website', 'metrics', 'prevMetrics', 'chartData', 'activeVisitors',
|
||||||
'goalConversions', 'goalChartData', 'from', 'to',
|
'goalConversions', 'goalChartData', 'from', 'to',
|
||||||
|
'topPages', 'topSources',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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']],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -8,11 +8,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
|
|||||||
@@ -73,6 +73,31 @@ class Website extends Model
|
|||||||
return $this->hasMany(GoalConversion::class);
|
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
|
public function dailyAggregates(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(DailyAggregate::class);
|
return $this->hasMany(DailyAggregate::class);
|
||||||
|
|||||||
@@ -19,5 +19,11 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
RateLimiter::for('collect', function (Request $request) {
|
RateLimiter::for('collect', function (Request $request) {
|
||||||
return Limit::perMinute(120)->by($request->ip());
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class AnalyticsQueryService
|
|||||||
{
|
{
|
||||||
private readonly Carbon $todayStart;
|
private readonly Carbon $todayStart;
|
||||||
|
|
||||||
|
private ?Collection $segmentVisitorIds = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Website $website,
|
private readonly Website $website,
|
||||||
private readonly Carbon $from,
|
private readonly Carbon $from,
|
||||||
@@ -29,6 +31,27 @@ class AnalyticsQueryService
|
|||||||
return new self($website, $from->startOfDay(), $to->endOfDay());
|
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:
|
* Split the requested date range into two parts:
|
||||||
* - "aggregate range": completed days that can be served from aggregate tables
|
* - "aggregate range": completed days that can be served from aggregate tables
|
||||||
@@ -76,6 +99,13 @@ class AnalyticsQueryService
|
|||||||
|
|
||||||
public function totalPageViews(): int
|
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;
|
$total = 0;
|
||||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||||
|
|
||||||
@@ -96,6 +126,13 @@ class AnalyticsQueryService
|
|||||||
|
|
||||||
public function uniqueVisitors(): int
|
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;
|
$total = 0;
|
||||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||||
|
|
||||||
@@ -117,6 +154,13 @@ class AnalyticsQueryService
|
|||||||
|
|
||||||
public function totalSessions(): int
|
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;
|
$total = 0;
|
||||||
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
[$aggFrom, $aggTo, $liveFrom, $liveTo] = $this->splitRange();
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"jaybizzle/crawler-detect": "^1.3",
|
"jaybizzle/crawler-detect": "^1.3",
|
||||||
"laravel/fortify": "^1.36",
|
"laravel/fortify": "^1.36",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"stevebauman/location": "^7.6"
|
"stevebauman/location": "^7.6"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+64
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "9d9708b2d4e7cc126272d691e95fa963",
|
"content-hash": "ea6b8a5091bcb1b3c932f36a03f8f62f",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@@ -1684,6 +1684,69 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-03-01T09:02:38+00:00"
|
"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",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.10",
|
"version": "v2.0.10",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
|
||||||
|
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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'])
|
@props(['website', 'from', 'to', 'title'])
|
||||||
|
|
||||||
<x-app-layout>
|
<x-analytics-layout :website="$website" :from="$from" :to="$to" :title="$title">
|
||||||
<x-slot name="header">
|
{{ $slot }}
|
||||||
<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">
|
|
||||||
{{ $slot }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@isset($scripts)
|
@isset($scripts)
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
{{ $scripts }}
|
{{ $scripts }}
|
||||||
@endpush
|
@endpush
|
||||||
@endisset
|
@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>
|
||||||
@@ -1,306 +1,222 @@
|
|||||||
<x-app-layout>
|
<x-analytics-layout :website="$website" :from="$from" :to="$to" title="Dashboard">
|
||||||
<x-slot name="header">
|
<x-slot name="headerActions">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
<div
|
||||||
<div class="flex items-center gap-3">
|
x-data="realtimeWidget('{{ route('dashboard.realtime', $website) }}', {{ $activeVisitors }})"
|
||||||
<a href="{{ route('websites.index') }}" class="text-gray-400 hover:text-gray-600">
|
x-init="start()"
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
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"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
>
|
||||||
</svg>
|
<span class="relative flex h-2 w-2">
|
||||||
</a>
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||||
{{ $website->name }}
|
</span>
|
||||||
</h2>
|
<span class="font-medium text-emerald-800"><span x-text="count"></span> online</span>
|
||||||
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
{{-- Real-time indicator --}}
|
|
||||||
<div
|
|
||||||
x-data="realtimeWidget('{{ route('dashboard.realtime', $website) }}')"
|
|
||||||
x-init="start()"
|
|
||||||
class="flex items-center gap-2 rounded-md bg-green-50 px-3 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<div class="py-8">
|
<div class="space-y-6">
|
||||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
{{-- Metric cards --}}
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
|
<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>
|
||||||
|
|
||||||
{{-- Metric Cards --}}
|
{{-- Visitors chart --}}
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Page Views</dt>
|
<h3 class="text-base font-semibold text-gray-900">Visitors & Page Views</h3>
|
||||||
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ number_format($metrics['page_views']) }}</dd>
|
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||||
</div>
|
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-0.5 rounded bg-indigo-500"></span>Visitors</span>
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
<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>
|
||||||
<dt class="text-sm font-medium text-gray-500 truncate">Unique Visitors</dt>
|
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-0.5 rounded bg-amber-400"></span>Sessions</span>
|
||||||
<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>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
{{-- Visitors Chart --}}
|
{{-- Two-column: Top Pages + Top Sources --}}
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
<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">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-base font-semibold text-gray-900">Visitors & Page Views</h3>
|
<h3 class="text-base font-semibold text-gray-900">Top Pages</h3>
|
||||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
<a href="{{ route('reports.pages', [$website] + request()->only(['range', 'from', 'to'])) }}" class="text-sm text-indigo-600 hover:text-indigo-800">View all</a>
|
||||||
<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>
|
</div>
|
||||||
|
@if ($topPages->isEmpty())
|
||||||
<div style="height: 320px;">
|
<x-empty-state message="No page data yet." compact />
|
||||||
<canvas id="visitors-chart"></canvas>
|
@else
|
||||||
</div>
|
<div class="space-y-3">
|
||||||
</div>
|
@php $maxViews = $topPages->max('views') ?: 1; @endphp
|
||||||
|
@foreach ($topPages as $page)
|
||||||
{{-- Goal Completions --}}
|
<div>
|
||||||
@if ($goalConversions->isNotEmpty())
|
<div class="flex justify-between text-sm mb-1">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
<span class="text-gray-700 truncate font-mono text-xs" title="{{ $page->path }}">{{ Str::limit($page->path, 50) }}</span>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<span class="text-gray-500 tabular-nums ml-2 shrink-0">{{ number_format($page->views) }}</span>
|
||||||
<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>
|
|
||||||
</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">
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<span class="text-sm font-medium text-gray-900 truncate">{{ $goal->name }}</span>
|
|
||||||
<span class="text-xs text-gray-500">{{ $goal->conversion_rate }}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($goal->completions) }}</div>
|
<div class="w-full bg-gray-100 rounded-full h-1.5">
|
||||||
@if ($goal->total_value)
|
<div class="bg-indigo-500 h-1.5 rounded-full transition-all" style="width: {{ ($page->views / $maxViews) * 100 }}%"></div>
|
||||||
<div class="text-xs text-gray-500 mt-0.5">${{ number_format($goal->total_value, 2) }} value</div>
|
|
||||||
@endif
|
|
||||||
<div class="mt-2 w-full bg-gray-100 rounded-full h-1.5">
|
|
||||||
<div class="bg-emerald-500 h-1.5 rounded-full" style="width: {{ min($goal->conversion_rate, 100) }}%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
{{-- Sparkline chart --}}
|
|
||||||
@if ($goalChartData->sum('completions') > 0)
|
|
||||||
<div class="mt-4" style="height: 120px;">
|
|
||||||
<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>
|
||||||
|
|
||||||
|
{{-- 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>
|
||||||
|
|
||||||
|
{{-- Goal completions --}}
|
||||||
|
@if ($goalConversions->isNotEmpty())
|
||||||
|
<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</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">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-sm font-medium text-gray-900 truncate">{{ $goal->name }}</span>
|
||||||
|
<span class="text-xs text-gray-500">{{ $goal->conversion_rate }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900">{{ number_format($goal->completions) }}</div>
|
||||||
|
@if ($goal->total_value)
|
||||||
|
<div class="text-xs text-gray-500 mt-0.5">${{ number_format($goal->total_value, 2) }} value</div>
|
||||||
|
@endif
|
||||||
|
<div class="mt-2 w-full bg-gray-100 rounded-full h-1.5">
|
||||||
|
<div class="bg-emerald-500 h-1.5 rounded-full" style="width: {{ min($goal->conversion_rate, 100) }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@if ($goalChartData->sum('completions') > 0)
|
||||||
|
<div class="mt-4" style="height: 100px;"><canvas id="goal-sparkline"></canvas></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const chartDefaults = {
|
||||||
|
font: { family: 'Inter, system-ui, sans-serif' },
|
||||||
|
color: '#9ca3af',
|
||||||
|
};
|
||||||
|
|
||||||
|
Chart.defaults.font = chartDefaults.font;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
const raw = @json($chartData);
|
const raw = @json($chartData);
|
||||||
const labels = raw.map(d => d.period);
|
if (raw.some(d => d.page_views > 0)) {
|
||||||
const visitors = raw.map(d => d.visitors);
|
new Chart(document.getElementById('visitors-chart'), {
|
||||||
const pageViews = raw.map(d => d.page_views);
|
type: 'line',
|
||||||
const sessions = raw.map(d => d.sessions);
|
data: {
|
||||||
|
labels: raw.map(d => d.period),
|
||||||
|
datasets: [
|
||||||
|
{ 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: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { intersect: false, mode: 'index' },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
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 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
new Chart(document.getElementById('visitors-chart'), {
|
// Sources donut
|
||||||
type: 'line',
|
const sourcesCanvas = document.getElementById('sources-donut');
|
||||||
data: {
|
if (sourcesCanvas) {
|
||||||
labels: labels,
|
const srcData = @json($topSources);
|
||||||
datasets: [
|
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||||
{
|
new Chart(sourcesCanvas, {
|
||||||
label: 'Visitors',
|
type: 'doughnut',
|
||||||
data: visitors,
|
data: {
|
||||||
borderColor: 'rgb(99, 102, 241)',
|
labels: srcData.map(s => s.source),
|
||||||
backgroundColor: 'rgba(99, 102, 241, 0.08)',
|
datasets: [{ data: srcData.map(s => s.sessions), backgroundColor: colors.slice(0, srcData.length), borderWidth: 0, spacing: 2 }],
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
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 },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
scales: {
|
options: {
|
||||||
x: {
|
responsive: true,
|
||||||
grid: { display: false },
|
maintainAspectRatio: true,
|
||||||
ticks: {
|
cutout: '65%',
|
||||||
maxTicksLimit: 12,
|
plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', padding: 10, cornerRadius: 8 } },
|
||||||
font: { size: 11 },
|
|
||||||
color: '#9ca3af',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: { color: 'rgba(0,0,0,0.04)' },
|
|
||||||
ticks: {
|
|
||||||
font: { size: 11 },
|
|
||||||
color: '#9ca3af',
|
|
||||||
precision: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// Goal sparkline
|
||||||
|
const goalCanvas = document.getElementById('goal-sparkline');
|
||||||
|
if (goalCanvas) {
|
||||||
|
const goalRaw = @json($goalChartData);
|
||||||
|
new Chart(goalCanvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: goalRaw.map(d => d.period),
|
||||||
|
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 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Goal sparkline
|
function realtimeWidget(url, initial) {
|
||||||
const goalCanvas = document.getElementById('goal-sparkline');
|
|
||||||
if (goalCanvas) {
|
|
||||||
const goalRaw = @json($goalChartData);
|
|
||||||
new Chart(goalCanvas, {
|
|
||||||
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,
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
||||||
scales: {
|
|
||||||
x: { display: false },
|
|
||||||
y: { display: false, beginAtZero: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function realtimeWidget(url) {
|
|
||||||
return {
|
return {
|
||||||
count: {{ $activeVisitors }},
|
count: initial,
|
||||||
start() {
|
start() {
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
fetch(url)
|
fetch(url).then(r => r.json()).then(d => { this.count = d.active; }).catch(() => {});
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => { this.count = d.active; })
|
|
||||||
.catch(() => {});
|
|
||||||
}, 30000);
|
}, 30000);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@endpush
|
@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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- URL Destination fields --}}
|
{{-- 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>
|
<h4 class="text-sm font-medium text-gray-700">URL Destination Conditions</h4>
|
||||||
<div>
|
<div>
|
||||||
<x-input-label for="conditions_path" value="Path" />
|
<x-input-label for="conditions_path" value="Path" />
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Event Match fields --}}
|
{{-- 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>
|
<h4 class="text-sm font-medium text-gray-700">Event Match Conditions</h4>
|
||||||
<div>
|
<div>
|
||||||
<x-input-label for="conditions_event_name" value="Event Name" />
|
<x-input-label for="conditions_event_name" value="Event Name" />
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Duration fields --}}
|
{{-- 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>
|
<h4 class="text-sm font-medium text-gray-700">Duration Condition</h4>
|
||||||
<div>
|
<div>
|
||||||
<x-input-label for="conditions_seconds" value="Minimum seconds on site" />
|
<x-input-label for="conditions_seconds" value="Minimum seconds on site" />
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Pages per Session fields --}}
|
{{-- 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>
|
<h4 class="text-sm font-medium text-gray-700">Pages per Session Condition</h4>
|
||||||
<div>
|
<div>
|
||||||
<x-input-label for="conditions_page_count" value="Minimum pages viewed" />
|
<x-input-label for="conditions_page_count" value="Minimum pages viewed" />
|
||||||
|
|||||||
@@ -1,29 +1,17 @@
|
|||||||
<x-app-layout>
|
<x-analytics-layout :website="$website" title="Create Goal" :showDatePicker="false">
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="max-w-2xl">
|
||||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
<form method="POST" action="{{ route('goals.store', $website) }}">
|
||||||
<form method="POST" action="{{ route('goals.store', $website) }}">
|
@csrf
|
||||||
@csrf
|
@include('goals._form', ['goal' => null])
|
||||||
@include('goals._form', ['goal' => null])
|
|
||||||
|
|
||||||
<div class="mt-6 flex items-center gap-4">
|
<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>
|
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
|
||||||
|
</x-analytics-layout>
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
<x-app-layout>
|
<x-analytics-layout :website="$website" :title="'Edit Goal: ' . $goal->name" :showDatePicker="false">
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="max-w-2xl">
|
||||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
<form method="POST" action="{{ route('goals.update', [$website, $goal]) }}">
|
||||||
<form method="POST" action="{{ route('goals.update', [$website, $goal]) }}">
|
@csrf
|
||||||
@csrf
|
@method('PUT')
|
||||||
@method('PUT')
|
@include('goals._form', ['goal' => $goal])
|
||||||
@include('goals._form', ['goal' => $goal])
|
|
||||||
|
|
||||||
<div class="mt-6 flex items-center gap-4">
|
<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>
|
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
|
||||||
|
</x-analytics-layout>
|
||||||
|
|||||||
@@ -1,91 +1,74 @@
|
|||||||
<x-app-layout>
|
<x-analytics-layout :website="$website" title="Goals" :showDatePicker="false">
|
||||||
<x-slot name="header">
|
<x-slot name="headerActions">
|
||||||
<div class="flex items-center justify-between">
|
<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">
|
||||||
<div class="flex items-center gap-3">
|
<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>
|
||||||
<a href="{{ route('dashboard.show', $website) }}" class="text-gray-400 hover:text-gray-600">
|
Add Goal
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
</a>
|
||||||
<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">
|
|
||||||
Add Goal
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="max-w-4xl mx-auto">
|
||||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
@if (session('success'))
|
||||||
@if (session('success'))
|
<div class="mb-4 rounded-md bg-green-50 p-4">
|
||||||
<div class="mb-4 rounded-md bg-green-50 p-4">
|
<p class="text-sm font-medium text-green-800">{{ session('success') }}</p>
|
||||||
<p class="text-sm font-medium text-green-800">{{ session('success') }}</p>
|
</div>
|
||||||
</div>
|
@endif
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($goals->isEmpty())
|
@if ($goals->isEmpty())
|
||||||
<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="p-12 text-center">
|
<x-empty-state message="No goals. Create goals to track conversions on your website." icon="chart">
|
||||||
<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="mt-6">
|
||||||
<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" />
|
<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">
|
||||||
</svg>
|
Add Goal
|
||||||
<h3 class="mt-2 text-sm font-semibold text-gray-900">No goals</h3>
|
</a>
|
||||||
<p class="mt-1 text-sm text-gray-500">Create goals to track conversions on your website.</p>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</x-empty-state>
|
||||||
@else
|
</div>
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<div class="overflow-x-auto">
|
||||||
<thead class="bg-gray-50">
|
<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">Type</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value</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 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
@php
|
||||||
|
$typeLabels = [
|
||||||
|
'url_destination' => 'URL Destination',
|
||||||
|
'event_match' => 'Event Match',
|
||||||
|
'duration' => 'Duration',
|
||||||
|
'pages_per_session' => 'Pages / Session',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
@foreach ($goals as $goal)
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
<td class="px-6 py-4 text-sm font-medium text-gray-900">{{ $goal->name }}</td>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
<td class="px-6 py-4 text-sm text-gray-500">{{ $typeLabels[$goal->type] ?? $goal->type }}</td>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value</th>
|
<td class="px-6 py-4 text-sm text-gray-500">{{ $goal->default_value ? number_format($goal->default_value, 2) : '—' }}</td>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
<td class="px-6 py-4">
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
|
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {{ $goal->is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' }}">
|
||||||
|
{{ $goal->is_active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right text-sm space-x-3">
|
||||||
|
<a href="{{ route('goals.edit', [$website, $goal]) }}" class="text-indigo-600 hover:text-indigo-800">Edit</a>
|
||||||
|
<form method="POST" action="{{ route('goals.destroy', [$website, $goal]) }}" class="inline" onsubmit="return confirm('Delete this goal?')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
@endforeach
|
||||||
<tbody class="divide-y divide-gray-200">
|
</tbody>
|
||||||
@php
|
</table>
|
||||||
$typeLabels = [
|
|
||||||
'url_destination' => 'URL Destination',
|
|
||||||
'event_match' => 'Event Match',
|
|
||||||
'duration' => 'Duration',
|
|
||||||
'pages_per_session' => 'Pages / Session',
|
|
||||||
];
|
|
||||||
@endphp
|
|
||||||
@foreach ($goals as $goal)
|
|
||||||
<tr>
|
|
||||||
<td class="px-6 py-4 text-sm font-medium text-gray-900">{{ $goal->name }}</td>
|
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">{{ $typeLabels[$goal->type] ?? $goal->type }}</td>
|
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">{{ $goal->default_value ? number_format($goal->default_value, 2) : '—' }}</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {{ $goal->is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' }}">
|
|
||||||
{{ $goal->is_active ? 'Active' : 'Inactive' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right text-sm space-x-3">
|
|
||||||
<a href="{{ route('goals.edit', [$website, $goal]) }}" class="text-indigo-600 hover:text-indigo-800">Edit</a>
|
|
||||||
<form method="POST" action="{{ route('goals.destroy', [$website, $goal]) }}" class="inline" onsubmit="return confirm('Delete this goal?')">
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<button type="submit" class="text-red-600 hover:text-red-800">Delete</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforeach
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
</div>
|
||||||
</div>
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
</x-analytics-layout>
|
||||||
|
|||||||
@@ -5,29 +5,25 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<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 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'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased">
|
||||||
<div class="min-h-screen bg-gray-100">
|
<div class="min-h-screen bg-gray-50">
|
||||||
@include('layouts.navigation')
|
@include('layouts.navigation')
|
||||||
|
|
||||||
<!-- Page Heading -->
|
|
||||||
@isset($header)
|
@isset($header)
|
||||||
<header class="bg-white shadow">
|
<header class="bg-white border-b border-gray-200">
|
||||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto py-5 px-4 sm:px-6 lg:px-8">
|
||||||
{{ $header }}
|
{{ $header }}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@endisset
|
@endisset
|
||||||
|
|
||||||
<!-- Page Content -->
|
|
||||||
<main>
|
<main>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,60 +1,51 @@
|
|||||||
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
|
<nav x-data="{ open: false }" class="bg-white border-b border-gray-200">
|
||||||
<!-- Primary Navigation Menu -->
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<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 justify-between h-16">
|
||||||
<div class="flex">
|
<div class="flex items-center gap-8">
|
||||||
<!-- Logo -->
|
<a href="{{ route('websites.index') }}" class="flex items-center gap-2.5">
|
||||||
<div class="shrink-0 flex items-center">
|
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
||||||
<a href="{{ route('dashboard') }}">
|
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
|
<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" />
|
||||||
</a>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="font-semibold text-gray-900">{{ config('app.name', 'Analytics') }}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Navigation Links -->
|
<div class="hidden sm:flex items-center gap-1">
|
||||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
<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">
|
||||||
<x-nav-link :href="route('websites.index')" :active="request()->routeIs('websites.*')">
|
Websites
|
||||||
{{ __('Websites') }}
|
</a>
|
||||||
</x-nav-link>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Dropdown -->
|
<div class="hidden sm:flex items-center">
|
||||||
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
|
||||||
<x-dropdown align="right" width="48">
|
<x-dropdown align="right" width="48">
|
||||||
<x-slot name="trigger">
|
<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">
|
<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">
|
||||||
<div>{{ Auth::user()->name }}</div>
|
<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)) }}
|
||||||
<div class="ms-1">
|
</span>
|
||||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
<span>{{ Auth::user()->name }}</span>
|
||||||
<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" />
|
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 20 20">
|
||||||
</svg>
|
<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" />
|
||||||
</div>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="content">
|
<x-slot name="content">
|
||||||
<x-dropdown-link :href="route('profile.edit')">
|
<x-dropdown-link :href="route('profile.edit')">Profile</x-dropdown-link>
|
||||||
{{ __('Profile') }}
|
|
||||||
</x-dropdown-link>
|
|
||||||
|
|
||||||
<!-- Authentication -->
|
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@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>
|
</form>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
</x-dropdown>
|
</x-dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hamburger -->
|
|
||||||
<div class="-me-2 flex items-center sm:hidden">
|
<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">
|
<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="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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Responsive Navigation Menu -->
|
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden border-t border-gray-200">
|
||||||
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
|
<div class="pt-2 pb-3 space-y-1 px-4">
|
||||||
<div class="pt-2 pb-3 space-y-1">
|
<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' }}">
|
||||||
<x-responsive-nav-link :href="route('websites.index')" :active="request()->routeIs('websites.*')">
|
Websites
|
||||||
{{ __('Websites') }}
|
</a>
|
||||||
</x-responsive-nav-link>
|
<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>
|
</div>
|
||||||
|
<div class="pt-3 pb-3 border-t border-gray-200 px-4">
|
||||||
<!-- Responsive Settings Options -->
|
<div class="flex items-center gap-3 mb-3">
|
||||||
<div class="pt-4 pb-1 border-t border-gray-200">
|
<span class="flex items-center justify-center w-8 h-8 rounded-full bg-indigo-100 text-sm font-semibold text-indigo-700">
|
||||||
<div class="px-4">
|
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
|
||||||
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
|
</span>
|
||||||
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
|
<div>
|
||||||
</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 class="mt-3 space-y-1">
|
</div>
|
||||||
<x-responsive-nav-link :href="route('profile.edit')">
|
|
||||||
{{ __('Profile') }}
|
|
||||||
</x-responsive-nav-link>
|
|
||||||
|
|
||||||
<!-- Authentication -->
|
|
||||||
<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>
|
|
||||||
</form>
|
|
||||||
</div>
|
</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
|
||||||
|
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Campaigns (UTM)">
|
<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())
|
@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
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 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">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-4 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-4 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-4 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-4 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-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200">
|
||||||
@foreach ($campaigns as $row)
|
@foreach ($campaigns as $row)
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->utm_source }}</td>
|
<td class="px-4 py-2.5 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-4 py-2.5 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-4 py-2.5 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-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ 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-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-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 text-right tabular-nums">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Referrers">
|
<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())
|
@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
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<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">
|
<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())
|
||||||
@if ($sources->isEmpty())
|
<div class="bg-white rounded-xl border border-gray-200">
|
||||||
<div class="px-6 py-12 text-center text-sm text-gray-500">No traffic source data available for this period.</div>
|
<x-empty-state message="No traffic source data available for this period." />
|
||||||
@else
|
</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">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 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">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-4 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-4 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-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-right text-xs font-medium text-gray-500 uppercase tracking-wider">Pages / Session</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200">
|
||||||
@foreach ($sources as $row)
|
@foreach ($sources as $row)
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-3 text-sm text-gray-900 font-medium">{{ $row->source }}</td>
|
<td class="px-4 py-2.5 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-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ 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-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-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 text-right tabular-nums">{{ 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 text-right tabular-nums">{{ round($row->avg_pages, 1) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
</div>
|
||||||
</div>
|
|
||||||
|
<x-slot name="scripts">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const 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>
|
</x-report-layout>
|
||||||
|
|||||||
@@ -1,57 +1,180 @@
|
|||||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Geographic Report">
|
<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="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>
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Visitors by Country</h3>
|
||||||
@if ($countries->isEmpty())
|
@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
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<div style="height: 320px;"><canvas id="countriesChart"></canvas></div>
|
||||||
<thead class="bg-gray-50">
|
<div class="overflow-x-auto">
|
||||||
<tr>
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
|
<thead class="bg-gray-50">
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">%</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200">
|
|
||||||
@php $totalCountry = $countries->sum('visitors'); @endphp
|
|
||||||
@foreach ($countries as $row)
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->country }}</td>
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
|
||||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->visitors) }}</td>
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||||
<td class="px-6 py-3 text-sm text-gray-500 text-right">{{ $totalCountry > 0 ? round(($row->visitors / $totalCountry) * 100, 1) : 0 }}%</td>
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">%</th>
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
</thead>
|
||||||
</tbody>
|
<tbody class="divide-y divide-gray-200">
|
||||||
</table>
|
@php $totalCountry = $countries->sum('visitors'); @endphp
|
||||||
|
@foreach ($countries as $row)
|
||||||
|
<tr>
|
||||||
|
<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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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>
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Visitors by City</h3>
|
||||||
@if ($cities->isEmpty())
|
@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
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 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">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-4 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-4 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-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200">
|
||||||
@foreach ($cities as $row)
|
@foreach ($cities as $row)
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->city }}</td>
|
<td class="px-4 py-2.5 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-4 py-2.5 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-4 py-2.5 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 text-right tabular-nums">{{ number_format($row->visitors) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Language Report">
|
<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>
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Visitors by Language</h3>
|
||||||
@if ($languages->isNotEmpty())
|
@if ($languages->isNotEmpty())
|
||||||
@php $total = $languages->sum('visitors'); @endphp
|
@php $total = $languages->sum('visitors'); @endphp
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,61 @@
|
|||||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="New vs Returning Visitors">
|
<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)
|
@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
|
@else
|
||||||
<div class="grid gap-6 sm:grid-cols-2 max-w-lg mx-auto">
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
<div class="rounded-lg border-2 border-indigo-200 bg-indigo-50 p-6 text-center">
|
<div class="h-64">
|
||||||
<div class="text-3xl font-bold text-indigo-700">{{ $data['new_pct'] }}%</div>
|
<canvas id="newVsReturningChart"></canvas>
|
||||||
<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>
|
||||||
<div class="rounded-lg border-2 border-emerald-200 bg-emerald-50 p-6 text-center">
|
<div class="grid gap-6 sm:grid-cols-2">
|
||||||
<div class="text-3xl font-bold text-emerald-700">{{ $data['returning_pct'] }}%</div>
|
<div class="rounded-xl border-2 border-indigo-200 bg-indigo-50 p-6 text-center">
|
||||||
<div class="text-sm font-medium text-emerald-600 mt-1">Returning Visitors</div>
|
<div class="text-3xl font-bold text-indigo-700">{{ $data['new_pct'] }}%</div>
|
||||||
<div class="text-xs text-emerald-400 mt-0.5">{{ number_format($data['returning']) }} visitors</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-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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,68 @@
|
|||||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Technology Report">
|
<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">
|
<div class="space-y-6">
|
||||||
{{-- Device Type --}}
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
|
||||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Device Type</h3>
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Device Type</h3>
|
||||||
@if ($devices->isNotEmpty())
|
@if ($devices->isNotEmpty())
|
||||||
@php $totalDevices = $devices->sum('visitors'); @endphp
|
@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">
|
||||||
@foreach ($devices as $row)
|
<div class="h-64">
|
||||||
<div class="rounded-lg border border-gray-200 p-4 text-center">
|
<canvas id="deviceChart"></canvas>
|
||||||
<div class="text-2xl font-bold text-gray-900">{{ $totalDevices > 0 ? round(($row->visitors / $totalDevices) * 100, 1) : 0 }}%</div>
|
</div>
|
||||||
<div class="text-sm text-gray-500 capitalize mt-1">{{ $row->device_type ?? 'Unknown' }}</div>
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
<div class="text-xs text-gray-400">{{ number_format($row->visitors) }} visitors</div>
|
@foreach ($devices as $row)
|
||||||
</div>
|
<div class="rounded-xl border border-gray-200 p-4 text-center">
|
||||||
@endforeach
|
<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>
|
</div>
|
||||||
@else
|
@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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-2">
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
{{-- Browsers --}}
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
|
||||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Browsers</h3>
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Browsers</h3>
|
||||||
@if ($browsers->isNotEmpty())
|
@if ($browsers->isNotEmpty())
|
||||||
@php $totalBrowsers = $browsers->sum('visitors'); @endphp
|
@php $totalBrowsers = $browsers->sum('visitors'); @endphp
|
||||||
@@ -40,12 +80,11 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Operating Systems --}}
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
|
||||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Operating Systems</h3>
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Operating Systems</h3>
|
||||||
@if ($operatingSystems->isNotEmpty())
|
@if ($operatingSystems->isNotEmpty())
|
||||||
@php $totalOS = $operatingSystems->sum('visitors'); @endphp
|
@php $totalOS = $operatingSystems->sum('visitors'); @endphp
|
||||||
@@ -63,13 +102,12 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Screen Resolutions --}}
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
|
||||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Screen Resolutions</h3>
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Screen Resolutions</h3>
|
||||||
@if ($resolutions->isNotEmpty())
|
@if ($resolutions->isNotEmpty())
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -91,7 +129,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Exit Pages">
|
<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())
|
@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
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<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">
|
<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())
|
@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
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<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">
|
<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())
|
||||||
@if ($pages->isEmpty())
|
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||||
<div class="px-6 py-12 text-center text-sm text-gray-500">No page view data available for this period.</div>
|
<x-empty-state message="No page view data available for this period." />
|
||||||
@else
|
</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">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
@@ -26,7 +43,45 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
</div>
|
||||||
</div>
|
|
||||||
|
<x-slot name="scripts">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
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>
|
</x-report-layout>
|
||||||
|
|||||||
@@ -8,22 +8,22 @@
|
|||||||
$overallRate = $totalSessions > 0 ? round(($totalCompletions / $totalSessions) * 100, 2) : 0;
|
$overallRate = $totalSessions > 0 ? round(($totalCompletions / $totalSessions) * 100, 2) : 0;
|
||||||
@endphp
|
@endphp
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<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>
|
<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>
|
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ number_format($totalCompletions) }}</dd>
|
||||||
</div>
|
</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>
|
<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>
|
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ $overallRate }}%</dd>
|
||||||
</div>
|
</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>
|
<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>
|
<dd class="mt-1 text-2xl font-bold text-gray-900">${{ number_format($totalValue, 2) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Completions Chart --}}
|
{{-- 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>
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Goal Completions Over Time</h3>
|
||||||
<div style="height: 240px;">
|
<div style="height: 240px;">
|
||||||
<canvas id="conversions-chart"></canvas>
|
<canvas id="conversions-chart"></canvas>
|
||||||
@@ -31,12 +31,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Per-Goal Table --}}
|
{{-- 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())
|
@if ($goals->isEmpty())
|
||||||
<div class="px-6 py-12 text-center text-sm text-gray-500">
|
<x-empty-state message="No goals configured.">
|
||||||
No goals configured.
|
<a href="{{ route('goals.create', $website) }}" class="text-sm text-indigo-600 hover:text-indigo-800">Create one</a>
|
||||||
<a href="{{ route('goals.create', $website) }}" class="text-indigo-600 hover:text-indigo-800">Create one</a>.
|
</x-empty-state>
|
||||||
</div>
|
|
||||||
@else
|
@else
|
||||||
@php
|
@php
|
||||||
$typeLabels = [
|
$typeLabels = [
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
<x-report-layout :website="$website" :from="$from" :to="$to" :title="'Event: ' . $eventName">
|
<x-report-layout :website="$website" :from="$from" :to="$to" :title="'Event: ' . $eventName">
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{{-- Trending Chart --}}
|
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden p-6">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
|
||||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Trend</h3>
|
<h3 class="text-base font-semibold text-gray-900 mb-4">Trend</h3>
|
||||||
<div style="height: 240px;">
|
<div style="height: 240px;">
|
||||||
<canvas id="event-trend-chart"></canvas>
|
<canvas id="event-trend-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Detail Table --}}
|
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
|
||||||
@if ($details->isEmpty())
|
@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
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
@@ -59,7 +57,14 @@
|
|||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: { legend: { display: false } },
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(17,24,39,0.95)',
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { grid: { display: false }, ticks: { maxTicksLimit: 12, font: { size: 11 }, color: '#9ca3af' } },
|
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' } },
|
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">
|
<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())
|
@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
|
@else
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-analytics-layout>
|
||||||
@@ -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>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
<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">
|
<div class="p-6">
|
||||||
<form method="POST" action="{{ route('websites.store') }}">
|
<form method="POST" action="{{ route('websites.store') }}">
|
||||||
@csrf
|
@csrf
|
||||||
@@ -26,7 +26,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<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>
|
<a href="{{ route('websites.index') }}" class="text-sm text-gray-600 hover:text-gray-900">{{ __('Cancel') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
<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">
|
<div class="p-6">
|
||||||
<form method="POST" action="{{ route('websites.update', $website) }}">
|
<form method="POST" action="{{ route('websites.update', $website) }}">
|
||||||
@csrf
|
@csrf
|
||||||
@@ -34,14 +34,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<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>
|
<a href="{{ route('dashboard.show', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">{{ __('Cancel') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="p-6">
|
||||||
<h3 class="text-lg font-medium text-red-600 mb-4">{{ __('Danger Zone') }}</h3>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -1,64 +1,55 @@
|
|||||||
<x-app-layout>
|
<x-app-layout>
|
||||||
<x-slot name="header">
|
<x-slot name="header">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Websites</h2>
|
||||||
{{ __('Websites') }}
|
<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">
|
||||||
</h2>
|
<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>
|
||||||
<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
|
||||||
{{ __('Add Website') }}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="py-10">
|
||||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
@if (session('success'))
|
@if (session('success'))
|
||||||
<div class="mb-4 rounded-md bg-green-50 p-4">
|
<div class="mb-6 rounded-xl bg-emerald-50 border border-emerald-200 p-4">
|
||||||
<p class="text-sm font-medium text-green-800">{{ session('success') }}</p>
|
<p class="text-sm font-medium text-emerald-800">{{ session('success') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($websites->isEmpty())
|
@if ($websites->isEmpty())
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="bg-white rounded-xl border border-gray-200 p-16 text-center">
|
||||||
<div class="p-12 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">
|
||||||
<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="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" />
|
||||||
<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>
|
||||||
</svg>
|
<h3 class="mt-4 text-lg font-semibold text-gray-900">No websites yet</h3>
|
||||||
<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 to track.</p>
|
||||||
<p class="mt-1 text-sm text-gray-500">{{ __('Get started by adding your first website.') }}</p>
|
<div class="mt-6">
|
||||||
<div class="mt-6">
|
<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">
|
||||||
<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">
|
<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') }}
|
Add Website
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@foreach ($websites as $website)
|
@foreach ($websites as $website)
|
||||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
<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="p-6">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="min-w-0">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
<h3 class="text-base font-semibold text-gray-900 truncate group-hover:text-indigo-600 transition-colors">{{ $website->name }}</h3>
|
||||||
<a href="{{ route('dashboard.show', $website) }}" class="hover:text-indigo-600">
|
<p class="text-sm text-gray-500 mt-0.5">{{ $website->domain }}</p>
|
||||||
{{ $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' }}">
|
|
||||||
{{ $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>
|
||||||
|
<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>
|
</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
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\V1\WebsiteController as ApiWebsiteController;
|
||||||
use App\Http\Controllers\CollectController;
|
use App\Http\Controllers\CollectController;
|
||||||
use App\Http\Controllers\TrackingScriptController;
|
use App\Http\Controllers\TrackingScriptController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -17,3 +18,12 @@ Route::middleware('collect.cors')->group(function () {
|
|||||||
->middleware('throttle:collect')
|
->middleware('throttle:collect')
|
||||||
->name('collect.pixel');
|
->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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ Artisan::command('inspire', function () {
|
|||||||
|
|
||||||
Schedule::command('analytics:aggregate')->dailyAt('02:00');
|
Schedule::command('analytics:aggregate')->dailyAt('02:00');
|
||||||
Schedule::command('analytics:purge --force')->dailyAt('03:00');
|
Schedule::command('analytics:purge --force')->dailyAt('03:00');
|
||||||
|
Schedule::command('analytics:check-alerts')->hourly();
|
||||||
|
|||||||
@@ -51,12 +51,71 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::put('{goal}', [\App\Http\Controllers\GoalController::class, 'update'])->name('update');
|
Route::put('{goal}', [\App\Http\Controllers\GoalController::class, 'update'])->name('update');
|
||||||
Route::delete('{goal}', [\App\Http\Controllers\GoalController::class, 'destroy'])->name('destroy');
|
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::middleware('auth')->group(function () {
|
||||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||||
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
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';
|
require __DIR__.'/auth.php';
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Figtree', ...defaultTheme.fontFamily.sans],
|
sans: ['Inter', 'Figtree', ...defaultTheme.fontFamily.sans],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user