quarterdeck 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +93 -0
- data/Rakefile +3 -0
- data/app/assets/stylesheets/quarterdeck/application.css +15 -0
- data/app/controllers/quarterdeck/application_controller.rb +73 -0
- data/app/controllers/quarterdeck/campaigns_controller.rb +52 -0
- data/app/controllers/quarterdeck/events_controller.rb +38 -0
- data/app/controllers/quarterdeck/geographics_controller.rb +41 -0
- data/app/controllers/quarterdeck/live_controller.rb +32 -0
- data/app/controllers/quarterdeck/overview_controller.rb +89 -0
- data/app/controllers/quarterdeck/visits_controller.rb +46 -0
- data/app/helpers/quarterdeck/application_helper.rb +4 -0
- data/app/javascript/quarterdeck/application.js +9 -0
- data/app/javascript/quarterdeck/controllers/chart_controller.js +99 -0
- data/app/javascript/quarterdeck/controllers/date_range_controller.js +18 -0
- data/app/javascript/quarterdeck/controllers/live_controller.js +59 -0
- data/app/jobs/quarterdeck/application_job.rb +4 -0
- data/app/mailers/quarterdeck/application_mailer.rb +6 -0
- data/app/models/quarterdeck/application_record.rb +5 -0
- data/app/views/layouts/quarterdeck/application.html.erb +36 -0
- data/app/views/quarterdeck/campaigns/show.html.erb +35 -0
- data/app/views/quarterdeck/events/index.html.erb +89 -0
- data/app/views/quarterdeck/geographics/show.html.erb +32 -0
- data/app/views/quarterdeck/live/show.html.erb +75 -0
- data/app/views/quarterdeck/overview/show.html.erb +38 -0
- data/app/views/quarterdeck/shared/_data_table.html.erb +29 -0
- data/app/views/quarterdeck/shared/_nav.html.erb +36 -0
- data/app/views/quarterdeck/shared/_period_tabs.html.erb +31 -0
- data/app/views/quarterdeck/shared/_stat_card.html.erb +22 -0
- data/app/views/quarterdeck/visits/index.html.erb +115 -0
- data/app/views/quarterdeck/visits/show.html.erb +72 -0
- data/config/importmap.rb +5 -0
- data/config/routes.rb +8 -0
- data/lib/generators/quarterdeck/install_generator.rb +39 -0
- data/lib/quarterdeck/engine.rb +19 -0
- data/lib/quarterdeck/version.rb +3 -0
- data/lib/quarterdeck.rb +19 -0
- data/lib/tasks/quarterdeck_tasks.rake +4 -0
- metadata +139 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["activeVisitors", "currentPages", "recentEvents"]
|
|
5
|
+
static values = { url: String }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.poll()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
disconnect() {
|
|
12
|
+
if (this.timer) clearTimeout(this.timer)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
poll() {
|
|
16
|
+
fetch(this.urlValue)
|
|
17
|
+
.then(response => response.json())
|
|
18
|
+
.then(data => this.update(data))
|
|
19
|
+
.catch(() => {})
|
|
20
|
+
.finally(() => {
|
|
21
|
+
this.timer = setTimeout(() => this.poll(), 5000)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
update(data) {
|
|
26
|
+
this.activeVisitorsTarget.textContent = data.active_visitors
|
|
27
|
+
|
|
28
|
+
if (this.hasCurrentPagesTarget) {
|
|
29
|
+
if (data.current_pages.length > 0) {
|
|
30
|
+
this.currentPagesTarget.innerHTML = data.current_pages.map(p =>
|
|
31
|
+
`<tr class="hover:bg-gray-50">
|
|
32
|
+
<td class="px-6 py-3 text-sm text-gray-900 truncate max-w-xs">${this.escapeHtml(p.url)}</td>
|
|
33
|
+
<td class="px-6 py-3 text-sm text-gray-600 text-right">${p.count}</td>
|
|
34
|
+
</tr>`
|
|
35
|
+
).join("")
|
|
36
|
+
} else {
|
|
37
|
+
this.currentPagesTarget.innerHTML = `<tr><td colspan="2" class="px-6 py-8 text-sm text-gray-400 text-center">No active pages</td></tr>`
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (this.hasRecentEventsTarget) {
|
|
42
|
+
this.recentEventsTarget.innerHTML = data.recent_events.map(e =>
|
|
43
|
+
`<tr class="hover:bg-gray-50">
|
|
44
|
+
<td class="px-6 py-3 text-sm text-gray-900 whitespace-nowrap">${this.escapeHtml(e.time)}</td>
|
|
45
|
+
<td class="px-6 py-3 text-sm">
|
|
46
|
+
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-50 text-indigo-700">${this.escapeHtml(e.name)}</span>
|
|
47
|
+
</td>
|
|
48
|
+
<td class="px-6 py-3 text-sm text-gray-500 font-mono">${this.escapeHtml(e.visitor || "")}</td>
|
|
49
|
+
</tr>`
|
|
50
|
+
).join("")
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
escapeHtml(text) {
|
|
55
|
+
const div = document.createElement("div")
|
|
56
|
+
div.textContent = text || ""
|
|
57
|
+
return div.innerHTML
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html class="h-full bg-gray-50">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Quarterdeck Analytics</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= csp_meta_tag %>
|
|
8
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
9
|
+
<%= javascript_importmap_tags("quarterdeck/application") if respond_to?(:javascript_importmap_tags) %>
|
|
10
|
+
</head>
|
|
11
|
+
<body class="h-full">
|
|
12
|
+
<div class="min-h-full">
|
|
13
|
+
<header class="bg-white shadow-sm border-b border-gray-200">
|
|
14
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
15
|
+
<div class="flex items-center justify-between h-16">
|
|
16
|
+
<div class="flex items-center space-x-2">
|
|
17
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
18
|
+
<path stroke-linecap="round" stroke-linejoin="round" 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" />
|
|
19
|
+
</svg>
|
|
20
|
+
<h1 class="text-xl font-bold text-gray-900">Quarterdeck</h1>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
27
|
+
<%= render "quarterdeck/shared/nav" %>
|
|
28
|
+
<%= render "quarterdeck/shared/period_tabs" %>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12">
|
|
32
|
+
<%= yield %>
|
|
33
|
+
</main>
|
|
34
|
+
</div>
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<div class="space-y-6">
|
|
2
|
+
<!-- Stats -->
|
|
3
|
+
<div class="flex items-center justify-between mb-2">
|
|
4
|
+
<div></div>
|
|
5
|
+
<%= link_to "Export CSV", url_for(request.query_parameters.merge(format: :csv)),
|
|
6
|
+
class: "text-xs text-indigo-600 hover:text-indigo-800 font-medium" %>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="grid grid-cols-1 sm:grid-cols-1 gap-4">
|
|
9
|
+
<%= render "quarterdeck/shared/stat_card", title: "Total Campaign Visits", value: @total_campaign_visits %>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<!-- UTM Breakdowns -->
|
|
13
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
14
|
+
<%= render "quarterdeck/shared/data_table", title: "Sources", label: "UTM Source", count_label: "Visits", data: @sources %>
|
|
15
|
+
<%= render "quarterdeck/shared/data_table", title: "Mediums", label: "UTM Medium", count_label: "Visits", data: @mediums %>
|
|
16
|
+
<%= render "quarterdeck/shared/data_table", title: "Campaigns", label: "UTM Campaign", count_label: "Visits", data: @campaigns %>
|
|
17
|
+
<%= render "quarterdeck/shared/data_table", title: "Terms", label: "UTM Term", count_label: "Visits", data: @terms %>
|
|
18
|
+
<%= render "quarterdeck/shared/data_table", title: "Content", label: "UTM Content", count_label: "Visits", data: @contents %>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<!-- Source Chart -->
|
|
22
|
+
<% if @sources.any? %>
|
|
23
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
24
|
+
<h3 class="text-sm font-semibold text-gray-900 mb-4">Top Sources</h3>
|
|
25
|
+
<div class="h-64">
|
|
26
|
+
<canvas
|
|
27
|
+
data-controller="chart"
|
|
28
|
+
data-chart-type-value="doughnut"
|
|
29
|
+
data-chart-labels-value="<%= @sources.keys.first(8).to_json %>"
|
|
30
|
+
data-chart-data-value="<%= @sources.values.first(8).to_json %>"
|
|
31
|
+
></canvas>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<div class="space-y-6">
|
|
2
|
+
<!-- Stats -->
|
|
3
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
4
|
+
<%= render "quarterdeck/shared/stat_card", title: "Total Events", value: @total_events, previous_value: @prev_total_events %>
|
|
5
|
+
<%= render "quarterdeck/shared/stat_card", title: "Unique Event Names", value: @unique_events, previous_value: @prev_unique_events %>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<!-- Filter by event name -->
|
|
9
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
|
10
|
+
<%= form_tag quarterdeck.events_path, method: :get, class: "flex items-end gap-3" do %>
|
|
11
|
+
<input type="hidden" name="period" value="<%= period %>">
|
|
12
|
+
<div class="flex-1">
|
|
13
|
+
<label class="block text-xs font-medium text-gray-500 mb-1">Event Name</label>
|
|
14
|
+
<select name="name" class="rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 w-full">
|
|
15
|
+
<option value="">All Events</option>
|
|
16
|
+
<% @event_names.each do |name, count| %>
|
|
17
|
+
<option value="<%= name %>" <%= "selected" if params[:name] == name %>><%= name %> (<%= count %>)</option>
|
|
18
|
+
<% end %>
|
|
19
|
+
</select>
|
|
20
|
+
</div>
|
|
21
|
+
<button type="submit" class="bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 transition">
|
|
22
|
+
Filter
|
|
23
|
+
</button>
|
|
24
|
+
<% if params[:name].present? %>
|
|
25
|
+
<%= link_to "Clear", quarterdeck.events_path(period: period),
|
|
26
|
+
class: "text-sm text-gray-500 hover:text-gray-700 px-3 py-2" %>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<!-- Daily Events Chart -->
|
|
32
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
33
|
+
<div class="flex items-center justify-between mb-4">
|
|
34
|
+
<h3 class="text-sm font-semibold text-gray-900">Events Over Time</h3>
|
|
35
|
+
<%= link_to "Export CSV", url_for(request.query_parameters.merge(format: :csv)),
|
|
36
|
+
class: "text-xs text-indigo-600 hover:text-indigo-800 font-medium" %>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="h-64">
|
|
39
|
+
<canvas
|
|
40
|
+
data-controller="chart"
|
|
41
|
+
data-chart-type-value="bar"
|
|
42
|
+
data-chart-labels-value="<%= @daily_events.keys.map { |d| d.strftime("%b %d") }.to_json %>"
|
|
43
|
+
data-chart-data-value="<%= @daily_events.values.to_json %>"
|
|
44
|
+
data-chart-label-value="Events"
|
|
45
|
+
></canvas>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<!-- Event Breakdown -->
|
|
50
|
+
<%= render "quarterdeck/shared/data_table", title: "Event Breakdown", label: "Event Name", count_label: "Count", data: @event_names %>
|
|
51
|
+
|
|
52
|
+
<!-- Recent Events -->
|
|
53
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
54
|
+
<div class="px-6 py-4 border-b border-gray-200">
|
|
55
|
+
<h3 class="text-sm font-semibold text-gray-900">Recent Events</h3>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="overflow-x-auto">
|
|
58
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
59
|
+
<thead class="bg-gray-50">
|
|
60
|
+
<tr>
|
|
61
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th>
|
|
62
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
|
63
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Properties</th>
|
|
64
|
+
</tr>
|
|
65
|
+
</thead>
|
|
66
|
+
<tbody class="bg-white divide-y divide-gray-200">
|
|
67
|
+
<% @events.each do |event| %>
|
|
68
|
+
<tr class="hover:bg-gray-50">
|
|
69
|
+
<td class="px-6 py-3 text-sm text-gray-900 whitespace-nowrap"><%= event.time.strftime("%b %d, %H:%M:%S") %></td>
|
|
70
|
+
<td class="px-6 py-3 text-sm">
|
|
71
|
+
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-50 text-indigo-700">
|
|
72
|
+
<%= event.name %>
|
|
73
|
+
</span>
|
|
74
|
+
</td>
|
|
75
|
+
<td class="px-6 py-3 text-sm text-gray-600 max-w-md truncate">
|
|
76
|
+
<% if event.properties.present? %>
|
|
77
|
+
<code class="text-xs bg-gray-100 rounded px-1 py-0.5"><%= event.properties.to_json %></code>
|
|
78
|
+
<% end %>
|
|
79
|
+
</td>
|
|
80
|
+
</tr>
|
|
81
|
+
<% end %>
|
|
82
|
+
</tbody>
|
|
83
|
+
</table>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="px-6 py-3 border-t border-gray-200">
|
|
86
|
+
<%== @pagy.series_nav %>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<div class="space-y-6">
|
|
2
|
+
<div class="flex items-center justify-between">
|
|
3
|
+
<div></div>
|
|
4
|
+
<%= link_to "Export CSV", url_for(request.query_parameters.merge(format: :csv)),
|
|
5
|
+
class: "text-xs text-indigo-600 hover:text-indigo-800 font-medium" %>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<!-- Countries -->
|
|
9
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
10
|
+
<%= render "quarterdeck/shared/data_table", title: "Countries", label: "Country", count_label: "Visits", data: @countries %>
|
|
11
|
+
|
|
12
|
+
<% if @countries.any? %>
|
|
13
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
14
|
+
<h3 class="text-sm font-semibold text-gray-900 mb-4">Top Countries</h3>
|
|
15
|
+
<div class="h-64">
|
|
16
|
+
<canvas
|
|
17
|
+
data-controller="chart"
|
|
18
|
+
data-chart-type-value="doughnut"
|
|
19
|
+
data-chart-labels-value="<%= @countries.keys.first(8).to_json %>"
|
|
20
|
+
data-chart-data-value="<%= @countries.values.first(8).to_json %>"
|
|
21
|
+
></canvas>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<% end %>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- Regions & Cities -->
|
|
28
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
29
|
+
<%= render "quarterdeck/shared/data_table", title: "Regions", label: "Region", count_label: "Visits", data: @regions %>
|
|
30
|
+
<%= render "quarterdeck/shared/data_table", title: "Cities", label: "City", count_label: "Visits", data: @cities %>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<div class="space-y-6" data-controller="live" data-live-url-value="<%= quarterdeck.live_path(format: :json) %>">
|
|
2
|
+
<!-- Active Visitors -->
|
|
3
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
|
4
|
+
<div class="inline-flex items-center gap-3">
|
|
5
|
+
<span class="relative flex h-3 w-3">
|
|
6
|
+
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
|
7
|
+
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
|
8
|
+
</span>
|
|
9
|
+
<span class="text-5xl font-bold text-gray-900" data-live-target="activeVisitors"><%= @active_visitors %></span>
|
|
10
|
+
</div>
|
|
11
|
+
<p class="mt-2 text-sm text-gray-500">active visitors in the last 5 minutes</p>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
15
|
+
<!-- Current Pages -->
|
|
16
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
17
|
+
<div class="px-6 py-4 border-b border-gray-200">
|
|
18
|
+
<h3 class="text-sm font-semibold text-gray-900">Pages Being Viewed</h3>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="overflow-x-auto">
|
|
21
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
22
|
+
<thead class="bg-gray-50">
|
|
23
|
+
<tr>
|
|
24
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Page</th>
|
|
25
|
+
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Viewers</th>
|
|
26
|
+
</tr>
|
|
27
|
+
</thead>
|
|
28
|
+
<tbody class="bg-white divide-y divide-gray-200" data-live-target="currentPages">
|
|
29
|
+
<% if @current_pages.any? %>
|
|
30
|
+
<% @current_pages.each do |url, count| %>
|
|
31
|
+
<tr class="hover:bg-gray-50">
|
|
32
|
+
<td class="px-6 py-3 text-sm text-gray-900 truncate max-w-xs"><%= url %></td>
|
|
33
|
+
<td class="px-6 py-3 text-sm text-gray-600 text-right"><%= count %></td>
|
|
34
|
+
</tr>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% else %>
|
|
37
|
+
<tr>
|
|
38
|
+
<td colspan="2" class="px-6 py-8 text-sm text-gray-400 text-center">No active pages</td>
|
|
39
|
+
</tr>
|
|
40
|
+
<% end %>
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<!-- Live Event Stream -->
|
|
47
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
48
|
+
<div class="px-6 py-4 border-b border-gray-200">
|
|
49
|
+
<h3 class="text-sm font-semibold text-gray-900">Live Event Stream</h3>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="overflow-x-auto max-h-96 overflow-y-auto">
|
|
52
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
53
|
+
<thead class="bg-gray-50 sticky top-0">
|
|
54
|
+
<tr>
|
|
55
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th>
|
|
56
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
|
57
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Visitor</th>
|
|
58
|
+
</tr>
|
|
59
|
+
</thead>
|
|
60
|
+
<tbody class="bg-white divide-y divide-gray-200" data-live-target="recentEvents">
|
|
61
|
+
<% @recent_events.each do |event| %>
|
|
62
|
+
<tr class="hover:bg-gray-50">
|
|
63
|
+
<td class="px-6 py-3 text-sm text-gray-900 whitespace-nowrap"><%= event.time.strftime("%H:%M:%S") %></td>
|
|
64
|
+
<td class="px-6 py-3 text-sm">
|
|
65
|
+
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-50 text-indigo-700"><%= event.name %></span>
|
|
66
|
+
</td>
|
|
67
|
+
<td class="px-6 py-3 text-sm text-gray-500 font-mono"><%= event.visit&.visitor_token&.first(8) %></td>
|
|
68
|
+
</tr>
|
|
69
|
+
<% end %>
|
|
70
|
+
</tbody>
|
|
71
|
+
</table>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<div class="space-y-8">
|
|
2
|
+
<!-- Stats -->
|
|
3
|
+
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
4
|
+
<%= render "quarterdeck/shared/stat_card", title: "Total Visits", value: @total_visits, previous_value: @prev_total_visits %>
|
|
5
|
+
<%= render "quarterdeck/shared/stat_card", title: "Unique Visitors", value: @unique_visitors, previous_value: @prev_unique_visitors %>
|
|
6
|
+
<%= render "quarterdeck/shared/stat_card", title: "Total Events", value: @total_events, previous_value: @prev_total_events %>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<!-- Daily Visits Chart -->
|
|
10
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
11
|
+
<div class="flex items-center justify-between mb-4">
|
|
12
|
+
<h3 class="text-sm font-semibold text-gray-900">Visits Over Time</h3>
|
|
13
|
+
<%= link_to "Export CSV", url_for(request.query_parameters.merge(format: :csv)),
|
|
14
|
+
class: "text-xs text-indigo-600 hover:text-indigo-800 font-medium" %>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="h-64">
|
|
17
|
+
<canvas
|
|
18
|
+
data-controller="chart"
|
|
19
|
+
data-chart-type-value="line"
|
|
20
|
+
data-chart-labels-value="<%= @daily_visits.keys.map { |d| d.strftime("%b %d") }.to_json %>"
|
|
21
|
+
data-chart-data-value="<%= @daily_visits.values.to_json %>"
|
|
22
|
+
data-chart-label-value="Visits"
|
|
23
|
+
></canvas>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- Two Column Grid -->
|
|
28
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
29
|
+
<%= render "quarterdeck/shared/data_table", title: "Top Pages", label: "Page", count_label: "Views", data: @top_pages %>
|
|
30
|
+
<%= render "quarterdeck/shared/data_table", title: "Top Referrers", label: "Referrer", count_label: "Visits", data: @top_referrers %>
|
|
31
|
+
<%= render "quarterdeck/shared/data_table", title: "Browsers", label: "Browser", count_label: "Visits", data: @browsers %>
|
|
32
|
+
<%= render "quarterdeck/shared/data_table", title: "Devices", label: "Device", count_label: "Visits", data: @devices %>
|
|
33
|
+
<%= render "quarterdeck/shared/data_table", title: "Operating Systems", label: "OS", count_label: "Visits", data: @os %>
|
|
34
|
+
<%= render "quarterdeck/shared/data_table", title: "Countries", label: "Country", count_label: "Visits", data: @countries %>
|
|
35
|
+
<%= render "quarterdeck/shared/data_table", title: "Top Events", label: "Event", count_label: "Count", data: @top_events %>
|
|
36
|
+
<%= render "quarterdeck/shared/data_table", title: "Campaigns", label: "Source", count_label: "Visits", data: @campaigns %>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
2
|
+
<div class="px-6 py-4 border-b border-gray-200">
|
|
3
|
+
<h3 class="text-sm font-semibold text-gray-900"><%= title %></h3>
|
|
4
|
+
</div>
|
|
5
|
+
<div class="overflow-x-auto">
|
|
6
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
7
|
+
<thead class="bg-gray-50">
|
|
8
|
+
<tr>
|
|
9
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"><%= label %></th>
|
|
10
|
+
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"><%= count_label || "Count" %></th>
|
|
11
|
+
</tr>
|
|
12
|
+
</thead>
|
|
13
|
+
<tbody class="bg-white divide-y divide-gray-200">
|
|
14
|
+
<% if data.any? %>
|
|
15
|
+
<% data.each do |key, count| %>
|
|
16
|
+
<tr class="hover:bg-gray-50">
|
|
17
|
+
<td class="px-6 py-3 text-sm text-gray-900"><%= key.presence || "(none)" %></td>
|
|
18
|
+
<td class="px-6 py-3 text-sm text-gray-600 text-right"><%= number_with_delimiter(count) %></td>
|
|
19
|
+
</tr>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% else %>
|
|
22
|
+
<tr>
|
|
23
|
+
<td colspan="2" class="px-6 py-8 text-sm text-gray-400 text-center">No data for this period</td>
|
|
24
|
+
</tr>
|
|
25
|
+
<% end %>
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%
|
|
2
|
+
nav_items = [
|
|
3
|
+
{ name: "Overview", path: quarterdeck.root_path, active: controller_name == "overview" },
|
|
4
|
+
{ name: "Visits", path: quarterdeck.visits_path, active: controller_name == "visits" },
|
|
5
|
+
{ name: "Events", path: quarterdeck.events_path, active: controller_name == "events" },
|
|
6
|
+
{ name: "Campaigns", path: quarterdeck.campaigns_path, active: controller_name == "campaigns" },
|
|
7
|
+
{ name: "Geographic", path: quarterdeck.geographic_path, active: controller_name == "geographics" },
|
|
8
|
+
{ name: "Live", path: quarterdeck.live_path, active: controller_name == "live", live: true }
|
|
9
|
+
]
|
|
10
|
+
%>
|
|
11
|
+
<nav class="flex space-x-1 mb-4" aria-label="Tabs">
|
|
12
|
+
<% nav_items.each do |item| %>
|
|
13
|
+
<% if item[:active] %>
|
|
14
|
+
<%= link_to item[:path],
|
|
15
|
+
class: "bg-indigo-100 text-indigo-700 px-4 py-2 font-medium text-sm rounded-lg inline-flex items-center gap-1.5" do %>
|
|
16
|
+
<% if item[:live] %>
|
|
17
|
+
<span class="relative flex h-2 w-2">
|
|
18
|
+
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
|
19
|
+
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
|
20
|
+
</span>
|
|
21
|
+
<% end %>
|
|
22
|
+
<%= item[:name] %>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% else %>
|
|
25
|
+
<%= link_to item[:path],
|
|
26
|
+
class: "text-gray-500 hover:text-gray-700 hover:bg-gray-100 px-4 py-2 font-medium text-sm rounded-lg transition inline-flex items-center gap-1.5" do %>
|
|
27
|
+
<% if item[:live] %>
|
|
28
|
+
<span class="relative flex h-2 w-2">
|
|
29
|
+
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
|
30
|
+
</span>
|
|
31
|
+
<% end %>
|
|
32
|
+
<%= item[:name] %>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
36
|
+
</nav>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<%
|
|
2
|
+
periods = [
|
|
3
|
+
{ label: "Today", value: "today" },
|
|
4
|
+
{ label: "7 days", value: "7d" },
|
|
5
|
+
{ label: "30 days", value: "30d" },
|
|
6
|
+
{ label: "90 days", value: "90d" }
|
|
7
|
+
]
|
|
8
|
+
custom_active = period == "custom"
|
|
9
|
+
%>
|
|
10
|
+
<div class="flex flex-wrap items-center gap-1 mb-6" data-controller="date-range">
|
|
11
|
+
<% periods.each do |p| %>
|
|
12
|
+
<% active = period == p[:value] %>
|
|
13
|
+
<%= link_to p[:label],
|
|
14
|
+
url_for(request.query_parameters.merge(period: p[:value]).except("start_date", "end_date")),
|
|
15
|
+
class: "px-3 py-1.5 text-xs font-medium rounded-md transition #{active ? 'bg-indigo-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'}" %>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<span class="px-3 py-1.5 text-xs font-medium rounded-md transition cursor-default <%= custom_active ? 'bg-indigo-600 text-white' : 'bg-white text-gray-600 border border-gray-200' %>">Custom</span>
|
|
19
|
+
|
|
20
|
+
<input type="date"
|
|
21
|
+
data-date-range-target="startDate"
|
|
22
|
+
data-action="change->date-range#submit"
|
|
23
|
+
value="<%= params[:start_date] %>"
|
|
24
|
+
class="px-2 py-1 text-xs rounded-md border border-gray-200 text-gray-700 focus:border-indigo-500 focus:ring-indigo-500">
|
|
25
|
+
<span class="text-xs text-gray-400">to</span>
|
|
26
|
+
<input type="date"
|
|
27
|
+
data-date-range-target="endDate"
|
|
28
|
+
data-action="change->date-range#submit"
|
|
29
|
+
value="<%= params[:end_date] %>"
|
|
30
|
+
class="px-2 py-1 text-xs rounded-md border border-gray-200 text-gray-700 focus:border-indigo-500 focus:ring-indigo-500">
|
|
31
|
+
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
2
|
+
<dt class="text-sm font-medium text-gray-500 truncate"><%= title %></dt>
|
|
3
|
+
<dd class="mt-2 flex items-baseline gap-2">
|
|
4
|
+
<span class="text-3xl font-bold text-gray-900"><%= number_with_delimiter(value) %></span>
|
|
5
|
+
<% if local_assigns[:previous_value] && previous_value && previous_value > 0 %>
|
|
6
|
+
<% change = ((value - previous_value).to_f / previous_value * 100).round(1) %>
|
|
7
|
+
<% if change > 0 %>
|
|
8
|
+
<span class="inline-flex items-center text-xs font-medium text-green-700 bg-green-50 rounded-full px-2 py-0.5">
|
|
9
|
+
<svg class="w-3 h-3 mr-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
|
|
10
|
+
<%= change %>%
|
|
11
|
+
</span>
|
|
12
|
+
<% elsif change < 0 %>
|
|
13
|
+
<span class="inline-flex items-center text-xs font-medium text-red-700 bg-red-50 rounded-full px-2 py-0.5">
|
|
14
|
+
<svg class="w-3 h-3 mr-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
|
15
|
+
<%= change.abs %>%
|
|
16
|
+
</span>
|
|
17
|
+
<% else %>
|
|
18
|
+
<span class="inline-flex items-center text-xs font-medium text-gray-500 bg-gray-50 rounded-full px-2 py-0.5">—</span>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% end %>
|
|
21
|
+
</dd>
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<div class="space-y-6">
|
|
2
|
+
<!-- Filters -->
|
|
3
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
|
4
|
+
<%= form_tag quarterdeck.visits_path, method: :get, class: "flex flex-wrap items-end gap-3" do %>
|
|
5
|
+
<input type="hidden" name="period" value="<%= period %>">
|
|
6
|
+
|
|
7
|
+
<div class="flex-1 min-w-[200px]">
|
|
8
|
+
<label class="block text-xs font-medium text-gray-500 mb-1">Search</label>
|
|
9
|
+
<input type="text" name="search" value="<%= params[:search] %>"
|
|
10
|
+
placeholder="Landing page, referrer, or IP..."
|
|
11
|
+
class="w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div>
|
|
15
|
+
<label class="block text-xs font-medium text-gray-500 mb-1">Browser</label>
|
|
16
|
+
<select name="browser" class="rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
|
17
|
+
<option value="">All</option>
|
|
18
|
+
<% @browsers.each do |b| %>
|
|
19
|
+
<option value="<%= b %>" <%= "selected" if params[:browser] == b %>><%= b %></option>
|
|
20
|
+
<% end %>
|
|
21
|
+
</select>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div>
|
|
25
|
+
<label class="block text-xs font-medium text-gray-500 mb-1">Device</label>
|
|
26
|
+
<select name="device_type" class="rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
|
27
|
+
<option value="">All</option>
|
|
28
|
+
<% @devices.each do |d| %>
|
|
29
|
+
<option value="<%= d %>" <%= "selected" if params[:device_type] == d %>><%= d %></option>
|
|
30
|
+
<% end %>
|
|
31
|
+
</select>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div>
|
|
35
|
+
<label class="block text-xs font-medium text-gray-500 mb-1">OS</label>
|
|
36
|
+
<select name="os" class="rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
|
37
|
+
<option value="">All</option>
|
|
38
|
+
<% @operating_systems.each do |o| %>
|
|
39
|
+
<option value="<%= o %>" <%= "selected" if params[:os] == o %>><%= o %></option>
|
|
40
|
+
<% end %>
|
|
41
|
+
</select>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div>
|
|
45
|
+
<label class="block text-xs font-medium text-gray-500 mb-1">Country</label>
|
|
46
|
+
<select name="country" class="rounded-lg border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
|
47
|
+
<option value="">All</option>
|
|
48
|
+
<% @countries_list.each do |c| %>
|
|
49
|
+
<option value="<%= c %>" <%= "selected" if params[:country] == c %>><%= c %></option>
|
|
50
|
+
<% end %>
|
|
51
|
+
</select>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="flex items-center gap-2">
|
|
55
|
+
<label class="flex items-center gap-1.5 text-sm text-gray-600">
|
|
56
|
+
<input type="checkbox" name="utm_only" value="1" <%= "checked" if params[:utm_only] == "1" %>
|
|
57
|
+
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
|
58
|
+
UTM only
|
|
59
|
+
</label>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<button type="submit" class="bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 transition">
|
|
63
|
+
Filter
|
|
64
|
+
</button>
|
|
65
|
+
|
|
66
|
+
<% if params.to_unsafe_h.except("controller", "action", "period").values.any?(&:present?) %>
|
|
67
|
+
<%= link_to "Clear", quarterdeck.visits_path(period: period),
|
|
68
|
+
class: "text-sm text-gray-500 hover:text-gray-700 px-3 py-2" %>
|
|
69
|
+
<% end %>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- Visits Table -->
|
|
74
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
75
|
+
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
|
76
|
+
<h3 class="text-sm font-semibold text-gray-900">Visits</h3>
|
|
77
|
+
<%= link_to "Export CSV", url_for(request.query_parameters.merge(format: :csv)),
|
|
78
|
+
class: "text-xs text-indigo-600 hover:text-indigo-800 font-medium" %>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="overflow-x-auto">
|
|
81
|
+
<table class="min-w-full divide-y divide-gray-200">
|
|
82
|
+
<thead class="bg-gray-50">
|
|
83
|
+
<tr>
|
|
84
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Started</th>
|
|
85
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Landing Page</th>
|
|
86
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Referrer</th>
|
|
87
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Browser</th>
|
|
88
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Device</th>
|
|
89
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Location</th>
|
|
90
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">UTM Source</th>
|
|
91
|
+
</tr>
|
|
92
|
+
</thead>
|
|
93
|
+
<tbody class="bg-white divide-y divide-gray-200">
|
|
94
|
+
<% @visits.each do |visit| %>
|
|
95
|
+
<tr class="hover:bg-gray-50">
|
|
96
|
+
<td class="px-6 py-3 text-sm text-gray-900 whitespace-nowrap">
|
|
97
|
+
<%= link_to visit.started_at.strftime("%b %d, %H:%M"), quarterdeck.visit_path(visit),
|
|
98
|
+
class: "text-indigo-600 hover:text-indigo-800" %>
|
|
99
|
+
</td>
|
|
100
|
+
<td class="px-6 py-3 text-sm text-gray-600 max-w-xs truncate"><%= visit.landing_page %></td>
|
|
101
|
+
<td class="px-6 py-3 text-sm text-gray-600"><%= visit.referring_domain %></td>
|
|
102
|
+
<td class="px-6 py-3 text-sm text-gray-600"><%= visit.browser %></td>
|
|
103
|
+
<td class="px-6 py-3 text-sm text-gray-600"><%= visit.device_type %></td>
|
|
104
|
+
<td class="px-6 py-3 text-sm text-gray-600"><%= [visit.city, visit.country].compact_blank.join(", ") %></td>
|
|
105
|
+
<td class="px-6 py-3 text-sm text-gray-600"><%= visit.utm_source %></td>
|
|
106
|
+
</tr>
|
|
107
|
+
<% end %>
|
|
108
|
+
</tbody>
|
|
109
|
+
</table>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="px-6 py-3 border-t border-gray-200">
|
|
112
|
+
<%== @pagy.series_nav %>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|