first commit from hostslim
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.tabular-nums { font-variant-numeric: tabular-nums; }
|
||||
@@ -0,0 +1,7 @@
|
||||
import './bootstrap';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
Alpine.start();
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
@@ -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,27 @@
|
||||
<x-guest-layout>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('password.confirm') }}">
|
||||
@csrf
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
|
||||
<x-text-input id="password" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="current-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<x-primary-button>
|
||||
{{ __('Confirm') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,25 @@
|
||||
<x-guest-layout>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
|
||||
</div>
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="mb-4" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('password.email') }}">
|
||||
@csrf
|
||||
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<x-primary-button>
|
||||
{{ __('Email Password Reset Link') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,47 @@
|
||||
<x-guest-layout>
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="mb-4" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('login') }}">
|
||||
@csrf
|
||||
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
|
||||
<x-text-input id="password" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="current-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<div class="block mt-4">
|
||||
<label for="remember_me" class="inline-flex items-center">
|
||||
<input id="remember_me" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" name="remember">
|
||||
<span class="ms-2 text-sm text-gray-600">{{ __('Remember me') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
@if (Route::has('password.request'))
|
||||
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('password.request') }}">
|
||||
{{ __('Forgot your password?') }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<x-primary-button class="ms-3">
|
||||
{{ __('Log in') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,52 @@
|
||||
<x-guest-layout>
|
||||
<form method="POST" action="{{ route('register') }}">
|
||||
@csrf
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Email Address -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
|
||||
<x-text-input id="password" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
|
||||
|
||||
<x-text-input id="password_confirmation" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password_confirmation" required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('login') }}">
|
||||
{{ __('Already registered?') }}
|
||||
</a>
|
||||
|
||||
<x-primary-button class="ms-4">
|
||||
{{ __('Register') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,39 @@
|
||||
<x-guest-layout>
|
||||
<form method="POST" action="{{ route('password.store') }}">
|
||||
@csrf
|
||||
|
||||
<!-- Password Reset Token -->
|
||||
<input type="hidden" name="token" value="{{ $request->route('token') }}">
|
||||
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
<x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
|
||||
|
||||
<x-text-input id="password_confirmation" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password_confirmation" required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<x-primary-button>
|
||||
{{ __('Reset Password') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,31 @@
|
||||
<x-guest-layout>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
|
||||
</div>
|
||||
|
||||
@if (session('status') == 'verification-link-sent')
|
||||
<div class="mb-4 font-medium text-sm text-green-600">
|
||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<form method="POST" action="{{ route('verification.send') }}">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<x-primary-button>
|
||||
{{ __('Resend Verification Email') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
|
||||
<button type="submit" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
{{ __('Log Out') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-guest-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,3 @@
|
||||
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" {{ $attributes }}>
|
||||
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,7 @@
|
||||
@props(['status'])
|
||||
|
||||
@if ($status)
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
|
||||
{{ $status }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,3 @@
|
||||
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150']) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@@ -0,0 +1,77 @@
|
||||
@props(['website', 'from', 'to'])
|
||||
|
||||
@php
|
||||
$currentRange = request('range', '7d');
|
||||
$currentRouteName = request()->route()->getName();
|
||||
$currentRouteParams = request()->route()->parameters();
|
||||
$presets = [
|
||||
'today' => 'Today',
|
||||
'7d' => 'Last 7 days',
|
||||
'30d' => 'Last 30 days',
|
||||
'90d' => 'Last 90 days',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div x-data="{ open: false, mode: '{{ $currentRange }}' }" class="relative">
|
||||
<button
|
||||
@click="open = !open"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
|
||||
</svg>
|
||||
<span>{{ $presets[$currentRange] ?? $from->format('M j') . ' - ' . $to->format('M j, Y') }}</span>
|
||||
<svg class="h-4 w-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>
|
||||
|
||||
<div
|
||||
x-show="open"
|
||||
@click.away="open = false"
|
||||
x-transition
|
||||
class="absolute right-0 z-10 mt-2 w-72 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div class="p-2">
|
||||
@foreach ($presets as $key => $label)
|
||||
<a
|
||||
href="{{ route($currentRouteName, $currentRouteParams + ['range' => $key]) }}"
|
||||
class="block rounded-md px-3 py-2 text-sm {{ $currentRange === $key ? 'bg-indigo-50 text-indigo-700 font-medium' : 'text-gray-700 hover:bg-gray-100' }}"
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
<div class="my-2 border-t border-gray-100"></div>
|
||||
|
||||
<form action="{{ route($currentRouteName, $currentRouteParams) }}" method="GET" class="px-3 py-2 space-y-3">
|
||||
<input type="hidden" name="range" value="custom">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">From</label>
|
||||
<input
|
||||
type="date"
|
||||
name="from"
|
||||
value="{{ $from->toDateString() }}"
|
||||
class="block w-full rounded-md 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">To</label>
|
||||
<input
|
||||
type="date"
|
||||
name="to"
|
||||
value="{{ $to->toDateString() }}"
|
||||
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out']) }}>{{ $slot }}</a>
|
||||
@@ -0,0 +1,35 @@
|
||||
@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white'])
|
||||
|
||||
@php
|
||||
$alignmentClasses = match ($align) {
|
||||
'left' => 'ltr:origin-top-left rtl:origin-top-right start-0',
|
||||
'top' => 'origin-top',
|
||||
default => 'ltr:origin-top-right rtl:origin-top-left end-0',
|
||||
};
|
||||
|
||||
$width = match ($width) {
|
||||
'48' => 'w-48',
|
||||
default => $width,
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
|
||||
<div @click="open = ! open">
|
||||
{{ $trigger }}
|
||||
</div>
|
||||
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
|
||||
style="display: none;"
|
||||
@click="open = false">
|
||||
<div class="rounded-md ring-1 ring-black ring-opacity-5 {{ $contentClasses }}">
|
||||
{{ $content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,9 @@
|
||||
@props(['messages'])
|
||||
|
||||
@if ($messages)
|
||||
<ul {{ $attributes->merge(['class' => 'text-sm text-red-600 space-y-1']) }}>
|
||||
@foreach ((array) $messages as $message)
|
||||
<li>{{ $message }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
@@ -0,0 +1,5 @@
|
||||
@props(['value'])
|
||||
|
||||
<label {{ $attributes->merge(['class' => 'block font-medium text-sm text-gray-700']) }}>
|
||||
{{ $value ?? $slot }}
|
||||
</label>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,78 @@
|
||||
@props([
|
||||
'name',
|
||||
'show' => false,
|
||||
'maxWidth' => '2xl'
|
||||
])
|
||||
|
||||
@php
|
||||
$maxWidth = [
|
||||
'sm' => 'sm:max-w-sm',
|
||||
'md' => 'sm:max-w-md',
|
||||
'lg' => 'sm:max-w-lg',
|
||||
'xl' => 'sm:max-w-xl',
|
||||
'2xl' => 'sm:max-w-2xl',
|
||||
][$maxWidth];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
show: @js($show),
|
||||
focusables() {
|
||||
// All focusable element types...
|
||||
let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
|
||||
return [...$el.querySelectorAll(selector)]
|
||||
// All non-disabled elements...
|
||||
.filter(el => ! el.hasAttribute('disabled'))
|
||||
},
|
||||
firstFocusable() { return this.focusables()[0] },
|
||||
lastFocusable() { return this.focusables().slice(-1)[0] },
|
||||
nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
|
||||
prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
|
||||
nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
|
||||
prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
|
||||
}"
|
||||
x-init="$watch('show', value => {
|
||||
if (value) {
|
||||
document.body.classList.add('overflow-y-hidden');
|
||||
{{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
|
||||
} else {
|
||||
document.body.classList.remove('overflow-y-hidden');
|
||||
}
|
||||
})"
|
||||
x-on:open-modal.window="$event.detail == '{{ $name }}' ? show = true : null"
|
||||
x-on:close-modal.window="$event.detail == '{{ $name }}' ? show = false : null"
|
||||
x-on:close.stop="show = false"
|
||||
x-on:keydown.escape.window="show = false"
|
||||
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
|
||||
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
|
||||
x-show="show"
|
||||
class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
|
||||
style="display: {{ $show ? 'block' : 'none' }};"
|
||||
>
|
||||
<div
|
||||
x-show="show"
|
||||
class="fixed inset-0 transform transition-all"
|
||||
x-on:click="show = false"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="show"
|
||||
class="mb-6 bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
@props(['active'])
|
||||
|
||||
@php
|
||||
$classes = ($active ?? false)
|
||||
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
|
||||
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out';
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
</a>
|
||||
@@ -0,0 +1,3 @@
|
||||
<button {{ $attributes->merge(['type' => 'submit', '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']) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@@ -0,0 +1,11 @@
|
||||
@props(['website', 'from', 'to', 'title'])
|
||||
|
||||
<x-analytics-layout :website="$website" :from="$from" :to="$to" :title="$title">
|
||||
{{ $slot }}
|
||||
|
||||
@isset($scripts)
|
||||
@push('scripts')
|
||||
{{ $scripts }}
|
||||
@endpush
|
||||
@endisset
|
||||
</x-analytics-layout>
|
||||
@@ -0,0 +1,24 @@
|
||||
@props(['headers', 'empty' => 'No data available for this period.'])
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
@foreach ($headers as $header)
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider {{ $loop->first ? '' : 'text-right' }}">
|
||||
{{ $header }}
|
||||
</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{{ $slot }}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (trim((string) $slot) === '')
|
||||
<div class="px-6 py-12 text-center text-sm text-gray-500">{{ $empty }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
@props(['active'])
|
||||
|
||||
@php
|
||||
$classes = ($active ?? false)
|
||||
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out'
|
||||
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out';
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
</a>
|
||||
@@ -0,0 +1,3 @@
|
||||
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 transition ease-in-out duration-150']) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@@ -0,0 +1,3 @@
|
||||
@props(['disabled' => false])
|
||||
|
||||
<input @disabled($disabled) {{ $attributes->merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) }}>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,17 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Dashboard') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
{{ __("You're logged in!") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,222 @@
|
||||
<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="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>
|
||||
|
||||
{{-- 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>
|
||||
|
||||
{{-- Two-column: Top Pages + Top Sources --}}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
{{-- Top pages --}}
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Top Pages</h3>
|
||||
<a href="{{ route('reports.pages', [$website] + request()->only(['range', 'from', 'to'])) }}" class="text-sm text-indigo-600 hover:text-indigo-800">View all</a>
|
||||
</div>
|
||||
@if ($topPages->isEmpty())
|
||||
<x-empty-state message="No page data yet." compact />
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@php $maxViews = $topPages->max('views') ?: 1; @endphp
|
||||
@foreach ($topPages as $page)
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-700 truncate font-mono text-xs" title="{{ $page->path }}">{{ Str::limit($page->path, 50) }}</span>
|
||||
<span class="text-gray-500 tabular-nums ml-2 shrink-0">{{ number_format($page->views) }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-1.5">
|
||||
<div class="bg-indigo-500 h-1.5 rounded-full transition-all" style="width: {{ ($page->views / $maxViews) * 100 }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Top sources --}}
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold text-gray-900">Traffic Sources</h3>
|
||||
<a href="{{ route('reports.sources', [$website] + request()->only(['range', 'from', 'to'])) }}" class="text-sm text-indigo-600 hover:text-indigo-800">View all</a>
|
||||
</div>
|
||||
@if ($topSources->isEmpty())
|
||||
<x-empty-state message="No traffic source data yet." compact />
|
||||
@else
|
||||
<div class="flex gap-6">
|
||||
<div class="w-40 h-40 shrink-0"><canvas id="sources-donut"></canvas></div>
|
||||
<div class="flex-1 space-y-2 min-w-0">
|
||||
@php $maxSess = $topSources->max('sessions') ?: 1; @endphp
|
||||
@foreach ($topSources as $src)
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-700 truncate">{{ $src->source }}</span>
|
||||
<span class="text-gray-500 tabular-nums ml-2 shrink-0">{{ number_format($src->sessions) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 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);
|
||||
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 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Sources donut
|
||||
const sourcesCanvas = document.getElementById('sources-donut');
|
||||
if (sourcesCanvas) {
|
||||
const srcData = @json($topSources);
|
||||
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
new Chart(sourcesCanvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: srcData.map(s => s.source),
|
||||
datasets: [{ data: srcData.map(s => s.sessions), backgroundColor: colors.slice(0, srcData.length), borderWidth: 0, spacing: 2 }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
cutout: '65%',
|
||||
plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(17,24,39,0.95)', padding: 10, cornerRadius: 8 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Goal sparkline
|
||||
const goalCanvas = document.getElementById('goal-sparkline');
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function realtimeWidget(url, initial) {
|
||||
return {
|
||||
count: initial,
|
||||
start() {
|
||||
setInterval(() => {
|
||||
fetch(url).then(r => r.json()).then(d => { this.count = d.active; }).catch(() => {});
|
||||
}, 30000);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</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>
|
||||
@@ -0,0 +1,95 @@
|
||||
@php
|
||||
$goalType = old('type', $goal->type ?? '');
|
||||
$conditions = old('conditions', $goal->conditions ?? []);
|
||||
@endphp
|
||||
|
||||
<div x-data="{ goalType: '{{ $goalType }}' }" class="space-y-6">
|
||||
<div>
|
||||
<x-input-label for="name" value="Goal Name" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $goal->name ?? '')" required autofocus placeholder="e.g. Completed Signup" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('name')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="type" value="Goal Type" />
|
||||
<select id="type" name="type" x-model="goalType" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required>
|
||||
<option value="">Select a type...</option>
|
||||
<option value="url_destination">URL Destination</option>
|
||||
<option value="event_match">Event Match</option>
|
||||
<option value="duration">Session Duration</option>
|
||||
<option value="pages_per_session">Pages per Session</option>
|
||||
</select>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('type')" />
|
||||
</div>
|
||||
|
||||
{{-- URL Destination fields --}}
|
||||
<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" />
|
||||
<x-text-input id="conditions_path" name="conditions[path]" type="text" class="mt-1 block w-full" :value="$conditions['path'] ?? ''" placeholder="/thank-you" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('conditions.path')" />
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="conditions_match_type" value="Match Type" />
|
||||
<select id="conditions_match_type" name="conditions[match_type]" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="exact" @selected(($conditions['match_type'] ?? '') === 'exact')>Exact match</option>
|
||||
<option value="starts_with" @selected(($conditions['match_type'] ?? '') === 'starts_with')>Starts with</option>
|
||||
<option value="contains" @selected(($conditions['match_type'] ?? '') === 'contains')>Contains</option>
|
||||
<option value="regex" @selected(($conditions['match_type'] ?? '') === 'regex')>Regex</option>
|
||||
</select>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('conditions.match_type')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Event Match fields --}}
|
||||
<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" />
|
||||
<x-text-input id="conditions_event_name" name="conditions[event_name]" type="text" class="mt-1 block w-full" :value="$conditions['event_name'] ?? ''" placeholder="signup" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('conditions.event_name')" />
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="conditions_event_category" value="Event Category (optional)" />
|
||||
<x-text-input id="conditions_event_category" name="conditions[event_category]" type="text" class="mt-1 block w-full" :value="$conditions['event_category'] ?? ''" placeholder="conversion" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('conditions.event_category')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Duration fields --}}
|
||||
<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" />
|
||||
<x-text-input id="conditions_seconds" name="conditions[seconds]" type="number" class="mt-1 block w-full" :value="$conditions['seconds'] ?? ''" min="1" placeholder="180" />
|
||||
<p class="mt-1 text-xs text-gray-500">Goal completes when a session lasts at least this many seconds.</p>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('conditions.seconds')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Pages per Session fields --}}
|
||||
<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" />
|
||||
<x-text-input id="conditions_page_count" name="conditions[page_count]" type="number" class="mt-1 block w-full" :value="$conditions['page_count'] ?? ''" min="1" placeholder="5" />
|
||||
<p class="mt-1 text-xs text-gray-500">Goal completes when a session views at least this many pages.</p>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('conditions.page_count')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="default_value" value="Goal Value (optional)" />
|
||||
<x-text-input id="default_value" name="default_value" type="number" step="0.01" class="mt-1 block w-full" :value="old('default_value', $goal->default_value ?? '')" placeholder="0.00" />
|
||||
<p class="mt-1 text-xs text-gray-500">Monetary value assigned to each conversion.</p>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('default_value')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="is_active" class="inline-flex items-center">
|
||||
<input id="is_active" type="checkbox" name="is_active" value="1" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" {{ old('is_active', $goal->is_active ?? true) ? 'checked' : '' }}>
|
||||
<span class="ms-2 text-sm text-gray-600">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<x-analytics-layout :website="$website" title="Create Goal" :showDatePicker="false">
|
||||
|
||||
<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">
|
||||
<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-analytics-layout>
|
||||
@@ -0,0 +1,18 @@
|
||||
<x-analytics-layout :website="$website" :title="'Edit Goal: ' . $goal->name" :showDatePicker="false">
|
||||
|
||||
<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">
|
||||
<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-analytics-layout>
|
||||
@@ -0,0 +1,74 @@
|
||||
<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="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="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>
|
||||
</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>
|
||||
<td class="px-6 py-4 text-sm font-medium text-gray-900">{{ $goal->name }}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{{ $typeLabels[$goal->type] ?? $goal->type }}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{{ $goal->default_value ? number_format($goal->default_value, 2) : '—' }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {{ $goal->is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' }}">
|
||||
{{ $goal->is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-sm space-x-3">
|
||||
<a href="{{ route('goals.edit', [$website, $goal]) }}" class="text-indigo-600 hover:text-indigo-800">Edit</a>
|
||||
<form method="POST" action="{{ route('goals.destroy', [$website, $goal]) }}" class="inline" onsubmit="return confirm('Delete this goal?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:text-red-800">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-analytics-layout>
|
||||
@@ -0,0 +1,33 @@
|
||||
<!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>{{ 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">
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
@include('layouts.navigation')
|
||||
|
||||
@isset($header)
|
||||
<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
|
||||
|
||||
<main>
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!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>{{ config('app.name', 'Laravel') }}</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" />
|
||||
|
||||
<!-- Scripts -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="font-sans text-gray-900 antialiased">
|
||||
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
|
||||
<div>
|
||||
<a href="/">
|
||||
<x-application-logo class="w-20 h-20 fill-current text-gray-500" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
<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 items-center gap-8">
|
||||
<a href="{{ route('websites.index') }}" class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900">{{ config('app.name', 'Analytics') }}</span>
|
||||
</a>
|
||||
|
||||
<div class="hidden sm:flex items-center gap-1">
|
||||
<a href="{{ route('websites.index') }}" class="px-3 py-2 rounded-lg text-sm font-medium {{ request()->routeIs('websites.*') ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50' }} transition">
|
||||
Websites
|
||||
</a>
|
||||
<a href="{{ route('api-tokens.index') }}" class="px-3 py-2 rounded-lg text-sm font-medium {{ request()->routeIs('api-tokens.*') ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50' }} transition">
|
||||
API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex items-center">
|
||||
<x-dropdown align="right" width="48">
|
||||
<x-slot name="trigger">
|
||||
<button class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">
|
||||
<span class="flex items-center justify-center w-7 h-7 rounded-full bg-indigo-100 text-xs font-semibold text-indigo-700">
|
||||
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
|
||||
</span>
|
||||
<span>{{ Auth::user()->name }}</span>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</x-slot>
|
||||
<x-slot name="content">
|
||||
<x-dropdown-link :href="route('profile.edit')">Profile</x-dropdown-link>
|
||||
<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>
|
||||
</form>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="-me-2 flex items-center sm:hidden">
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
@@ -0,0 +1,29 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Profile') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('profile.partials.update-profile-information-form')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('profile.partials.update-password-form')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('profile.partials.delete-user-form')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,55 @@
|
||||
<section class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{{ __('Delete Account') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<x-danger-button
|
||||
x-data=""
|
||||
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
|
||||
>{{ __('Delete Account') }}</x-danger-button>
|
||||
|
||||
<x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
|
||||
<form method="post" action="{{ route('profile.destroy') }}" class="p-6">
|
||||
@csrf
|
||||
@method('delete')
|
||||
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{{ __('Are you sure you want to delete your account?') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
|
||||
|
||||
<x-text-input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
|
||||
<x-input-error :messages="$errors->userDeletion->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button x-on:click="$dispatch('close')">
|
||||
{{ __('Cancel') }}
|
||||
</x-secondary-button>
|
||||
|
||||
<x-danger-button class="ms-3">
|
||||
{{ __('Delete Account') }}
|
||||
</x-danger-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
||||
</section>
|
||||
@@ -0,0 +1,48 @@
|
||||
<section>
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{{ __('Update Password') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ __('Ensure your account is using a long, random password to stay secure.') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form method="post" action="{{ route('password.update') }}" class="mt-6 space-y-6">
|
||||
@csrf
|
||||
@method('put')
|
||||
|
||||
<div>
|
||||
<x-input-label for="update_password_current_password" :value="__('Current Password')" />
|
||||
<x-text-input id="update_password_current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
|
||||
<x-input-error :messages="$errors->updatePassword->get('current_password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="update_password_password" :value="__('New Password')" />
|
||||
<x-text-input id="update_password_password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->updatePassword->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="update_password_password_confirmation" :value="__('Confirm Password')" />
|
||||
<x-text-input id="update_password_password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->updatePassword->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Save') }}</x-primary-button>
|
||||
|
||||
@if (session('status') === 'password-updated')
|
||||
<p
|
||||
x-data="{ show: true }"
|
||||
x-show="show"
|
||||
x-transition
|
||||
x-init="setTimeout(() => show = false, 2000)"
|
||||
class="text-sm text-gray-600"
|
||||
>{{ __('Saved.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,64 @@
|
||||
<section>
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{{ __('Profile Information') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ __("Update your account's profile information and email address.") }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form id="send-verification" method="post" action="{{ route('verification.send') }}">
|
||||
@csrf
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ route('profile.update') }}" class="mt-6 space-y-6">
|
||||
@csrf
|
||||
@method('patch')
|
||||
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('name')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full" :value="old('email', $user->email)" required autocomplete="username" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('email')" />
|
||||
|
||||
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
|
||||
<div>
|
||||
<p class="text-sm mt-2 text-gray-800">
|
||||
{{ __('Your email address is unverified.') }}
|
||||
|
||||
<button form="send-verification" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
{{ __('Click here to re-send the verification email.') }}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@if (session('status') === 'verification-link-sent')
|
||||
<p class="mt-2 font-medium text-sm text-green-600">
|
||||
{{ __('A new verification link has been sent to your email address.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Save') }}</x-primary-button>
|
||||
|
||||
@if (session('status') === 'profile-updated')
|
||||
<p
|
||||
x-data="{ show: true }"
|
||||
x-show="show"
|
||||
x-transition
|
||||
x-init="setTimeout(() => show = false, 2000)"
|
||||
class="text-sm text-gray-600"
|
||||
>{{ __('Saved.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,36 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Campaigns (UTM)">
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-200">
|
||||
@if ($campaigns->isEmpty())
|
||||
<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-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-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>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -0,0 +1,32 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Referrers">
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
@if ($referrers->isEmpty())
|
||||
<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">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Referrer Domain</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($referrers as $row)
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 font-medium">{{ $row->referrer_domain }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->sessions) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -0,0 +1,75 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Traffic Sources">
|
||||
|
||||
@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-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-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>
|
||||
</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>
|
||||
@@ -0,0 +1,187 @@
|
||||
<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">
|
||||
{{-- 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())
|
||||
<x-empty-state icon="globe" message="No geographic data available for this period." />
|
||||
@else
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div style="height: 320px;"><canvas id="countriesChart"></canvas></div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Country</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@php $totalCountry = $countries->sum('visitors'); @endphp
|
||||
@foreach ($countries as $row)
|
||||
<tr>
|
||||
<td class="px-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>
|
||||
|
||||
{{-- 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())
|
||||
<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-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-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>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -0,0 +1,25 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Language Report">
|
||||
|
||||
<div class="bg-white 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
|
||||
<div class="space-y-3">
|
||||
@foreach ($languages as $row)
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-900">{{ $row->language }}</span>
|
||||
<span class="text-gray-500">{{ number_format($row->visitors) }} ({{ $total > 0 ? round(($row->visitors / $total) * 100, 1) : 0 }}%)</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-2">
|
||||
<div class="bg-indigo-500 h-2 rounded-full" style="width: {{ $total > 0 ? ($row->visitors / $total) * 100 : 0 }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<x-empty-state message="No language data available for this period." />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -0,0 +1,75 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="New vs Returning Visitors">
|
||||
|
||||
<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)
|
||||
<x-empty-state message="No visitor data available for this period." />
|
||||
@else
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="h-64">
|
||||
<canvas id="newVsReturningChart"></canvas>
|
||||
</div>
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div class="rounded-xl border-2 border-indigo-200 bg-indigo-50 p-6 text-center">
|
||||
<div class="text-3xl font-bold text-indigo-700">{{ $data['new_pct'] }}%</div>
|
||||
<div class="text-sm font-medium text-indigo-600 mt-1">New Visitors</div>
|
||||
<div class="text-xs text-indigo-400 mt-0.5">{{ number_format($data['new']) }} visitors</div>
|
||||
</div>
|
||||
<div class="rounded-xl border-2 border-emerald-200 bg-emerald-50 p-6 text-center">
|
||||
<div class="text-3xl font-bold text-emerald-700">{{ $data['returning_pct'] }}%</div>
|
||||
<div class="text-sm font-medium text-emerald-600 mt-1">Returning Visitors</div>
|
||||
<div class="text-xs text-emerald-400 mt-0.5">{{ number_format($data['returning']) }} visitors</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 max-w-lg mx-auto">
|
||||
<div class="w-full bg-gray-100 rounded-full h-4 overflow-hidden flex">
|
||||
<div class="bg-indigo-500 h-4" style="width: {{ $data['new_pct'] }}%"></div>
|
||||
<div class="bg-emerald-500 h-4" style="width: {{ $data['returning_pct'] }}%"></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>New</span>
|
||||
<span>Returning</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -0,0 +1,137 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Technology Report">
|
||||
|
||||
<x-slot name="scripts">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const devicesData = @json($devices->map(fn($d) => ['label' => $d->device_type ?? 'Unknown', 'visitors' => $d->visitors])->values());
|
||||
if (devicesData.length > 0) {
|
||||
const ctx = document.getElementById('deviceChart');
|
||||
if (ctx) {
|
||||
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: devicesData.map(d => d.label),
|
||||
datasets: [{
|
||||
data: devicesData.map(d => d.visitors),
|
||||
backgroundColor: colors.slice(0, devicesData.length),
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17,24,39,0.95)',
|
||||
padding: 10,
|
||||
cornerRadius: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="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-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
|
||||
<x-empty-state message="No device data available." />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<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
|
||||
<div class="space-y-3">
|
||||
@foreach ($browsers as $row)
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-900">{{ $row->browser }}</span>
|
||||
<span class="text-gray-500">{{ number_format($row->visitors) }} ({{ $totalBrowsers > 0 ? round(($row->visitors / $totalBrowsers) * 100, 1) : 0 }}%)</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-2">
|
||||
<div class="bg-indigo-500 h-2 rounded-full" style="width: {{ $totalBrowsers > 0 ? ($row->visitors / $totalBrowsers) * 100 : 0 }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<x-empty-state message="No browser data available." />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<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
|
||||
<div class="space-y-3">
|
||||
@foreach ($operatingSystems as $row)
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-900">{{ $row->os }}</span>
|
||||
<span class="text-gray-500">{{ number_format($row->visitors) }} ({{ $totalOS > 0 ? round(($row->visitors / $totalOS) * 100, 1) : 0 }}%)</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-2">
|
||||
<div class="bg-emerald-500 h-2 rounded-full" style="width: {{ $totalOS > 0 ? ($row->visitors / $totalOS) * 100 : 0 }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<x-empty-state message="No OS data available." />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Resolution</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Visitors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($resolutions as $row)
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 font-mono">{{ $row->screen_resolution }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->visitors) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<x-empty-state message="No screen resolution data available." />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -0,0 +1,28 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Exit Pages">
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
@if ($pages->isEmpty())
|
||||
<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">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Exit Page</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Exits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($pages as $row)
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 font-mono max-w-md truncate">{{ $row->path }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->exits) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -0,0 +1,32 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Landing Pages">
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
@if ($pages->isEmpty())
|
||||
<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">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry Page</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Sessions</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Bounce Rate</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($pages as $row)
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 font-mono max-w-md truncate">{{ $row->path }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->sessions) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->sessions > 0 ? round(($row->bounces / $row->sessions) * 100, 1) : 0 }}%</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ gmdate('i:s', (int) round($row->avg_duration)) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -0,0 +1,87 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Pages Report">
|
||||
|
||||
@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">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Page Path</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Views</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique Views</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Avg. Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($pages as $row)
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 font-mono max-w-md truncate">{{ $row->path }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->views) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->unique_views) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->avg_time !== null ? gmdate('i:s', (int) round($row->avg_time)) : '—' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,112 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Conversions">
|
||||
|
||||
<div class="space-y-6">
|
||||
{{-- Summary Cards --}}
|
||||
@php
|
||||
$totalCompletions = $goals->sum('completions');
|
||||
$totalValue = $goals->sum('total_value');
|
||||
$overallRate = $totalSessions > 0 ? round(($totalCompletions / $totalSessions) * 100, 2) : 0;
|
||||
@endphp
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<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 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 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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Per-Goal Table --}}
|
||||
<div class="bg-white rounded-xl border border-gray-200">
|
||||
@if ($goals->isEmpty())
|
||||
<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 = [
|
||||
'url_destination' => 'URL Destination',
|
||||
'event_match' => 'Event Match',
|
||||
'duration' => 'Duration',
|
||||
'pages_per_session' => 'Pages / Session',
|
||||
];
|
||||
@endphp
|
||||
<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">Goal</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">Completions</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Conversion Rate</th>
|
||||
<th class="px-6 py-3 text-right 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@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-900 text-right">{{ number_format($goal->completions) }}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900 text-right">{{ $goal->conversion_rate }}%</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900 text-right">{{ $goal->total_value ? '$' . number_format($goal->total_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>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-slot name="scripts">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const raw = @json($chartData);
|
||||
new Chart(document.getElementById('conversions-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: raw.map(d => d.period),
|
||||
datasets: [{
|
||||
label: 'Completions',
|
||||
data: raw.map(d => d.completions),
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.7)',
|
||||
borderRadius: 4,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { maxTicksLimit: 12, font: { size: 11 }, color: '#9ca3af' } },
|
||||
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { precision: 0, font: { size: 11 }, color: '#9ca3af' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</x-slot>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -0,0 +1,78 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" :title="'Event: ' . $eventName">
|
||||
|
||||
<div class="space-y-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>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
@if ($details->isEmpty())
|
||||
<x-empty-state message="No detail breakdown 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">Action</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Label</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Count</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Total Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($details as $row)
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900">{{ $row->action ?? '—' }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->label ?? '—' }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->total) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->total_value ? number_format($row->total_value, 2) : '—' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-slot name="scripts">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const raw = @json($trending);
|
||||
new Chart(document.getElementById('event-trend-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: raw.map(d => d.period),
|
||||
datasets: [{
|
||||
label: '{{ $eventName }}',
|
||||
data: raw.map(d => d.total),
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.7)',
|
||||
borderRadius: 4,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
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' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</x-slot>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -0,0 +1,38 @@
|
||||
<x-report-layout :website="$website" :from="$from" :to="$to" title="Events Overview">
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
@if ($events->isEmpty())
|
||||
<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">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique Visitors</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Total Value</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($events as $row)
|
||||
<tr>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 font-medium">{{ $row->name }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-500">{{ $row->category ?? '—' }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->total) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ number_format($row->unique_visitors) }}</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ $row->total_value ? number_format($row->total_value, 2) : '—' }}</td>
|
||||
<td class="px-6 py-3 text-sm text-right">
|
||||
<a href="{{ route('reports.event-detail', [$website, $row->name] + request()->only(['range', 'from', 'to'])) }}" class="text-indigo-600 hover:text-indigo-800">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</x-report-layout>
|
||||
@@ -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,244 @@
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
var TRACKING_ID = "{{ $trackingId }}";
|
||||
var COLLECT_URL = "{{ $collectUrl }}";
|
||||
var SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||
var STORAGE_PREFIX = "_ta_";
|
||||
|
||||
// ---- Utilities ----
|
||||
|
||||
function generateId() {
|
||||
try {
|
||||
var arr = new Uint8Array(16);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, function(b) { return b.toString(16).padStart(2, "0"); }).join("");
|
||||
} catch(e) {
|
||||
return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, function(c) {
|
||||
var r = Math.random() * 16 | 0;
|
||||
return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getStore(key) {
|
||||
try { return localStorage.getItem(STORAGE_PREFIX + key); } catch(e) { return null; }
|
||||
}
|
||||
|
||||
function setStore(key, value) {
|
||||
try { localStorage.setItem(STORAGE_PREFIX + key, value); } catch(e) {}
|
||||
}
|
||||
|
||||
function now() { return Date.now(); }
|
||||
|
||||
// ---- Visitor ID ----
|
||||
|
||||
function getVisitorId() {
|
||||
var vid = getStore("vid");
|
||||
if (!vid) {
|
||||
vid = generateId();
|
||||
setStore("vid", vid);
|
||||
}
|
||||
return vid;
|
||||
}
|
||||
|
||||
// ---- Session ID ----
|
||||
|
||||
function getSessionId() {
|
||||
var sid = getStore("sid");
|
||||
var ts = parseInt(getStore("sid_ts"), 10) || 0;
|
||||
|
||||
if (!sid || (now() - ts) > SESSION_TIMEOUT) {
|
||||
sid = generateId();
|
||||
setStore("sid", sid);
|
||||
setStore("is_new_session", "1");
|
||||
}
|
||||
|
||||
setStore("sid_ts", String(now()));
|
||||
return sid;
|
||||
}
|
||||
|
||||
function isNewSession() {
|
||||
var val = getStore("is_new_session");
|
||||
setStore("is_new_session", "0");
|
||||
return val === "1";
|
||||
}
|
||||
|
||||
// ---- Device / Browser Detection ----
|
||||
|
||||
function detectDevice() {
|
||||
var ua = navigator.userAgent || "";
|
||||
if (/Mobi|Android.*Mobile|iPhone|iPod/i.test(ua)) return "mobile";
|
||||
if (/iPad|Android(?!.*Mobile)|Tablet/i.test(ua)) return "tablet";
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
function detectBrowser() {
|
||||
var ua = navigator.userAgent || "";
|
||||
var m;
|
||||
|
||||
if ((m = ua.match(/Edg(?:e|A|iOS)?\/(\S+)/))) return { name: "Edge", version: m[1] };
|
||||
if ((m = ua.match(/OPR\/(\S+)/))) return { name: "Opera", version: m[1] };
|
||||
if ((m = ua.match(/Chrome\/(\S+)/)) && !/Edg/.test(ua)) return { name: "Chrome", version: m[1] };
|
||||
if ((m = ua.match(/Safari\/(\S+)/)) && !/Chrome/.test(ua)) {
|
||||
var v = ua.match(/Version\/(\S+)/);
|
||||
return { name: "Safari", version: v ? v[1] : m[1] };
|
||||
}
|
||||
if ((m = ua.match(/Firefox\/(\S+)/))) return { name: "Firefox", version: m[1] };
|
||||
|
||||
return { name: "Other", version: "" };
|
||||
}
|
||||
|
||||
function detectOS() {
|
||||
var ua = navigator.userAgent || "";
|
||||
var m;
|
||||
|
||||
if ((m = ua.match(/Windows NT (\d+\.\d+)/))) return { name: "Windows", version: m[1] };
|
||||
if ((m = ua.match(/Mac OS X (\d+[._]\d+[._]?\d*)/))) return { name: "macOS", version: m[1].replace(/_/g, ".") };
|
||||
if ((m = ua.match(/Android (\d+(\.\d+)*)/))) return { name: "Android", version: m[1] };
|
||||
if (/iPhone|iPad|iPod/.test(ua)) {
|
||||
m = ua.match(/OS (\d+[_]\d+[_]?\d*)/);
|
||||
return { name: "iOS", version: m ? m[1].replace(/_/g, ".") : "" };
|
||||
}
|
||||
if (/Linux/.test(ua)) return { name: "Linux", version: "" };
|
||||
|
||||
return { name: "Other", version: "" };
|
||||
}
|
||||
|
||||
// ---- UTM Parameters ----
|
||||
|
||||
function getUtmParams() {
|
||||
var params = {};
|
||||
try {
|
||||
var sp = new URLSearchParams(window.location.search);
|
||||
["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"].forEach(function(key) {
|
||||
var val = sp.get(key);
|
||||
if (val) params[key] = val;
|
||||
});
|
||||
} catch(e) {}
|
||||
return params;
|
||||
}
|
||||
|
||||
function getReferrerDomain() {
|
||||
try {
|
||||
if (!document.referrer) return "";
|
||||
return new URL(document.referrer).hostname;
|
||||
} catch(e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Send Beacon ----
|
||||
|
||||
function send(data) {
|
||||
var vid = getVisitorId();
|
||||
var sid = getSessionId();
|
||||
var browser = detectBrowser();
|
||||
var os = detectOS();
|
||||
var utm = getUtmParams();
|
||||
|
||||
var payload = Object.assign({
|
||||
tid: TRACKING_ID,
|
||||
vid: vid,
|
||||
sid: sid,
|
||||
is_new_session: isNewSession(),
|
||||
url: window.location.href,
|
||||
path: window.location.pathname,
|
||||
title: document.title,
|
||||
referrer: document.referrer,
|
||||
ref_domain: getReferrerDomain(),
|
||||
sr: screen.width + "x" + screen.height,
|
||||
lang: (navigator.language || "").substring(0, 10),
|
||||
device: detectDevice(),
|
||||
browser: browser.name,
|
||||
browser_version: browser.version.substring(0, 20),
|
||||
os: os.name,
|
||||
os_version: os.version.substring(0, 20),
|
||||
}, utm, data);
|
||||
|
||||
var blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(COLLECT_URL, blob);
|
||||
} else {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", COLLECT_URL, true);
|
||||
xhr.setRequestHeader("Content-Type", "application/json");
|
||||
xhr.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Time on Page ----
|
||||
|
||||
var pageEnteredAt = now();
|
||||
|
||||
function getTimeOnPage() {
|
||||
return Math.round((now() - pageEnteredAt) / 1000);
|
||||
}
|
||||
|
||||
// ---- Track Page View ----
|
||||
|
||||
function trackPageView() {
|
||||
pageEnteredAt = now();
|
||||
send({ type: "pageview" });
|
||||
}
|
||||
|
||||
// ---- Track Event ----
|
||||
|
||||
function trackEvent(name, params) {
|
||||
params = params || {};
|
||||
send({
|
||||
type: "event",
|
||||
event_name: name,
|
||||
event_category: params.category || null,
|
||||
event_action: params.action || null,
|
||||
event_label: params.label || null,
|
||||
event_value: params.value != null ? params.value : null,
|
||||
event_properties: params.properties || null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Send time-on-page before leaving ----
|
||||
|
||||
function onPageLeave() {
|
||||
var top = getTimeOnPage();
|
||||
if (top > 0) {
|
||||
send({ type: "event", event_name: "page_leave", event_properties: { time_on_page: top } });
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", function() {
|
||||
if (document.visibilityState === "hidden") onPageLeave();
|
||||
});
|
||||
|
||||
// ---- SPA Support: listen for pushState / popstate ----
|
||||
|
||||
var originalPushState = history.pushState;
|
||||
history.pushState = function() {
|
||||
onPageLeave();
|
||||
originalPushState.apply(history, arguments);
|
||||
trackPageView();
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", function() {
|
||||
onPageLeave();
|
||||
trackPageView();
|
||||
});
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
window.ta = function(command, arg1, arg2) {
|
||||
switch(command) {
|
||||
case "event":
|
||||
trackEvent(arg1, arg2);
|
||||
break;
|
||||
case "pageview":
|
||||
trackPageView();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Auto-track initial page view ----
|
||||
|
||||
trackPageView();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -0,0 +1,39 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Add Website') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden rounded-xl border border-gray-200">
|
||||
<div class="p-6">
|
||||
<form method="POST" action="{{ route('websites.store') }}">
|
||||
@csrf
|
||||
|
||||
<div class="mb-6">
|
||||
<x-input-label for="name" :value="__('Website Name')" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name')" required autofocus placeholder="My Website" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('name')" />
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<x-input-label for="domain" :value="__('Domain')" />
|
||||
<x-text-input id="domain" name="domain" type="text" class="mt-1 block w-full" :value="old('domain')" required placeholder="example.com" />
|
||||
<p class="mt-1 text-sm text-gray-500">{{ __('Enter the domain without http:// or https://') }}</p>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('domain')" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,59 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Edit Website') }}: {{ $website->name }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<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
|
||||
@method('PUT')
|
||||
|
||||
<div class="mb-6">
|
||||
<x-input-label for="name" :value="__('Website Name')" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $website->name)" required autofocus />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('name')" />
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<x-input-label for="domain" :value="__('Domain')" />
|
||||
<x-text-input id="domain" name="domain" type="text" class="mt-1 block w-full" :value="old('domain', $website->domain)" required />
|
||||
<p class="mt-1 text-sm text-gray-500">{{ __('Enter the domain without http:// or https://') }}</p>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('domain')" />
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="is_active" class="inline-flex items-center">
|
||||
<input id="is_active" type="checkbox" name="is_active" value="1" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" {{ old('is_active', $website->is_active) ? 'checked' : '' }}>
|
||||
<span class="ms-2 text-sm text-gray-600">{{ __('Active') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<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-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>
|
||||
<form method="POST" action="{{ route('websites.destroy', $website) }}" onsubmit="return confirm('Are you sure you want to delete this website and all its data?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<x-danger-button>{{ __('Delete Website') }}</x-danger-button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,58 @@
|
||||
<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 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-10">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
@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 ($websites->isEmpty())
|
||||
<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)
|
||||
<a href="{{ route('dashboard.show', $website) }}" class="group bg-white rounded-xl border border-gray-200 p-6 hover:border-indigo-300 hover:shadow-md transition-all">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-gray-900 truncate group-hover:text-indigo-600 transition-colors">{{ $website->name }}</h3>
|
||||
<p class="text-sm text-gray-500 mt-0.5">{{ $website->domain }}</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium {{ $website->is_active ? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200' : 'bg-gray-50 text-gray-500 ring-1 ring-gray-200' }}">
|
||||
<span class="w-1.5 h-1.5 rounded-full {{ $website->is_active ? 'bg-emerald-500' : 'bg-gray-400' }}"></span>
|
||||
{{ $website->is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-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
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,93 @@
|
||||
<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">
|
||||
{{ __('Tracking Snippet') }}: {{ $website->name }}
|
||||
</h2>
|
||||
<a href="{{ route('dashboard.show', $website) }}" class="text-sm text-gray-600 hover:text-gray-900">
|
||||
← {{ __('Back to dashboard') }}
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ __('Install the tracking code') }}</h3>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
{{ __('Copy and paste the following code snippet into the') }}
|
||||
<code class="text-sm bg-gray-100 px-1.5 py-0.5 rounded"><head></code>
|
||||
{{ __('section of every page on') }}
|
||||
<strong>{{ $website->domain }}</strong>.
|
||||
</p>
|
||||
|
||||
<div class="relative" x-data="{ copied: false }">
|
||||
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 text-sm overflow-x-auto"><code id="snippet-code">{{ $website->embedSnippet() }}</code></pre>
|
||||
|
||||
<button
|
||||
@click="
|
||||
navigator.clipboard.writeText(document.getElementById('snippet-code').textContent);
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 2000);
|
||||
"
|
||||
class="absolute top-3 right-3 inline-flex items-center gap-1 rounded-md bg-gray-700 px-2.5 py-1.5 text-xs font-medium text-gray-200 hover:bg-gray-600 transition"
|
||||
>
|
||||
<span x-show="!copied">{{ __('Copy') }}</span>
|
||||
<span x-show="copied" x-cloak>{{ __('Copied!') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ __('Track custom events') }}</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
{{ __('After the tracking script is loaded, you can send custom events from anywhere in your JavaScript:') }}
|
||||
</p>
|
||||
|
||||
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 text-sm overflow-x-auto"><code>// Basic event
|
||||
ta('event', 'signup');
|
||||
|
||||
// Event with details
|
||||
ta('event', 'purchase', {
|
||||
category: 'ecommerce',
|
||||
action: 'checkout',
|
||||
label: 'premium_plan',
|
||||
value: 29.99,
|
||||
properties: {
|
||||
plan: 'premium',
|
||||
period: 'monthly'
|
||||
}
|
||||
});</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">{{ __('Details') }}</h3>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">{{ __('Tracking ID') }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono">{{ $website->tracking_id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">{{ __('Domain') }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ $website->domain }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">{{ __('Script URL') }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono break-all">{{ route('tracking.script', $website->tracking_id) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">{{ __('Collection Endpoint') }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono break-all">{{ route('collect.store') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,277 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ config('app.name') }} - Privacy-First 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="antialiased bg-white">
|
||||
<!-- Navigation -->
|
||||
<nav class="fixed w-full bg-white/80 backdrop-blur-md border-b border-gray-200 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<span class="text-2xl font-bold text-indigo-600">{{ config('app.name') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
@auth
|
||||
<a href="{{ url('/dashboard') }}" class="text-gray-700 hover:text-indigo-600 px-4 py-2">Dashboard</a>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="text-gray-700 hover:text-indigo-600 px-4 py-2">Log in</a>
|
||||
@if (Route::has('register'))
|
||||
<a href="{{ route('register') }}" class="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition">
|
||||
Get Started
|
||||
</a>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="pt-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="text-center max-w-3xl mx-auto">
|
||||
<div class="inline-flex items-center px-4 py-2 bg-indigo-50 rounded-full text-indigo-600 text-sm font-medium mb-8">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
|
||||
</svg>
|
||||
Privacy-First & GDPR Compliant
|
||||
</div>
|
||||
|
||||
<h1 class="text-5xl sm:text-6xl lg:text-7xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
Analytics That Respect
|
||||
<span class="text-indigo-600">Privacy</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-gray-600 mb-10 leading-relaxed">
|
||||
EU-made and hosted analytics platform. Track your website performance without compromising your visitors' privacy. GDPR compliant by design.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
@if (Route::has('register'))
|
||||
<a href="{{ route('register') }}" class="bg-indigo-600 text-white px-8 py-4 rounded-lg hover:bg-indigo-700 transition font-semibold text-lg">
|
||||
Start Free Trial
|
||||
</a>
|
||||
@endif
|
||||
<a href="#features" class="border-2 border-gray-300 text-gray-700 px-8 py-4 rounded-lg hover:border-indigo-600 hover:text-indigo-600 transition font-semibold text-lg">
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-20 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl font-bold text-gray-900 mb-4">Built for Privacy & Performance</h2>
|
||||
<p class="text-xl text-gray-600">Everything you need to understand your audience, without tracking them</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<!-- Feature 1 -->
|
||||
<div class="bg-white p-8 rounded-xl shadow-sm hover:shadow-md transition">
|
||||
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Privacy-First</h3>
|
||||
<p class="text-gray-600">No cookies, no tracking, no personal data collection. GDPR compliant by default.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 2 -->
|
||||
<div class="bg-white p-8 rounded-xl shadow-sm hover:shadow-md transition">
|
||||
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">EU Hosted</h3>
|
||||
<p class="text-gray-600">All data stored in the European Union. Your data never leaves EU borders.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 3 -->
|
||||
<div class="bg-white p-8 rounded-xl shadow-sm hover:shadow-md transition">
|
||||
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Lightning Fast</h3>
|
||||
<p class="text-gray-600">Lightweight tracking script that doesn't slow down your website.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 4 -->
|
||||
<div class="bg-white p-8 rounded-xl shadow-sm hover:shadow-md transition">
|
||||
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Real-Time Analytics</h3>
|
||||
<p class="text-gray-600">See who's on your site right now. Monitor traffic as it happens.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 5 -->
|
||||
<div class="bg-white p-8 rounded-xl shadow-sm hover:shadow-md transition">
|
||||
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Goal Tracking</h3>
|
||||
<p class="text-gray-600">Track conversions, events, and custom goals without compromising privacy.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 6 -->
|
||||
<div class="bg-white p-8 rounded-xl shadow-sm hover:shadow-md transition">
|
||||
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Simple Dashboard</h3>
|
||||
<p class="text-gray-600">Clean, intuitive interface. Get insights without the complexity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why Privacy Matters Section -->
|
||||
<section class="py-20 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 class="text-4xl font-bold text-gray-900 mb-6">Why Privacy Matters</h2>
|
||||
<p class="text-lg text-gray-600 mb-6">
|
||||
Traditional analytics tools track your visitors across the web, collect personal data, and sell it to advertisers.
|
||||
This creates privacy concerns and requires annoying cookie consent banners.
|
||||
</p>
|
||||
<p class="text-lg text-gray-600 mb-6">
|
||||
We believe you can get the insights you need while respecting your visitors' privacy.
|
||||
Our platform gives you all the metrics that matter without invasive tracking.
|
||||
</p>
|
||||
<ul class="space-y-4">
|
||||
<li class="flex items-start">
|
||||
<svg class="w-6 h-6 text-green-500 mr-3 mt-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700">No cookie consent banners needed</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<svg class="w-6 h-6 text-green-500 mr-3 mt-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700">100% GDPR compliant by design</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<svg class="w-6 h-6 text-green-500 mr-3 mt-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700">No personal data collection</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<svg class="w-6 h-6 text-green-500 mr-3 mt-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700">Data stored in the EU only</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl p-8 text-white">
|
||||
<h3 class="text-2xl font-bold mb-4">What We Track</h3>
|
||||
<ul class="space-y-3 mb-8">
|
||||
<li class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Page views & sessions
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Traffic sources & referrers
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Device & browser info
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Country (not city/IP)
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="text-2xl font-bold mb-4">What We Don't Track</h3>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
No personal identifiable info
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
No cookies or tracking pixels
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
No cross-site tracking
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
No data selling to third parties
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="py-20 bg-indigo-600">
|
||||
<div class="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-4xl font-bold text-white mb-6">Ready to respect your visitors' privacy?</h2>
|
||||
<p class="text-xl text-indigo-100 mb-8">
|
||||
Join companies that care about privacy. Start tracking what matters without compromising on ethics.
|
||||
</p>
|
||||
@if (Route::has('register'))
|
||||
<a href="{{ route('register') }}" class="inline-block bg-white text-indigo-600 px-8 py-4 rounded-lg hover:bg-gray-100 transition font-semibold text-lg">
|
||||
Get Started Free
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-900 text-gray-400 py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-semibold text-white mb-2">{{ config('app.name') }}</p>
|
||||
<p class="text-sm">Privacy-first analytics. Made and hosted in the EU.</p>
|
||||
<p class="text-sm mt-4">© {{ date('Y') }} {{ config('app.name') }}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user