phase 8 and 9
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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'])
|
||||
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('dashboard.show', $website) }}" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ $title }}</h2>
|
||||
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
|
||||
</div>
|
||||
<x-date-range-picker :website="$website" :from="$from" :to="$to" />
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="flex gap-6">
|
||||
{{-- Sidebar --}}
|
||||
<nav class="hidden lg:block w-56 shrink-0">
|
||||
<div class="bg-white shadow-sm sm:rounded-lg p-4 sticky top-8 space-y-6">
|
||||
@php
|
||||
$q = request()->only(['range', 'from', 'to']);
|
||||
$active = fn(string $route) => request()->routeIs($route) ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-gray-600 hover:bg-gray-50';
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
<h4 class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Audience</h4>
|
||||
<a href="{{ route('reports.geographic', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.geographic') }}">Geographic</a>
|
||||
<a href="{{ route('reports.technology', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.technology') }}">Technology</a>
|
||||
<a href="{{ route('reports.language', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.language') }}">Language</a>
|
||||
<a href="{{ route('reports.new-vs-returning', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.new-vs-returning') }}">New vs Returning</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Acquisition</h4>
|
||||
<a href="{{ route('reports.sources', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.sources') }}">Traffic Sources</a>
|
||||
<a href="{{ route('reports.campaigns', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.campaigns') }}">Campaigns</a>
|
||||
<a href="{{ route('reports.referrers', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.referrers') }}">Referrers</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Behavior</h4>
|
||||
<a href="{{ route('reports.pages', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.pages') }}">Pages</a>
|
||||
<a href="{{ route('reports.landing-pages', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.landing-pages') }}">Landing Pages</a>
|
||||
<a href="{{ route('reports.exit-pages', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.exit-pages') }}">Exit Pages</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Events</h4>
|
||||
<a href="{{ route('reports.events', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.events') }}">Overview</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Conversions</h4>
|
||||
<a href="{{ route('reports.conversions', [$website] + $q) }}" class="block rounded-md px-3 py-1.5 text-sm {{ $active('reports.conversions') }}">Goals</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{-- Main Content --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<x-analytics-layout :website="$website" :from="$from" :to="$to" :title="$title">
|
||||
{{ $slot }}
|
||||
|
||||
@isset($scripts)
|
||||
@push('scripts')
|
||||
{{ $scripts }}
|
||||
@endpush
|
||||
@endisset
|
||||
</x-app-layout>
|
||||
</x-analytics-layout>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<x-analytics-layout :website="$website" title="Manage Widgets">
|
||||
<x-slot name="headerActions">
|
||||
<a href="{{ route('custom-dashboard.show', $website) }}" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
|
||||
View Dashboard
|
||||
</a>
|
||||
</x-slot>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mb-6 rounded-xl bg-emerald-50 border border-emerald-200 p-4">
|
||||
<p class="text-sm font-medium text-emerald-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
{{-- Add widget form --}}
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Add Widget</h3>
|
||||
<form method="POST" action="{{ route('custom-dashboard.store', $website) }}" x-data="{ type: 'metric' }" class="space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
||||
<input type="text" name="title" id="title" required placeholder="e.g. Daily Visitors"
|
||||
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="widget_type" class="block text-sm font-medium text-gray-700 mb-1">Type</label>
|
||||
<select name="widget_type" id="widget_type" x-model="type"
|
||||
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@foreach ($types as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="type === 'metric'" x-cloak>
|
||||
<label for="config_metric" class="block text-sm font-medium text-gray-700 mb-1">Metric</label>
|
||||
<select name="config[metric]" id="config_metric"
|
||||
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@foreach ($metrics as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="size" class="block text-sm font-medium text-gray-700 mb-1">Size</label>
|
||||
<select name="size" id="size"
|
||||
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="third">Small (1/3)</option>
|
||||
<option value="half">Medium (1/2)</option>
|
||||
<option value="full">Full Width</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
|
||||
Add Widget
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Current widgets --}}
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Current Widgets</h3>
|
||||
|
||||
@if ($widgets->isEmpty())
|
||||
<p class="text-sm text-gray-400">No widgets yet. Add one using the form.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($widgets as $widget)
|
||||
<div class="flex items-center justify-between rounded-lg border border-gray-200 px-4 py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ $widget->title }}</p>
|
||||
<p class="text-xs text-gray-500">{{ \App\Models\DashboardWidget::$types[$widget->widget_type] ?? $widget->widget_type }} · {{ $widget->size }}</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('custom-dashboard.destroy', [$website, $widget]) }}" onsubmit="return confirm('Remove this widget?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600 hover:text-red-800 font-medium transition">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-analytics-layout>
|
||||
@@ -0,0 +1,120 @@
|
||||
<x-analytics-layout :website="$website" :from="$from" :to="$to" title="Custom Dashboard">
|
||||
<x-slot name="headerActions">
|
||||
<a href="{{ route('custom-dashboard.manage', $website) }}" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
|
||||
Manage Widgets
|
||||
</a>
|
||||
</x-slot>
|
||||
|
||||
@if ($widgets->isEmpty())
|
||||
<x-empty-state
|
||||
icon='<svg class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" /></svg>'
|
||||
title="No widgets yet"
|
||||
message="Add widgets to build your custom dashboard."
|
||||
>
|
||||
<a href="{{ route('custom-dashboard.manage', $website) }}" class="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg text-sm font-medium text-white hover:bg-indigo-500 transition">
|
||||
Manage Widgets
|
||||
</a>
|
||||
</x-empty-state>
|
||||
@else
|
||||
<div class="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($widgets as $widget)
|
||||
@php
|
||||
$colSpan = match($widget->size) {
|
||||
'full' => 'sm:col-span-2 lg:col-span-3',
|
||||
'half' => 'sm:col-span-1 lg:col-span-2' ,
|
||||
default => '',
|
||||
};
|
||||
$data = $widgetData[$widget->id] ?? null;
|
||||
@endphp
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-5 {{ $colSpan }}">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">{{ $widget->title }}</h3>
|
||||
|
||||
@if ($widget->widget_type === 'metric')
|
||||
@php $metricLabel = \App\Models\DashboardWidget::$metrics[$widget->config['metric'] ?? 'page_views'] ?? 'Metric'; @endphp
|
||||
<p class="text-3xl font-bold text-gray-900 tabular-nums">
|
||||
@if (($widget->config['metric'] ?? '') === 'bounce_rate')
|
||||
{{ $data }}%
|
||||
@elseif (($widget->config['metric'] ?? '') === 'avg_duration')
|
||||
{{ gmdate('i:s', (int) $data) }}
|
||||
@else
|
||||
{{ number_format($data) }}
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ $metricLabel }}</p>
|
||||
|
||||
@elseif ($widget->widget_type === 'chart')
|
||||
<div style="height: 200px">
|
||||
<canvas id="widget-chart-{{ $widget->id }}"></canvas>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const ctx = document.getElementById('widget-chart-{{ $widget->id }}');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: @json($data->pluck('date')),
|
||||
datasets: [{
|
||||
label: 'Visitors',
|
||||
data: @json($data->pluck('visitors')),
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99,102,241,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { beginAtZero: true } } }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@elseif (in_array($widget->widget_type, ['top_pages', 'top_sources', 'browsers', 'countries', 'events']))
|
||||
@if ($data && $data->isNotEmpty())
|
||||
<div class="space-y-2">
|
||||
@foreach ($data->take(5) as $row)
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600 truncate mr-2">
|
||||
{{ $row->path ?? $row->source ?? $row->browser ?? $row->country ?? $row->name ?? '—' }}
|
||||
</span>
|
||||
<span class="font-medium text-gray-900 tabular-nums shrink-0">
|
||||
{{ number_format($row->views ?? $row->sessions ?? $row->visitors ?? $row->total ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-400">No data for this period.</p>
|
||||
@endif
|
||||
|
||||
@elseif ($widget->widget_type === 'new_vs_returning')
|
||||
<div class="flex items-center gap-6">
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-indigo-600 tabular-nums">{{ $data['new_pct'] ?? 0 }}%</p>
|
||||
<p class="text-xs text-gray-500">New</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-emerald-600 tabular-nums">{{ $data['returning_pct'] ?? 0 }}%</p>
|
||||
<p class="text-xs text-gray-500">Returning</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@elseif ($widget->widget_type === 'conversions')
|
||||
@if ($data && $data->isNotEmpty())
|
||||
<div class="space-y-2">
|
||||
@foreach ($data->take(5) as $goal)
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600 truncate mr-2">{{ $goal->name }}</span>
|
||||
<span class="font-medium text-gray-900 tabular-nums shrink-0">{{ number_format($goal->conversions) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-400">No conversions for this period.</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-analytics-layout>
|
||||
@@ -1,306 +1,222 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('websites.index') }}" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ $website->name }}
|
||||
</h2>
|
||||
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
{{-- Real-time indicator --}}
|
||||
<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>
|
||||
<x-analytics-layout :website="$website" :from="$from" :to="$to" title="Dashboard">
|
||||
<x-slot name="headerActions">
|
||||
<div
|
||||
x-data="realtimeWidget('{{ route('dashboard.realtime', $website) }}', {{ $activeVisitors }})"
|
||||
x-init="start()"
|
||||
class="hidden sm:flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1.5 text-sm ring-1 ring-emerald-200"
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</span>
|
||||
<span class="font-medium text-emerald-800"><span x-text="count"></span> online</span>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="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 --}}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Page Views</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ number_format($metrics['page_views']) }}</dd>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Unique Visitors</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ number_format($metrics['visitors']) }}</dd>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Sessions</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ number_format($metrics['sessions']) }}</dd>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Bounce Rate</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ $metrics['bounce_rate'] }}%</dd>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Avg. Duration</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ gmdate('i:s', (int) $metrics['avg_duration']) }}</dd>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Pages / Session</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ $metrics['avg_pages'] }}</dd>
|
||||
{{-- Visitors chart --}}
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Visitors & Page Views</h3>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-0.5 rounded bg-indigo-500"></span>Visitors</span>
|
||||
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-0.5 rounded bg-emerald-500"></span>Page Views</span>
|
||||
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-0.5 rounded bg-amber-400"></span>Sessions</span>
|
||||
</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 --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
{{-- Two-column: Top Pages + Top Sources --}}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
{{-- Top pages --}}
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Visitors & Page Views</h3>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-0.5 rounded bg-indigo-500"></span> Visitors
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-0.5 rounded bg-emerald-500"></span> Page Views
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-0.5 rounded bg-amber-400"></span> Sessions
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900">Top Pages</h3>
|
||||
<a href="{{ route('reports.pages', [$website] + request()->only(['range', 'from', 'to'])) }}" class="text-sm text-indigo-600 hover:text-indigo-800">View all</a>
|
||||
</div>
|
||||
|
||||
<div style="height: 320px;">
|
||||
<canvas id="visitors-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Goal Completions --}}
|
||||
@if ($goalConversions->isNotEmpty())
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Goal Completions</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('reports.conversions', [$website] + request()->only(['range', 'from', 'to'])) }}" class="text-sm text-indigo-600 hover:text-indigo-800">View report</a>
|
||||
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-500 hover:text-gray-700">Manage goals</a>
|
||||
</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>
|
||||
@if ($topPages->isEmpty())
|
||||
<x-empty-state message="No page data yet." compact />
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@php $maxViews = $topPages->max('views') ?: 1; @endphp
|
||||
@foreach ($topPages as $page)
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-700 truncate font-mono text-xs" title="{{ $page->path }}">{{ Str::limit($page->path, 50) }}</span>
|
||||
<span class="text-gray-500 tabular-nums ml-2 shrink-0">{{ number_format($page->views) }}</span>
|
||||
</div>
|
||||
<div class="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 class="w-full bg-gray-100 rounded-full h-1.5">
|
||||
<div class="bg-indigo-500 h-1.5 rounded-full transition-all" style="width: {{ ($page->views / $maxViews) * 100 }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- 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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Top sources --}}
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Traffic Sources</h3>
|
||||
<a href="{{ route('reports.sources', [$website] + request()->only(['range', 'from', 'to'])) }}" class="text-sm text-indigo-600 hover:text-indigo-800">View all</a>
|
||||
</div>
|
||||
@if ($topSources->isEmpty())
|
||||
<x-empty-state message="No traffic source data yet." compact />
|
||||
@else
|
||||
<div class="flex gap-6">
|
||||
<div class="w-40 h-40 shrink-0"><canvas id="sources-donut"></canvas></div>
|
||||
<div class="flex-1 space-y-2 min-w-0">
|
||||
@php $maxSess = $topSources->max('sessions') ?: 1; @endphp
|
||||
@foreach ($topSources as $src)
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-700 truncate">{{ $src->source }}</span>
|
||||
<span class="text-gray-500 tabular-nums ml-2 shrink-0">{{ number_format($src->sessions) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 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>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
const chartDefaults = {
|
||||
font: { family: 'Inter, system-ui, sans-serif' },
|
||||
color: '#9ca3af',
|
||||
};
|
||||
|
||||
Chart.defaults.font = chartDefaults.font;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const raw = @json($chartData);
|
||||
const labels = raw.map(d => d.period);
|
||||
const visitors = raw.map(d => d.visitors);
|
||||
const pageViews = raw.map(d => d.page_views);
|
||||
const sessions = raw.map(d => d.sessions);
|
||||
if (raw.some(d => d.page_views > 0)) {
|
||||
new Chart(document.getElementById('visitors-chart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: 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'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Visitors',
|
||||
data: visitors,
|
||||
borderColor: 'rgb(99, 102, 241)',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.08)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: labels.length > 60 ? 0 : 3,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: 'Page Views',
|
||||
data: pageViews,
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.05)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: labels.length > 60 ? 0 : 3,
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: 'Sessions',
|
||||
data: sessions,
|
||||
borderColor: 'rgb(251, 191, 36)',
|
||||
backgroundColor: 'rgba(251, 191, 36, 0.05)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: labels.length > 60 ? 0 : 3,
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
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 },
|
||||
},
|
||||
// Sources donut
|
||||
const sourcesCanvas = document.getElementById('sources-donut');
|
||||
if (sourcesCanvas) {
|
||||
const srcData = @json($topSources);
|
||||
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
new Chart(sourcesCanvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: srcData.map(s => s.source),
|
||||
datasets: [{ data: srcData.map(s => s.sessions), backgroundColor: colors.slice(0, srcData.length), borderWidth: 0, spacing: 2 }],
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
cutout: '65%',
|
||||
plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', padding: 10, cornerRadius: 8 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Goal sparkline
|
||||
const goalCanvas = document.getElementById('goal-sparkline');
|
||||
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
|
||||
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) {
|
||||
function realtimeWidget(url, initial) {
|
||||
return {
|
||||
count: {{ $activeVisitors }},
|
||||
count: initial,
|
||||
start() {
|
||||
setInterval(() => {
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(d => { this.count = d.active; })
|
||||
.catch(() => {});
|
||||
fetch(url).then(r => r.json()).then(d => { this.count = d.active; }).catch(() => {});
|
||||
}, 30000);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
</x-analytics-layout>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<x-mail::message>
|
||||
# Alert Triggered: {{ $alert->name }}
|
||||
|
||||
{{ $notification->message }}
|
||||
|
||||
**Website:** {{ $alert->website->name }}
|
||||
**Time:** {{ $notification->created_at->format('M j, Y g:i A') }}
|
||||
|
||||
<x-mail::button :url="route('dashboard.show', $alert->website)">
|
||||
View Dashboard
|
||||
</x-mail::button>
|
||||
|
||||
You can manage your alerts in the analytics dashboard settings.
|
||||
|
||||
Thanks,<br>
|
||||
{{ config('app.name') }}
|
||||
</x-mail::message>
|
||||
@@ -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>
|
||||
|
||||
{{-- URL Destination fields --}}
|
||||
<div x-show="goalType === 'url_destination'" x-cloak class="rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div x-show="goalType === 'url_destination'" x-cloak class="rounded-xl border border-gray-200 p-4 space-y-4">
|
||||
<h4 class="text-sm font-medium text-gray-700">URL Destination Conditions</h4>
|
||||
<div>
|
||||
<x-input-label for="conditions_path" value="Path" />
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Event Match fields --}}
|
||||
<div x-show="goalType === 'event_match'" x-cloak class="rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div x-show="goalType === 'event_match'" x-cloak class="rounded-xl border border-gray-200 p-4 space-y-4">
|
||||
<h4 class="text-sm font-medium text-gray-700">Event Match Conditions</h4>
|
||||
<div>
|
||||
<x-input-label for="conditions_event_name" value="Event Name" />
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Duration fields --}}
|
||||
<div x-show="goalType === 'duration'" x-cloak class="rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div x-show="goalType === 'duration'" x-cloak class="rounded-xl border border-gray-200 p-4 space-y-4">
|
||||
<h4 class="text-sm font-medium text-gray-700">Duration Condition</h4>
|
||||
<div>
|
||||
<x-input-label for="conditions_seconds" value="Minimum seconds on site" />
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Pages per Session fields --}}
|
||||
<div x-show="goalType === 'pages_per_session'" x-cloak class="rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div x-show="goalType === 'pages_per_session'" x-cloak class="rounded-xl border border-gray-200 p-4 space-y-4">
|
||||
<h4 class="text-sm font-medium text-gray-700">Pages per Session Condition</h4>
|
||||
<div>
|
||||
<x-input-label for="conditions_page_count" value="Minimum pages viewed" />
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('goals.index', $website) }}" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Create Goal</h2>
|
||||
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
|
||||
</div>
|
||||
</x-slot>
|
||||
<x-analytics-layout :website="$website" title="Create Goal" :showDatePicker="false">
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<form method="POST" action="{{ route('goals.store', $website) }}">
|
||||
@csrf
|
||||
@include('goals._form', ['goal' => null])
|
||||
<div class="max-w-2xl">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<form method="POST" action="{{ route('goals.store', $website) }}">
|
||||
@csrf
|
||||
@include('goals._form', ['goal' => null])
|
||||
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<x-primary-button>Create Goal</x-primary-button>
|
||||
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 transition">Create Goal</button>
|
||||
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
</x-analytics-layout>
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('goals.index', $website) }}" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Edit Goal: {{ $goal->name }}</h2>
|
||||
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
|
||||
</div>
|
||||
</x-slot>
|
||||
<x-analytics-layout :website="$website" :title="'Edit Goal: ' . $goal->name" :showDatePicker="false">
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<form method="POST" action="{{ route('goals.update', [$website, $goal]) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
@include('goals._form', ['goal' => $goal])
|
||||
<div class="max-w-2xl">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<form method="POST" action="{{ route('goals.update', [$website, $goal]) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
@include('goals._form', ['goal' => $goal])
|
||||
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<x-primary-button>Save Changes</x-primary-button>
|
||||
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center gap-4">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 transition">Save Changes</button>
|
||||
<a href="{{ route('goals.index', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
</x-analytics-layout>
|
||||
|
||||
@@ -1,91 +1,74 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('dashboard.show', $website) }}" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Goals</h2>
|
||||
<span class="text-sm text-gray-400">{{ $website->domain }}</span>
|
||||
</div>
|
||||
<a href="{{ route('goals.create', $website) }}" class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 transition">
|
||||
Add Goal
|
||||
</a>
|
||||
</div>
|
||||
<x-analytics-layout :website="$website" title="Goals" :showDatePicker="false">
|
||||
<x-slot name="headerActions">
|
||||
<a href="{{ route('goals.create', $website) }}" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 transition">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
|
||||
Add Goal
|
||||
</a>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||
@if (session('success'))
|
||||
<div class="mb-4 rounded-md bg-green-50 p-4">
|
||||
<p class="text-sm font-medium text-green-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
<div class="max-w-4xl mx-auto">
|
||||
@if (session('success'))
|
||||
<div class="mb-4 rounded-md bg-green-50 p-4">
|
||||
<p class="text-sm font-medium text-green-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($goals->isEmpty())
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900">No goals</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Create goals to track conversions on your website.</p>
|
||||
<div class="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>
|
||||
@if ($goals->isEmpty())
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<x-empty-state message="No goals. Create goals to track conversions on your website." icon="chart">
|
||||
<div class="mt-6">
|
||||
<a href="{{ route('goals.create', $website) }}" class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700">
|
||||
Add Goal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
</x-empty-state>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
</x-analytics-layout>
|
||||
|
||||
@@ -5,29 +5,25 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
||||
<title>{{ config('app.name', 'Analytics') }}</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Scripts -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
@include('layouts.navigation')
|
||||
|
||||
<!-- Page Heading -->
|
||||
@isset($header)
|
||||
<header class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<header class="bg-white border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto py-5 px-4 sm:px-6 lg:px-8">
|
||||
{{ $header }}
|
||||
</div>
|
||||
</header>
|
||||
@endisset
|
||||
|
||||
<!-- Page Content -->
|
||||
<main>
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
@@ -1,60 +1,51 @@
|
||||
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
|
||||
<!-- Primary Navigation Menu -->
|
||||
<nav x-data="{ open: false }" class="bg-white border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<!-- Logo -->
|
||||
<div class="shrink-0 flex items-center">
|
||||
<a href="{{ route('dashboard') }}">
|
||||
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-8">
|
||||
<a href="{{ route('websites.index') }}" class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900">{{ config('app.name', 'Analytics') }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||
<x-nav-link :href="route('websites.index')" :active="request()->routeIs('websites.*')">
|
||||
{{ __('Websites') }}
|
||||
</x-nav-link>
|
||||
<div class="hidden sm:flex items-center gap-1">
|
||||
<a href="{{ route('websites.index') }}" class="px-3 py-2 rounded-lg text-sm font-medium {{ request()->routeIs('websites.*') ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50' }} transition">
|
||||
Websites
|
||||
</a>
|
||||
<a href="{{ route('api-tokens.index') }}" class="px-3 py-2 rounded-lg text-sm font-medium {{ request()->routeIs('api-tokens.*') ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50' }} transition">
|
||||
API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Dropdown -->
|
||||
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
||||
<div class="hidden sm:flex items-center">
|
||||
<x-dropdown align="right" width="48">
|
||||
<x-slot name="trigger">
|
||||
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
|
||||
<div>{{ Auth::user()->name }}</div>
|
||||
|
||||
<div class="ms-1">
|
||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<button class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">
|
||||
<span class="flex items-center justify-center w-7 h-7 rounded-full bg-indigo-100 text-xs font-semibold text-indigo-700">
|
||||
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
|
||||
</span>
|
||||
<span>{{ Auth::user()->name }}</span>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<x-dropdown-link :href="route('profile.edit')">
|
||||
{{ __('Profile') }}
|
||||
</x-dropdown-link>
|
||||
|
||||
<!-- Authentication -->
|
||||
<x-dropdown-link :href="route('profile.edit')">Profile</x-dropdown-link>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
|
||||
<x-dropdown-link :href="route('logout')"
|
||||
onclick="event.preventDefault();
|
||||
this.closest('form').submit();">
|
||||
{{ __('Log Out') }}
|
||||
</x-dropdown-link>
|
||||
<x-dropdown-link :href="route('logout')" onclick="event.preventDefault(); this.closest('form').submit();">Log Out</x-dropdown-link>
|
||||
</form>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- Hamburger -->
|
||||
<div class="-me-2 flex items-center sm:hidden">
|
||||
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
|
||||
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-lg text-gray-400 hover:text-gray-500 hover:bg-gray-100 transition">
|
||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -64,37 +55,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Responsive Navigation Menu -->
|
||||
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<x-responsive-nav-link :href="route('websites.index')" :active="request()->routeIs('websites.*')">
|
||||
{{ __('Websites') }}
|
||||
</x-responsive-nav-link>
|
||||
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden border-t border-gray-200">
|
||||
<div class="pt-2 pb-3 space-y-1 px-4">
|
||||
<a href="{{ route('websites.index') }}" class="block rounded-lg px-3 py-2 text-base font-medium {{ request()->routeIs('websites.*') ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600' }}">
|
||||
Websites
|
||||
</a>
|
||||
<a href="{{ route('api-tokens.index') }}" class="block rounded-lg px-3 py-2 text-base font-medium {{ request()->routeIs('api-tokens.*') ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600' }}">
|
||||
API Tokens
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Responsive Settings Options -->
|
||||
<div class="pt-4 pb-1 border-t border-gray-200">
|
||||
<div class="px-4">
|
||||
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
|
||||
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-1">
|
||||
<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 class="pt-3 pb-3 border-t border-gray-200 px-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-full bg-indigo-100 text-sm font-semibold text-indigo-700">
|
||||
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800">{{ Auth::user()->name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ Auth::user()->email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Campaigns (UTM)">
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="bg-white rounded-xl border border-gray-200">
|
||||
@if ($campaigns->isEmpty())
|
||||
<div class="px-6 py-12 text-center text-sm text-gray-500">No campaign data available. Traffic with UTM parameters will appear here.</div>
|
||||
<x-empty-state message="No campaign data available. Traffic with UTM parameters will appear here." />
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Medium</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Campaign</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Medium</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Campaign</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($campaigns as $row)
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->utm_source }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->utm_medium ?? '—' }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->utm_campaign ?? '—' }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->sessions) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900">{{ $row->utm_source }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-500">{{ $row->utm_medium ?? '—' }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-500">{{ $row->utm_campaign ?? '—' }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ number_format($row->sessions) }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Referrers">
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
@if ($referrers->isEmpty())
|
||||
<div class="px-6 py-12 text-center text-sm text-gray-500">No referrer data available for this period.</div>
|
||||
<x-empty-state message="No referrer data available for this period." />
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
|
||||
@@ -1,34 +1,75 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Traffic Sources">
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
@if ($sources->isEmpty())
|
||||
<div class="px-6 py-12 text-center text-sm text-gray-500">No traffic source data available for this period.</div>
|
||||
@else
|
||||
@if ($sources->isEmpty())
|
||||
<div class="bg-white rounded-xl border border-gray-200">
|
||||
<x-empty-state message="No traffic source data available for this period." />
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$chartSources = $sources->take(10);
|
||||
@endphp
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6 mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-4">Top Sources by Sessions</h3>
|
||||
<div style="height: 280px;"><canvas id="sources-chart"></canvas></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-200">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Pages / Session</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Pages / Session</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($sources as $row)
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 font-medium">{{ $row->source }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->sessions) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ round($row->avg_pages, 1) }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 font-medium">{{ $row->source }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ number_format($row->sessions) }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ round($row->avg_pages, 1) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-slot name="scripts">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#6366f1', '#10b981', '#f59e0b'];
|
||||
const srcData = @json($chartSources->map(fn($s) => ['source' => $s->source, 'sessions' => $s->sessions])->values());
|
||||
new Chart(document.getElementById('sources-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: srcData.map(d => d.source),
|
||||
datasets: [{ label: 'Sessions', data: srcData.map(d => d.sessions), backgroundColor: colors, borderRadius: 6 }]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', padding: 10, cornerRadius: 8 }
|
||||
},
|
||||
scales: {
|
||||
x: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.04)' } },
|
||||
y: { grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</x-slot>
|
||||
@endif
|
||||
|
||||
</x-report-layout>
|
||||
|
||||
@@ -1,57 +1,180 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Geographic Report">
|
||||
|
||||
<x-slot name="scripts">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-geo@4/build/index.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const countriesData = @json($countries->map(fn($c) => ['country' => $c->country, 'visitors' => $c->visitors])->values());
|
||||
|
||||
if (countriesData.length > 0) {
|
||||
// Bar chart
|
||||
const barCtx = document.getElementById('countriesChart');
|
||||
if (barCtx) {
|
||||
new Chart(barCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: countriesData.slice(0, 10).map(d => d.country),
|
||||
datasets: [{
|
||||
label: 'Visitors',
|
||||
data: countriesData.slice(0, 10).map(d => d.visitors),
|
||||
backgroundColor: '#6366f1',
|
||||
borderRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', padding: 10, cornerRadius: 8 },
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
x: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.04)' } },
|
||||
y: { grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// World map
|
||||
const mapCtx = document.getElementById('worldMap');
|
||||
if (mapCtx && window.ChartGeo) {
|
||||
try {
|
||||
const resp = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json');
|
||||
const topoData = await resp.json();
|
||||
const countries110 = ChartGeo.topojson.feature(topoData, topoData.objects.countries).features;
|
||||
|
||||
const countryMap = {};
|
||||
countriesData.forEach(d => { countryMap[d.country] = d.visitors; });
|
||||
const maxVisitors = Math.max(...countriesData.map(d => d.visitors));
|
||||
|
||||
new Chart(mapCtx, {
|
||||
type: 'choropleth',
|
||||
data: {
|
||||
labels: countries110.map(f => f.properties.name),
|
||||
datasets: [{
|
||||
label: 'Visitors',
|
||||
outline: countries110,
|
||||
data: countries110.map(f => ({
|
||||
feature: f,
|
||||
value: countryMap[f.properties.name] || 0
|
||||
})),
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
showOutline: true,
|
||||
showGraticule: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17,24,39,0.95)',
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const val = ctx.raw?.value || 0;
|
||||
return val > 0 ? `${ctx.label}: ${val.toLocaleString()} visitors` : `${ctx.label}: 0`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
projection: {
|
||||
axis: 'x',
|
||||
projection: 'equalEarth',
|
||||
},
|
||||
color: {
|
||||
axis: 'x',
|
||||
quantize: 5,
|
||||
legend: { display: false },
|
||||
interpolate: (v) => {
|
||||
const r = Math.round(99 + (255 - 99) * (1 - v));
|
||||
const g = Math.round(102 + (255 - 102) * (1 - v));
|
||||
const b = Math.round(241 + (255 - 241) * (1 - v));
|
||||
return `rgb(${r},${g},${b})`;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
document.getElementById('worldMapContainer')?.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
{{-- World Map --}}
|
||||
@if ($countries->isNotEmpty())
|
||||
<div id="worldMapContainer" class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">World Map</h3>
|
||||
<div style="height: 380px;"><canvas id="worldMap"></canvas></div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Countries --}}
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Visitors by Country</h3>
|
||||
@if ($countries->isEmpty())
|
||||
<p class="text-sm text-gray-500 py-8 text-center">No geographic data available for this period.</p>
|
||||
<x-empty-state icon="globe" message="No geographic data available for this period." />
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@php $totalCountry = $countries->sum('visitors'); @endphp
|
||||
@foreach ($countries as $row)
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div style="height: 320px;"><canvas id="countriesChart"></canvas></div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->country }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->visitors) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-500 text-right">{{ $totalCountry > 0 ? round(($row->visitors / $totalCountry) * 100, 1) : 0 }}%</td>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">%</th>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
{{-- Cities --}}
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Visitors by City</h3>
|
||||
@if ($cities->isEmpty())
|
||||
<p class="text-sm text-gray-500 py-8 text-center">No city-level data available.</p>
|
||||
<x-empty-state icon="globe" message="No city-level data available." />
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">City</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Region</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">City</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Region</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($cities as $row)
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->city }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->region }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->country }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->visitors) }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900">{{ $row->city }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-500">{{ $row->region }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-500">{{ $row->country }}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-gray-900 text-right tabular-nums">{{ number_format($row->visitors) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Language Report">
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Visitors by Language</h3>
|
||||
@if ($languages->isNotEmpty())
|
||||
@php $total = $languages->sum('visitors'); @endphp
|
||||
@@ -18,7 +18,7 @@
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500 py-8 text-center">No language data available for this period.</p>
|
||||
<x-empty-state message="No language data available for this period." />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,19 +1,61 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="New vs Returning Visitors">
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<x-slot name="scripts">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const newCount = {{ $data['new'] }};
|
||||
const returningCount = {{ $data['returning'] }};
|
||||
if (newCount > 0 || returningCount > 0) {
|
||||
const ctx = document.getElementById('newVsReturningChart');
|
||||
if (ctx) {
|
||||
new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: ['New Visitors', 'Returning Visitors'],
|
||||
datasets: [{
|
||||
data: [newCount, returningCount],
|
||||
backgroundColor: ['#6366f1', '#10b981'],
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17,24,39,0.95)',
|
||||
padding: 10,
|
||||
cornerRadius: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</x-slot>
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
|
||||
@if ($data['new'] === 0 && $data['returning'] === 0)
|
||||
<p class="text-sm text-gray-500 py-8 text-center">No visitor data available for this period.</p>
|
||||
<x-empty-state message="No visitor data available for this period." />
|
||||
@else
|
||||
<div class="grid gap-6 sm:grid-cols-2 max-w-lg mx-auto">
|
||||
<div class="rounded-lg border-2 border-indigo-200 bg-indigo-50 p-6 text-center">
|
||||
<div class="text-3xl font-bold text-indigo-700">{{ $data['new_pct'] }}%</div>
|
||||
<div class="text-sm font-medium text-indigo-600 mt-1">New Visitors</div>
|
||||
<div class="text-xs text-indigo-400 mt-0.5">{{ number_format($data['new']) }} visitors</div>
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="h-64">
|
||||
<canvas id="newVsReturningChart"></canvas>
|
||||
</div>
|
||||
<div class="rounded-lg border-2 border-emerald-200 bg-emerald-50 p-6 text-center">
|
||||
<div class="text-3xl font-bold text-emerald-700">{{ $data['returning_pct'] }}%</div>
|
||||
<div class="text-sm font-medium text-emerald-600 mt-1">Returning Visitors</div>
|
||||
<div class="text-xs text-emerald-400 mt-0.5">{{ number_format($data['returning']) }} visitors</div>
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div class="rounded-xl border-2 border-indigo-200 bg-indigo-50 p-6 text-center">
|
||||
<div class="text-3xl font-bold text-indigo-700">{{ $data['new_pct'] }}%</div>
|
||||
<div class="text-sm font-medium text-indigo-600 mt-1">New Visitors</div>
|
||||
<div class="text-xs text-indigo-400 mt-0.5">{{ number_format($data['new']) }} visitors</div>
|
||||
</div>
|
||||
<div class="rounded-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>
|
||||
|
||||
|
||||
@@ -1,28 +1,68 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Technology Report">
|
||||
|
||||
<x-slot name="scripts">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const devicesData = @json($devices->map(fn($d) => ['label' => $d->device_type ?? 'Unknown', 'visitors' => $d->visitors])->values());
|
||||
if (devicesData.length > 0) {
|
||||
const ctx = document.getElementById('deviceChart');
|
||||
if (ctx) {
|
||||
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: devicesData.map(d => d.label),
|
||||
datasets: [{
|
||||
data: devicesData.map(d => d.visitors),
|
||||
backgroundColor: colors.slice(0, devicesData.length),
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17,24,39,0.95)',
|
||||
padding: 10,
|
||||
cornerRadius: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
{{-- Device Type --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Device Type</h3>
|
||||
@if ($devices->isNotEmpty())
|
||||
@php $totalDevices = $devices->sum('visitors'); @endphp
|
||||
<div class="grid gap-4 sm:grid-cols-3 mb-4">
|
||||
@foreach ($devices as $row)
|
||||
<div class="rounded-lg border border-gray-200 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">{{ $totalDevices > 0 ? round(($row->visitors / $totalDevices) * 100, 1) : 0 }}%</div>
|
||||
<div class="text-sm text-gray-500 capitalize mt-1">{{ $row->device_type ?? 'Unknown' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ number_format($row->visitors) }} visitors</div>
|
||||
</div>
|
||||
@endforeach
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="h-64">
|
||||
<canvas id="deviceChart"></canvas>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
@foreach ($devices as $row)
|
||||
<div class="rounded-xl border border-gray-200 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">{{ $totalDevices > 0 ? round(($row->visitors / $totalDevices) * 100, 1) : 0 }}%</div>
|
||||
<div class="text-sm text-gray-500 capitalize mt-1">{{ $row->device_type ?? 'Unknown' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ number_format($row->visitors) }} visitors</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500 py-4 text-center">No device data available.</p>
|
||||
<x-empty-state message="No device data available." />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
{{-- Browsers --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Browsers</h3>
|
||||
@if ($browsers->isNotEmpty())
|
||||
@php $totalBrowsers = $browsers->sum('visitors'); @endphp
|
||||
@@ -40,12 +80,11 @@
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500 py-4 text-center">No browser data available.</p>
|
||||
<x-empty-state message="No browser data available." />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Operating Systems --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Operating Systems</h3>
|
||||
@if ($operatingSystems->isNotEmpty())
|
||||
@php $totalOS = $operatingSystems->sum('visitors'); @endphp
|
||||
@@ -63,13 +102,12 @@
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500 py-4 text-center">No OS data available.</p>
|
||||
<x-empty-state message="No OS data available." />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Screen Resolutions --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Screen Resolutions</h3>
|
||||
@if ($resolutions->isNotEmpty())
|
||||
<div class="overflow-x-auto">
|
||||
@@ -91,7 +129,7 @@
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500 py-4 text-center">No screen resolution data available.</p>
|
||||
<x-empty-state message="No screen resolution data available." />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Exit Pages">
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
@if ($pages->isEmpty())
|
||||
<div class="px-6 py-12 text-center text-sm text-gray-500">No exit page data available for this period.</div>
|
||||
<x-empty-state message="No exit page data available for this period." />
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Landing Pages">
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
@if ($pages->isEmpty())
|
||||
<div class="px-6 py-12 text-center text-sm text-gray-500">No landing page data available for this period.</div>
|
||||
<x-empty-state message="No landing page data available for this period." />
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Pages Report">
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
@if ($pages->isEmpty())
|
||||
<div class="px-6 py-12 text-center text-sm text-gray-500">No page view data available for this period.</div>
|
||||
@else
|
||||
@if ($pages->isEmpty())
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<x-empty-state message="No page view data available for this period." />
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$chartPages = $pages->take(10);
|
||||
$chartLabels = $chartPages->pluck('path')->map(fn ($p) => strlen($p) > 30 ? substr($p, 0, 27) . '...' : $p)->values()->toArray();
|
||||
$chartData = $chartPages->pluck('views')->values()->toArray();
|
||||
@endphp
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-sm font-semibold text-gray-900">Top 10 Pages by Views</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div style="height: 280px;"><canvas id="pages-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
@@ -26,7 +43,45 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-slot name="scripts">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
new Chart(document.getElementById('pages-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: @json($chartLabels),
|
||||
datasets: [{
|
||||
label: 'Views',
|
||||
data: @json($chartData),
|
||||
backgroundColor: ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#6366f1', '#10b981', '#f59e0b'],
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17, 24, 39, 0.9)',
|
||||
titleColor: '#f9fafb',
|
||||
bodyColor: '#e5e7eb',
|
||||
borderColor: 'rgba(75, 85, 99, 0.5)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { color: 'rgba(0,0,0,0.05)' } },
|
||||
y: { grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</x-slot>
|
||||
@endif
|
||||
|
||||
</x-report-layout>
|
||||
|
||||
@@ -8,22 +8,22 @@
|
||||
$overallRate = $totalSessions > 0 ? round(($totalCompletions / $totalSessions) * 100, 2) : 0;
|
||||
@endphp
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<dt class="text-sm font-medium text-gray-500">Total Completions</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ number_format($totalCompletions) }}</dd>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<dt class="text-sm font-medium text-gray-500">Overall Conversion Rate</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-gray-900">{{ $overallRate }}%</dd>
|
||||
</div>
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-5">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<dt class="text-sm font-medium text-gray-500">Total Goal Value</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-gray-900">${{ number_format($totalValue, 2) }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Completions Chart --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Goal Completions Over Time</h3>
|
||||
<div style="height: 240px;">
|
||||
<canvas id="conversions-chart"></canvas>
|
||||
@@ -31,12 +31,11 @@
|
||||
</div>
|
||||
|
||||
{{-- Per-Goal Table --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="bg-white rounded-xl border border-gray-200">
|
||||
@if ($goals->isEmpty())
|
||||
<div class="px-6 py-12 text-center text-sm text-gray-500">
|
||||
No goals configured.
|
||||
<a href="{{ route('goals.create', $website) }}" class="text-indigo-600 hover:text-indigo-800">Create one</a>.
|
||||
</div>
|
||||
<x-empty-state message="No goals configured.">
|
||||
<a href="{{ route('goals.create', $website) }}" class="text-sm text-indigo-600 hover:text-indigo-800">Create one</a>
|
||||
</x-empty-state>
|
||||
@else
|
||||
@php
|
||||
$typeLabels = [
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" :title="'Event: ' . $eventName">
|
||||
|
||||
<div class="space-y-6">
|
||||
{{-- Trending Chart --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-4">Trend</h3>
|
||||
<div style="height: 240px;">
|
||||
<canvas id="event-trend-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Detail Table --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
@if ($details->isEmpty())
|
||||
<div class="px-6 py-12 text-center text-sm text-gray-500">No detail breakdown available.</div>
|
||||
<x-empty-state message="No detail breakdown available." />
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
@@ -59,7 +57,14 @@
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17,24,39,0.95)',
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: 12, font: { size: 11 }, color: '#9ca3af' } },
|
||||
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { precision: 0, font: { size: 11 }, color: '#9ca3af' } },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Events Overview">
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
@if ($events->isEmpty())
|
||||
<div class="px-6 py-12 text-center text-sm text-gray-500">No events tracked for this period.</div>
|
||||
<x-empty-state message="No events tracked for this period." />
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
|
||||
@@ -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="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="bg-white overflow-hidden rounded-xl border border-gray-200">
|
||||
<div class="p-6">
|
||||
<form method="POST" action="{{ route('websites.store') }}">
|
||||
@csrf
|
||||
@@ -26,7 +26,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Add Website') }}</x-primary-button>
|
||||
<button type="submit" class="bg-indigo-600 rounded-lg px-4 py-2 text-white hover:bg-indigo-500 transition">
|
||||
{{ __('Add Website') }}
|
||||
</button>
|
||||
<a href="{{ route('websites.index') }}" class="text-sm text-gray-600 hover:text-gray-900">{{ __('Cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="bg-white overflow-hidden rounded-xl border border-gray-200">
|
||||
<div class="p-6">
|
||||
<form method="POST" action="{{ route('websites.update', $website) }}">
|
||||
@csrf
|
||||
@@ -34,14 +34,16 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Save Changes') }}</x-primary-button>
|
||||
<button type="submit" class="bg-indigo-600 rounded-lg px-4 py-2 text-white hover:bg-indigo-500 transition">
|
||||
{{ __('Save Changes') }}
|
||||
</button>
|
||||
<a href="{{ route('dashboard.show', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">{{ __('Cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="mt-6 bg-red-50 overflow-hidden rounded-xl border border-red-200">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-red-600 mb-4">{{ __('Danger Zone') }}</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">{{ __('Deleting a website will permanently remove all its analytics data. This action cannot be undone.') }}</p>
|
||||
|
||||
@@ -1,64 +1,55 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Websites') }}
|
||||
</h2>
|
||||
<a href="{{ route('websites.create') }}" class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
{{ __('Add Website') }}
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Websites</h2>
|
||||
<a href="{{ route('websites.create') }}" class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
|
||||
Add Website
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="py-10">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
@if (session('success'))
|
||||
<div class="mb-4 rounded-md bg-green-50 p-4">
|
||||
<p class="text-sm font-medium text-green-800">{{ session('success') }}</p>
|
||||
<div class="mb-6 rounded-xl bg-emerald-50 border border-emerald-200 p-4">
|
||||
<p class="text-sm font-medium text-emerald-800">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($websites->isEmpty())
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900">{{ __('No websites') }}</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ __('Get started by adding your first website.') }}</p>
|
||||
<div class="mt-6">
|
||||
<a href="{{ route('websites.create') }}" class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700">
|
||||
{{ __('Add Website') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-16 text-center">
|
||||
<svg class="mx-auto h-14 w-14 text-gray-300" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-semibold text-gray-900">No websites yet</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by adding your first website to track.</p>
|
||||
<div class="mt-6">
|
||||
<a href="{{ route('websites.create') }}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-indigo-600 rounded-lg font-medium text-sm text-white hover:bg-indigo-500 transition">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
|
||||
Add Website
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($websites as $website)
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
||||
<a href="{{ route('dashboard.show', $website) }}" class="hover:text-indigo-600">
|
||||
{{ $website->name }}
|
||||
</a>
|
||||
</h3>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {{ $website->is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' }}">
|
||||
{{ $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>
|
||||
<a href="{{ route('dashboard.show', $website) }}" class="group bg-white rounded-xl border border-gray-200 p-6 hover:border-indigo-300 hover:shadow-md transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-gray-900 truncate group-hover:text-indigo-600 transition-colors">{{ $website->name }}</h3>
|
||||
<p class="text-sm text-gray-500 mt-0.5">{{ $website->domain }}</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium {{ $website->is_active ? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200' : 'bg-gray-50 text-gray-500 ring-1 ring-gray-200' }}">
|
||||
<span class="w-1.5 h-1.5 rounded-full {{ $website->is_active ? 'bg-emerald-500' : 'bg-gray-400' }}"></span>
|
||||
{{ $website->is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 font-mono mt-2">{{ $website->tracking_id }}</p>
|
||||
<div class="mt-4 pt-3 border-t border-gray-100 flex items-center gap-4 text-sm" onclick="event.stopPropagation();">
|
||||
<span class="text-indigo-600 group-hover:text-indigo-700 font-medium">Dashboard →</span>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
Reference in New Issue
Block a user