ruby_cms 0.2.0.5 → 0.2.0.9
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 +4 -4
- data/CHANGELOG.md +22 -1
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +9 -1
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +3 -3
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +8 -8
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +15 -5
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +57 -12
- data/app/controllers/concerns/ruby_cms/page_tracking.rb +15 -0
- data/app/controllers/ruby_cms/admin/analytics_controller.rb +12 -0
- data/app/controllers/ruby_cms/admin/commands_controller.rb +6 -6
- data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +65 -13
- data/app/services/ruby_cms/analytics/report.rb +119 -7
- data/app/services/ruby_cms/command_runner.rb +3 -3
- data/app/views/ruby_cms/admin/analytics/index.html.erb +175 -67
- data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +21 -15
- data/app/views/ruby_cms/admin/analytics/partials/_conversions.html.erb +49 -0
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +36 -16
- data/app/views/ruby_cms/admin/analytics/partials/_exit_pages.html.erb +39 -0
- data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +29 -13
- data/app/views/ruby_cms/admin/analytics/partials/_landing_pages.html.erb +27 -15
- data/app/views/ruby_cms/admin/analytics/partials/_os_stats.html.erb +29 -18
- data/app/views/ruby_cms/admin/analytics/partials/_popular_pages.html.erb +17 -12
- data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +25 -14
- data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +27 -15
- data/app/views/ruby_cms/admin/analytics/partials/_top_visitors.html.erb +20 -10
- data/app/views/ruby_cms/admin/analytics/partials/_utm_sources.html.erb +27 -15
- data/app/views/ruby_cms/admin/content_blocks/index.html.erb +8 -1
- data/config/locales/en.yml +69 -5
- data/config/locales/nl.yml +98 -0
- data/db/migrate/20260409000001_add_analytics_performance_indexes.rb +29 -0
- data/lib/generators/ruby_cms/install_generator.rb +21 -6
- data/lib/generators/ruby_cms/templates/ruby_cms.rb +40 -3
- data/lib/ruby_cms/settings_registry.rb +35 -0
- data/lib/ruby_cms/version.rb +1 -1
- metadata +18 -1
|
@@ -14,6 +14,14 @@ module RubyCms
|
|
|
14
14
|
DEFAULT_RECENT_PAGE_VIEWS_LIMIT = 25
|
|
15
15
|
DEFAULT_PAGE_DETAILS_LIMIT = 100
|
|
16
16
|
DEFAULT_VISITOR_DETAILS_LIMIT = 100
|
|
17
|
+
DEFAULT_MAX_EXIT_PAGES = 10
|
|
18
|
+
DEFAULT_MAX_CONVERSIONS = 10
|
|
19
|
+
|
|
20
|
+
# Supported Ahoy event names. Use these constants when calling ahoy.track in the host app.
|
|
21
|
+
# page_view: tracked automatically via RubyCms::PageTracking (page_name:, request_path:)
|
|
22
|
+
# conversion: tracked by the host app, e.g. ahoy.track "conversion", goal: "contact_form"
|
|
23
|
+
EVENT_PAGE_VIEW = "page_view"
|
|
24
|
+
EVENT_CONVERSION = "conversion"
|
|
17
25
|
|
|
18
26
|
def initialize(start_date:, end_date:, period: nil)
|
|
19
27
|
@start_date = start_date.to_date.beginning_of_day
|
|
@@ -84,7 +92,12 @@ module RubyCms
|
|
|
84
92
|
end
|
|
85
93
|
|
|
86
94
|
def page_view_events
|
|
87
|
-
base = Ahoy::Event.where(name:
|
|
95
|
+
base = Ahoy::Event.where(name: EVENT_PAGE_VIEW, time: @range).joins(:visit).merge(visits)
|
|
96
|
+
apply_event_scope_hook(base)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def conversion_events
|
|
100
|
+
base = Ahoy::Event.where(name: EVENT_CONVERSION, time: @range).joins(:visit).merge(visits)
|
|
88
101
|
apply_event_scope_hook(base)
|
|
89
102
|
end
|
|
90
103
|
|
|
@@ -298,6 +311,102 @@ module RubyCms
|
|
|
298
311
|
end
|
|
299
312
|
end
|
|
300
313
|
|
|
314
|
+
def parse_event_properties(props)
|
|
315
|
+
return props if props.kind_of?(Hash)
|
|
316
|
+
|
|
317
|
+
JSON.parse(props.to_s)
|
|
318
|
+
rescue StandardError
|
|
319
|
+
{}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def conversion_stats_data
|
|
323
|
+
limit = RubyCms::Settings.get(:analytics_max_conversions, default: DEFAULT_MAX_CONVERSIONS).to_i
|
|
324
|
+
total = conversion_events.count
|
|
325
|
+
grouped = conversion_events.pluck(:properties).each_with_object(Hash.new(0)) do |props, acc|
|
|
326
|
+
parsed = parse_event_properties(props)
|
|
327
|
+
goal = parsed["goal"].presence || "unknown"
|
|
328
|
+
acc[goal] += 1
|
|
329
|
+
end
|
|
330
|
+
by_goal = grouped.sort_by {|_, count| -count }.first(limit).to_h
|
|
331
|
+
{ total:, by_goal: }
|
|
332
|
+
rescue StandardError
|
|
333
|
+
{ total: 0, by_goal: {} }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def exit_pages_data
|
|
337
|
+
limit = RubyCms::Settings.get(:analytics_max_exit_pages, default: DEFAULT_MAX_EXIT_PAGES).to_i
|
|
338
|
+
|
|
339
|
+
# Single DB query: subquery finds the latest event time per visit, outer query
|
|
340
|
+
# counts how often each page_name appears as the last page of a session.
|
|
341
|
+
last_times_sql = page_view_events
|
|
342
|
+
.select(:visit_id, Arel.sql("MAX(ahoy_events.time) AS max_time"))
|
|
343
|
+
.group(:visit_id)
|
|
344
|
+
.to_sql
|
|
345
|
+
|
|
346
|
+
Ahoy::Event
|
|
347
|
+
.joins("INNER JOIN (#{last_times_sql}) last_pv
|
|
348
|
+
ON ahoy_events.visit_id = last_pv.visit_id
|
|
349
|
+
AND ahoy_events.time = last_pv.max_time")
|
|
350
|
+
.where(name: EVENT_PAGE_VIEW, time: @range)
|
|
351
|
+
.where.not(page_name: [nil, ""])
|
|
352
|
+
.group(:page_name)
|
|
353
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
354
|
+
.limit(limit)
|
|
355
|
+
.count
|
|
356
|
+
rescue StandardError
|
|
357
|
+
{}
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def previous_period_start
|
|
361
|
+
@previous_period_start ||= @start_date - days_in_range.days
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def previous_period_end
|
|
365
|
+
@previous_period_end ||= @start_date - 1.second
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def previous_visits
|
|
369
|
+
base = Ahoy::Visit.where(started_at: previous_period_start..previous_period_end)
|
|
370
|
+
apply_visit_scope_hook(base)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def previous_page_view_events
|
|
374
|
+
base = Ahoy::Event
|
|
375
|
+
.where(name: EVENT_PAGE_VIEW, time: previous_period_start..previous_period_end)
|
|
376
|
+
.joins(:visit)
|
|
377
|
+
.merge(previous_visits)
|
|
378
|
+
apply_event_scope_hook(base)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def previous_period_totals
|
|
382
|
+
{
|
|
383
|
+
total_page_views: previous_page_view_events.count,
|
|
384
|
+
unique_visitors: previous_visits.distinct.count(:visitor_token),
|
|
385
|
+
total_sessions: previous_visits.distinct.count(:visit_token)
|
|
386
|
+
}
|
|
387
|
+
rescue StandardError
|
|
388
|
+
{}
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def compute_delta(current, previous)
|
|
392
|
+
return nil if previous.to_i.zero?
|
|
393
|
+
|
|
394
|
+
((current.to_f - previous.to_f) / previous.to_f * 100).round(1)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def compute_period_deltas(current_views, current_visitors, current_sessions)
|
|
398
|
+
prev = previous_period_totals
|
|
399
|
+
return {} if prev.empty?
|
|
400
|
+
|
|
401
|
+
{
|
|
402
|
+
total_page_views: compute_delta(current_views, prev[:total_page_views]),
|
|
403
|
+
unique_visitors: compute_delta(current_visitors, prev[:unique_visitors]),
|
|
404
|
+
total_sessions: compute_delta(current_sessions, prev[:total_sessions])
|
|
405
|
+
}
|
|
406
|
+
rescue StandardError
|
|
407
|
+
{}
|
|
408
|
+
end
|
|
409
|
+
|
|
301
410
|
def fill_date_gaps(data)
|
|
302
411
|
(@start_date.to_date..@end_date.to_date).each_with_object({}) do |date, acc|
|
|
303
412
|
key = date.strftime("%Y-%m-%d")
|
|
@@ -336,12 +445,12 @@ module RubyCms
|
|
|
336
445
|
total = visits.distinct.count(:visitor_token)
|
|
337
446
|
return 0 unless total.positive?
|
|
338
447
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
448
|
+
# Subquery keeps everything in the DB; avoids loading all historical tokens into Ruby.
|
|
449
|
+
returning_subquery = Ahoy::Visit
|
|
450
|
+
.where(started_at: ...@start_date)
|
|
451
|
+
.select(:visitor_token)
|
|
343
452
|
|
|
344
|
-
new_count = visits.where.not(visitor_token:
|
|
453
|
+
new_count = visits.where.not(visitor_token: returning_subquery).distinct.count(:visitor_token)
|
|
345
454
|
((new_count.to_f / total) * 100).round(0).to_i
|
|
346
455
|
rescue StandardError
|
|
347
456
|
0
|
|
@@ -392,7 +501,10 @@ module RubyCms
|
|
|
392
501
|
os_stats: visits.where.not(os: [nil, ""]).group(:os).count,
|
|
393
502
|
suspicious_activity: suspicious_activity_data,
|
|
394
503
|
recent_page_views: page_view_events.order(time: :desc).limit(recent_page_views_limit),
|
|
395
|
-
extra_cards: extra_cards_data
|
|
504
|
+
extra_cards: extra_cards_data,
|
|
505
|
+
conversions: conversion_stats_data,
|
|
506
|
+
exit_pages: exit_pages_data,
|
|
507
|
+
period_deltas: compute_period_deltas(total_views, unique_visitors, total_sessions)
|
|
396
508
|
}
|
|
397
509
|
end
|
|
398
510
|
end
|
|
@@ -10,10 +10,10 @@ module RubyCms
|
|
|
10
10
|
raise ArgumentError, "rake task blank" if task.to_s.strip.blank?
|
|
11
11
|
|
|
12
12
|
env = { "RAILS_ENV" => Rails.env.to_s }
|
|
13
|
-
argv = [
|
|
13
|
+
argv = ["bundle", "exec", "rake", task.to_s]
|
|
14
14
|
stdout_and_stderr, status = Open3.capture2e(env, *argv, chdir: Rails.root.to_s)
|
|
15
15
|
<<~TEXT.strip
|
|
16
|
-
$ #{argv.join(
|
|
16
|
+
$ #{argv.join(' ')}
|
|
17
17
|
(exit #{status.exitstatus})
|
|
18
18
|
|
|
19
19
|
#{stdout_and_stderr}
|
|
@@ -30,7 +30,7 @@ module RubyCms
|
|
|
30
30
|
|
|
31
31
|
File.open(path, "rb") do |f|
|
|
32
32
|
size = f.size
|
|
33
|
-
f.seek([
|
|
33
|
+
f.seek([0, size - max_bytes].max)
|
|
34
34
|
chunk = f.read
|
|
35
35
|
chunk.lines.last(lines.to_i.clamp(1, 10_000)).join
|
|
36
36
|
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
subtitle: t("ruby_cms.admin.analytics.subtitle", default: "Traffic and engagement overview"),
|
|
4
4
|
content_card: false
|
|
5
5
|
) do %>
|
|
6
|
-
<div class="space-y-
|
|
6
|
+
<div class="space-y-6">
|
|
7
|
+
|
|
7
8
|
<%# ── Period selector + date range ── %>
|
|
8
9
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
9
10
|
<div class="inline-flex items-center rounded-lg border border-border/60 bg-white p-1 shadow-sm">
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
<% active = params[:period] == value || (params[:period].blank? && value == @period.to_s) %>
|
|
18
19
|
<%= link_to label,
|
|
19
20
|
ruby_cms_admin_analytics_path(start_date:, end_date: Date.current, period: value),
|
|
20
|
-
class: "px-
|
|
21
|
+
class: "px-4 py-1.5 text-sm font-medium rounded-md transition-colors #{active ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}" %>
|
|
21
22
|
<% end %>
|
|
22
23
|
</div>
|
|
23
24
|
|
|
@@ -36,99 +37,205 @@
|
|
|
36
37
|
<% end %>
|
|
37
38
|
</div>
|
|
38
39
|
|
|
39
|
-
<%# ──
|
|
40
|
-
<div class="grid
|
|
41
|
-
<div class="rounded-lg border border-border/60 bg-white p-2 shadow-sm">
|
|
42
|
-
<div class="flex items-center justify-between">
|
|
43
|
-
<p class="text-sm font-medium text-muted-foreground"><%= t("ruby_cms.admin.analytics.total_page_views", default: "Page Views") %></p>
|
|
44
|
-
</div>
|
|
45
|
-
<div class="mt-1 flex items-baseline gap-2">
|
|
46
|
-
<p class="text-lg font-semibold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@total_page_views) %></p>
|
|
47
|
-
</div>
|
|
48
|
-
<p class="mt-0.5 text-[10px] text-muted-foreground">~<%= number_with_delimiter(@avg_daily_views) %> / day</p>
|
|
49
|
-
</div>
|
|
40
|
+
<%# ── Activity charts (3/4) + KPI summary card (1/4) ── %>
|
|
41
|
+
<div class="grid gap-4 items-start" style="grid-template-columns: 3fr 1fr;">
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
<div class="mt-1 flex items-baseline gap-2">
|
|
56
|
-
<p class="text-lg font-semibold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@unique_visitors) %></p>
|
|
57
|
-
<% if @new_visitor_percentage.to_i > 0 %>
|
|
58
|
-
<span class="inline-flex items-center text-xs font-medium text-emerald-600">
|
|
59
|
-
<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 10l7-7m0 0l7 7m-7-7v18"></path></svg>
|
|
60
|
-
<%= @new_visitor_percentage %>% new
|
|
61
|
-
</span>
|
|
62
|
-
<% end %>
|
|
63
|
-
</div>
|
|
64
|
-
<p class="mt-0.5 text-[10px] text-muted-foreground">Distinct visitor tokens</p>
|
|
43
|
+
<%# Left: activity charts stacked %>
|
|
44
|
+
<div class="space-y-4 min-w-0">
|
|
45
|
+
<%= render "ruby_cms/admin/analytics/partials/daily_activity_chart" %>
|
|
46
|
+
<%= render "ruby_cms/admin/analytics/partials/hourly_activity_chart" %>
|
|
65
47
|
</div>
|
|
66
48
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<div class="mt-1 flex items-baseline gap-2">
|
|
72
|
-
<p class="text-lg font-semibold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@total_sessions) %></p>
|
|
49
|
+
<%# Right: single KPI summary card %>
|
|
50
|
+
<div class="rounded-xl border border-border/60 bg-white shadow-sm overflow-hidden">
|
|
51
|
+
<div class="px-4 py-3 border-b border-border/60">
|
|
52
|
+
<p class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Overview</p>
|
|
73
53
|
</div>
|
|
74
|
-
<p class="mt-0.5 text-[10px] text-muted-foreground"><%= @pages_per_session %> pages / session</p>
|
|
75
|
-
</div>
|
|
76
54
|
|
|
77
|
-
|
|
78
|
-
<div class="
|
|
79
|
-
<
|
|
55
|
+
<%# Active users now %>
|
|
56
|
+
<div class="px-4 py-3 bg-emerald-50/60 border-b border-emerald-100">
|
|
57
|
+
<div class="flex items-center justify-between">
|
|
58
|
+
<div>
|
|
59
|
+
<p class="text-xs font-medium text-emerald-700">Nu actief</p>
|
|
60
|
+
<div class="flex items-baseline gap-1.5 mt-0.5">
|
|
61
|
+
<p class="text-2xl font-bold tracking-tight text-emerald-800 tabular-nums"><%= @active_users || 0 %></p>
|
|
62
|
+
<span class="text-xs text-emerald-600">gebruikers</span>
|
|
63
|
+
</div>
|
|
64
|
+
<p class="text-xs text-emerald-600/70 mt-0.5">Afgelopen 5 minuten</p>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-1">
|
|
67
|
+
<span class="relative flex h-2 w-2">
|
|
68
|
+
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75"></span>
|
|
69
|
+
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-600"></span>
|
|
70
|
+
</span>
|
|
71
|
+
<span class="text-xs font-medium text-emerald-700">Live</span>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
80
74
|
</div>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
75
|
+
|
|
76
|
+
<div class="divide-y divide-border/50">
|
|
77
|
+
|
|
78
|
+
<%# Page Views %>
|
|
79
|
+
<div class="px-4 py-3">
|
|
80
|
+
<div class="flex items-center justify-between mb-1">
|
|
81
|
+
<span class="text-xs font-medium text-muted-foreground"><%= t("ruby_cms.admin.analytics.total_page_views", default: "Page Views") %></span>
|
|
82
|
+
<div class="rounded-md bg-blue-50 p-1 flex-shrink-0">
|
|
83
|
+
<svg class="w-3.5 h-3.5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="flex items-baseline gap-2">
|
|
87
|
+
<p class="text-xl font-bold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@total_page_views) %></p>
|
|
88
|
+
<% if (delta = @period_deltas&.dig(:total_page_views)) %>
|
|
89
|
+
<span class="inline-flex items-center text-xs font-semibold rounded-full px-1.5 py-0.5 <%= delta >= 0 ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700' %>">
|
|
90
|
+
<%= delta >= 0 ? "↑" : "↓" %> <%= delta.abs %>%
|
|
91
|
+
</span>
|
|
92
|
+
<% end %>
|
|
93
|
+
</div>
|
|
94
|
+
<p class="mt-0.5 text-xs text-muted-foreground">~<%= number_with_delimiter(@avg_daily_views) %> per day</p>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<%# Unique Visitors %>
|
|
98
|
+
<div class="px-4 py-3">
|
|
99
|
+
<div class="flex items-center justify-between mb-1">
|
|
100
|
+
<span class="text-xs font-medium text-muted-foreground"><%= t("ruby_cms.admin.analytics.unique_visitors", default: "Unique Visitors") %></span>
|
|
101
|
+
<div class="rounded-md bg-violet-50 p-1 flex-shrink-0">
|
|
102
|
+
<svg class="w-3.5 h-3.5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="flex items-baseline gap-2">
|
|
106
|
+
<p class="text-xl font-bold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@unique_visitors) %></p>
|
|
107
|
+
<% if (delta = @period_deltas&.dig(:unique_visitors)) %>
|
|
108
|
+
<span class="inline-flex items-center text-xs font-semibold rounded-full px-1.5 py-0.5 <%= delta >= 0 ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700' %>">
|
|
109
|
+
<%= delta >= 0 ? "↑" : "↓" %> <%= delta.abs %>%
|
|
110
|
+
</span>
|
|
111
|
+
<% elsif @new_visitor_percentage.to_i > 0 %>
|
|
112
|
+
<span class="inline-flex items-center text-xs font-semibold rounded-full px-1.5 py-0.5 bg-emerald-50 text-emerald-700">
|
|
113
|
+
<%= @new_visitor_percentage %>% new
|
|
114
|
+
</span>
|
|
115
|
+
<% end %>
|
|
116
|
+
</div>
|
|
117
|
+
<p class="mt-0.5 text-xs text-muted-foreground">Distinct visitor tokens</p>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<%# Sessions %>
|
|
121
|
+
<div class="px-4 py-3">
|
|
122
|
+
<div class="flex items-center justify-between mb-1">
|
|
123
|
+
<span class="text-xs font-medium text-muted-foreground"><%= t("ruby_cms.admin.analytics.total_sessions", default: "Sessions") %></span>
|
|
124
|
+
<div class="rounded-md bg-emerald-50 p-1 flex-shrink-0">
|
|
125
|
+
<svg class="w-3.5 h-3.5 text-emerald-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"/></svg>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="flex items-baseline gap-2">
|
|
129
|
+
<p class="text-xl font-bold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@total_sessions) %></p>
|
|
130
|
+
<% if (delta = @period_deltas&.dig(:total_sessions)) %>
|
|
131
|
+
<span class="inline-flex items-center text-xs font-semibold rounded-full px-1.5 py-0.5 <%= delta >= 0 ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700' %>">
|
|
132
|
+
<%= delta >= 0 ? "↑" : "↓" %> <%= delta.abs %>%
|
|
133
|
+
</span>
|
|
134
|
+
<% end %>
|
|
135
|
+
</div>
|
|
136
|
+
<p class="mt-0.5 text-xs text-muted-foreground"><%= @pages_per_session %> pages per session</p>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<%# Bounce Rate %>
|
|
140
|
+
<div class="px-4 py-3">
|
|
141
|
+
<div class="flex items-center justify-between mb-1">
|
|
142
|
+
<span class="text-xs font-medium text-muted-foreground"><%= t("ruby_cms.admin.analytics.bounce_rate", default: "Bounce Rate") %></span>
|
|
143
|
+
<div class="rounded-md p-1 flex-shrink-0 <%= @bounce_rate.to_f > 70 ? 'bg-red-50' : @bounce_rate.to_f > 50 ? 'bg-amber-50' : 'bg-emerald-50' %>">
|
|
144
|
+
<svg class="w-3.5 h-3.5 <%= @bounce_rate.to_f > 70 ? 'text-red-600' : @bounce_rate.to_f > 50 ? 'text-amber-600' : 'text-emerald-600' %>" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="flex items-baseline gap-2">
|
|
148
|
+
<p class="text-xl font-bold tracking-tight text-foreground tabular-nums"><%= @bounce_rate %>%</p>
|
|
149
|
+
<span class="inline-flex items-center text-xs font-semibold rounded-full px-1.5 py-0.5 <%= @bounce_rate.to_f > 70 ? 'bg-red-50 text-red-700' : @bounce_rate.to_f > 50 ? 'bg-amber-50 text-amber-700' : 'bg-emerald-50 text-emerald-700' %>">
|
|
150
|
+
<%= @bounce_rate.to_f > 70 ? "High" : @bounce_rate.to_f > 50 ? "Average" : "Good" %>
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
<p class="mt-0.5 text-xs text-muted-foreground">Single-page sessions</p>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
86
156
|
</div>
|
|
87
|
-
<p class="mt-0.5 text-[10px] text-muted-foreground">Single-page sessions</p>
|
|
88
157
|
</div>
|
|
158
|
+
|
|
89
159
|
</div>
|
|
90
160
|
|
|
91
|
-
<%# ──
|
|
92
|
-
<div class="
|
|
93
|
-
|
|
94
|
-
|
|
161
|
+
<%# ── Section: Content ── %>
|
|
162
|
+
<div class="flex items-center gap-3">
|
|
163
|
+
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground whitespace-nowrap">Content</span>
|
|
164
|
+
<div class="flex-1 h-px bg-border/50"></div>
|
|
95
165
|
</div>
|
|
96
166
|
|
|
97
|
-
|
|
98
|
-
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2">
|
|
167
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
99
168
|
<div class="lg:col-span-2">
|
|
100
169
|
<%= render "ruby_cms/admin/analytics/partials/popular_pages" %>
|
|
101
170
|
</div>
|
|
102
171
|
<%= render "ruby_cms/admin/analytics/partials/top_visitors" %>
|
|
103
172
|
</div>
|
|
104
173
|
|
|
105
|
-
<%# ──
|
|
106
|
-
<div class="
|
|
174
|
+
<%# ── Section: Traffic Sources ── %>
|
|
175
|
+
<div class="flex items-center gap-3">
|
|
176
|
+
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground whitespace-nowrap">Traffic Sources</span>
|
|
177
|
+
<div class="flex-1 h-px bg-border/50"></div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
107
181
|
<%= render "ruby_cms/admin/analytics/partials/top_referrers" %>
|
|
108
182
|
<%= render "ruby_cms/admin/analytics/partials/landing_pages" %>
|
|
109
183
|
<%= render "ruby_cms/admin/analytics/partials/utm_sources" %>
|
|
110
184
|
</div>
|
|
111
185
|
|
|
112
|
-
<%# ──
|
|
113
|
-
<div class="
|
|
186
|
+
<%# ── Section: Audience & Technology ── %>
|
|
187
|
+
<div class="flex items-center gap-3">
|
|
188
|
+
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground whitespace-nowrap">Audience & Technology</span>
|
|
189
|
+
<div class="flex-1 h-px bg-border/50"></div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
114
193
|
<%= render "ruby_cms/admin/analytics/partials/browser_device" %>
|
|
115
194
|
<%= render "ruby_cms/admin/analytics/partials/os_stats" %>
|
|
116
195
|
<%= render "ruby_cms/admin/analytics/partials/recent_activity" %>
|
|
117
196
|
</div>
|
|
118
197
|
|
|
198
|
+
<%# ── Section: Behavior ── %>
|
|
199
|
+
<div class="flex items-center gap-3">
|
|
200
|
+
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground whitespace-nowrap">Behavior</span>
|
|
201
|
+
<div class="flex-1 h-px bg-border/50"></div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
205
|
+
<% if lookup_context.exists?("ruby_cms/admin/analytics/partials/exit_pages", [], true) %>
|
|
206
|
+
<%= render "ruby_cms/admin/analytics/partials/exit_pages" %>
|
|
207
|
+
<% else %>
|
|
208
|
+
<div class="rounded-xl border border-border/60 bg-white p-4 shadow-sm">
|
|
209
|
+
<p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.exit_pages", default: "Exit Pages") %></p>
|
|
210
|
+
<p class="mt-1 text-xs text-muted-foreground"><%= t("ruby_cms.admin.analytics.not_available", default: "Not available in this version.") %></p>
|
|
211
|
+
</div>
|
|
212
|
+
<% end %>
|
|
213
|
+
|
|
214
|
+
<% if lookup_context.exists?("ruby_cms/admin/analytics/partials/conversions", [], true) %>
|
|
215
|
+
<%= render "ruby_cms/admin/analytics/partials/conversions" %>
|
|
216
|
+
<% else %>
|
|
217
|
+
<div class="rounded-xl border border-border/60 bg-white p-4 shadow-sm">
|
|
218
|
+
<p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.conversions", default: "Conversions") %></p>
|
|
219
|
+
<p class="mt-1 text-xs text-muted-foreground"><%= t("ruby_cms.admin.analytics.not_available", default: "Not available in this version.") %></p>
|
|
220
|
+
</div>
|
|
221
|
+
<% end %>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
119
224
|
<%# ── Suspicious activity ── %>
|
|
120
225
|
<% if @suspicious_activity.present? %>
|
|
121
|
-
<div class="rounded-
|
|
122
|
-
<div class="px-
|
|
123
|
-
<
|
|
124
|
-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
226
|
+
<div class="rounded-xl border border-amber-200 bg-amber-50/60 shadow-sm overflow-hidden">
|
|
227
|
+
<div class="px-4 py-3 border-b border-amber-200/60 flex items-center gap-3">
|
|
228
|
+
<div class="rounded-md bg-amber-100 p-1.5 flex-shrink-0">
|
|
229
|
+
<svg class="w-4 h-4 text-amber-700" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
|
230
|
+
</div>
|
|
231
|
+
<div>
|
|
232
|
+
<p class="text-sm font-semibold text-amber-900"><%= t("ruby_cms.admin.analytics.suspicious_activity", default: "Suspicious activity") %></p>
|
|
233
|
+
<p class="text-xs text-amber-700/80">Potential bot-like traffic signals</p>
|
|
234
|
+
</div>
|
|
128
235
|
</div>
|
|
129
|
-
<div class="
|
|
236
|
+
<div class="divide-y divide-amber-200/40">
|
|
130
237
|
<% @suspicious_activity.first(6).each do |item| %>
|
|
131
|
-
<div class="flex items-center justify-between gap-3 py-2
|
|
238
|
+
<div class="flex items-center justify-between gap-3 px-4 py-2.5">
|
|
132
239
|
<span class="text-sm text-amber-800"><%= item[:description] %></span>
|
|
133
240
|
<span class="text-sm font-semibold tabular-nums text-amber-900"><%= number_with_delimiter(item[:count]) %></span>
|
|
134
241
|
</div>
|
|
@@ -139,17 +246,18 @@
|
|
|
139
246
|
|
|
140
247
|
<%# ── Extra cards hook ── %>
|
|
141
248
|
<% if Array(@extra_cards).any? %>
|
|
142
|
-
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-
|
|
249
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
143
250
|
<% @extra_cards.each do |card| %>
|
|
144
|
-
<div class="rounded-
|
|
251
|
+
<div class="rounded-xl border border-border/60 bg-white p-4 shadow-sm">
|
|
145
252
|
<p class="text-sm font-medium text-muted-foreground"><%= card[:title].to_s %></p>
|
|
146
|
-
<p class="mt-
|
|
253
|
+
<p class="mt-2 text-2xl font-bold tracking-tight text-foreground"><%= card[:value].to_s %></p>
|
|
147
254
|
<% if card[:description].present? %>
|
|
148
|
-
<p class="mt-1 text-
|
|
255
|
+
<p class="mt-1.5 text-xs text-muted-foreground"><%= card[:description] %></p>
|
|
149
256
|
<% end %>
|
|
150
257
|
</div>
|
|
151
258
|
<% end %>
|
|
152
259
|
</div>
|
|
153
260
|
<% end %>
|
|
261
|
+
|
|
154
262
|
</div>
|
|
155
263
|
<% end %>
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
<div class="rounded-
|
|
2
|
-
<div class="px-4 py-3 border-b border-border/40">
|
|
3
|
-
<
|
|
4
|
-
|
|
1
|
+
<div class="rounded-xl border border-border/60 bg-white shadow-sm overflow-hidden">
|
|
2
|
+
<div class="flex items-center gap-3 px-4 py-3 border-b border-border/40">
|
|
3
|
+
<div class="rounded-md bg-slate-100 p-1.5 flex-shrink-0">
|
|
4
|
+
<svg class="w-4 h-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
|
5
|
+
</div>
|
|
6
|
+
<div>
|
|
7
|
+
<p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.browsers_devices", default: "Browsers & Devices") %></p>
|
|
8
|
+
<p class="text-xs text-muted-foreground">Environment breakdown</p>
|
|
9
|
+
</div>
|
|
5
10
|
</div>
|
|
6
11
|
|
|
7
|
-
<div class="px-4 py-3 space-y-
|
|
12
|
+
<div class="px-4 py-3 space-y-4">
|
|
8
13
|
<% if @browser_stats.present? && @browser_stats.any? %>
|
|
9
14
|
<div>
|
|
10
|
-
<p class="text-xs font-
|
|
15
|
+
<p class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2.5">Browsers</p>
|
|
11
16
|
<% total = @browser_stats.values.sum.to_f %>
|
|
12
17
|
<div class="space-y-2.5">
|
|
13
18
|
<% @browser_stats.sort_by { |_, v| -v }.first(4).each do |browser, count| %>
|
|
@@ -20,8 +25,8 @@
|
|
|
20
25
|
<span class="text-sm font-semibold tabular-nums text-foreground"><%= number_with_delimiter(count) %></span>
|
|
21
26
|
</div>
|
|
22
27
|
</div>
|
|
23
|
-
<div class="w-full h-1 bg-muted rounded-full overflow-hidden">
|
|
24
|
-
<div class="h-full bg-
|
|
28
|
+
<div class="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
|
29
|
+
<div class="h-full bg-slate-400 rounded-full" style="width: <%= pct %>%"></div>
|
|
25
30
|
</div>
|
|
26
31
|
</div>
|
|
27
32
|
<% end %>
|
|
@@ -31,14 +36,14 @@
|
|
|
31
36
|
|
|
32
37
|
<% if @device_stats.present? && @device_stats.any? %>
|
|
33
38
|
<div>
|
|
34
|
-
<p class="text-xs font-
|
|
39
|
+
<p class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2.5">Devices</p>
|
|
35
40
|
<% total = @device_stats.values.sum.to_f %>
|
|
36
|
-
<div class="
|
|
37
|
-
<% @device_stats.sort_by { |_, v| -v }.first(
|
|
41
|
+
<div class="grid grid-cols-3 gap-2">
|
|
42
|
+
<% @device_stats.sort_by { |_, v| -v }.first(3).each do |device, count| %>
|
|
38
43
|
<% pct = total.positive? ? (count / total * 100).round(0) : 0 %>
|
|
39
|
-
|
|
40
|
-
<p class="text-lg font-
|
|
41
|
-
<p class="text-xs text-muted-foreground mt-0.5"><%= device %></p>
|
|
44
|
+
<div class="rounded-lg border border-border/40 bg-muted/30 p-2.5 text-center">
|
|
45
|
+
<p class="text-lg font-bold text-foreground tabular-nums"><%= pct %>%</p>
|
|
46
|
+
<p class="text-xs text-muted-foreground mt-0.5 truncate"><%= device %></p>
|
|
42
47
|
</div>
|
|
43
48
|
<% end %>
|
|
44
49
|
</div>
|
|
@@ -46,7 +51,8 @@
|
|
|
46
51
|
<% end %>
|
|
47
52
|
|
|
48
53
|
<% if (@browser_stats.blank? || @browser_stats.empty?) && (@device_stats.blank? || @device_stats.empty?) %>
|
|
49
|
-
<div class="py-6
|
|
54
|
+
<div class="flex flex-col items-center justify-center py-6 gap-2">
|
|
55
|
+
<svg class="w-8 h-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
|
50
56
|
<p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.no_data", default: "No data yet.") %></p>
|
|
51
57
|
</div>
|
|
52
58
|
<% end %>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<div class="rounded-xl border border-border/60 bg-white shadow-sm overflow-hidden">
|
|
2
|
+
<div class="flex items-center justify-between gap-3 px-4 py-3 border-b border-border/40">
|
|
3
|
+
<div class="flex items-center gap-3">
|
|
4
|
+
<div class="rounded-md bg-emerald-50 p-1.5 flex-shrink-0">
|
|
5
|
+
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
6
|
+
</div>
|
|
7
|
+
<div>
|
|
8
|
+
<p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.conversions", default: "Conversions") %></p>
|
|
9
|
+
<p class="text-xs text-muted-foreground"><%= t("ruby_cms.admin.analytics.conversions_subtitle", default: "Goals tracked via ahoy.track in your app") %></p>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
<% if @conversions&.dig(:total).to_i > 0 %>
|
|
13
|
+
<span class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700 border border-emerald-200 flex-shrink-0">
|
|
14
|
+
<%= number_with_delimiter(@conversions[:total]) %> <%= t("ruby_cms.admin.analytics.conversions_total", default: "total") %>
|
|
15
|
+
</span>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<% goals = @conversions&.dig(:by_goal) %>
|
|
20
|
+
<% if goals.present? && goals.any? %>
|
|
21
|
+
<% total = goals.values.sum.to_f %>
|
|
22
|
+
<div class="divide-y divide-border/30">
|
|
23
|
+
<% goals.each do |goal, count| %>
|
|
24
|
+
<% pct = total.positive? ? (count / total * 100).round(1) : 0 %>
|
|
25
|
+
<div class="relative flex items-center justify-between gap-3 px-4 py-2.5">
|
|
26
|
+
<div class="absolute inset-y-0 left-0 bg-emerald-500/5" style="width: <%= pct %>%"></div>
|
|
27
|
+
<div class="relative z-10 flex items-center gap-2 min-w-0">
|
|
28
|
+
<div class="w-1.5 h-1.5 rounded-full bg-emerald-400 flex-shrink-0"></div>
|
|
29
|
+
<p class="text-sm text-foreground truncate"><%= goal.to_s.humanize %></p>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="relative z-10 flex items-center gap-2 flex-shrink-0">
|
|
32
|
+
<span class="text-xs text-muted-foreground tabular-nums"><%= pct %>%</span>
|
|
33
|
+
<span class="text-sm font-semibold tabular-nums text-foreground"><%= number_with_delimiter(count) %></span>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
37
|
+
</div>
|
|
38
|
+
<% else %>
|
|
39
|
+
<div class="flex flex-col items-center justify-center px-4 py-10 gap-3">
|
|
40
|
+
<svg class="w-8 h-8 text-muted-foreground/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
41
|
+
<div class="text-center">
|
|
42
|
+
<p class="text-sm font-medium text-foreground"><%= t("ruby_cms.admin.analytics.no_conversions", default: "No conversions recorded.") %></p>
|
|
43
|
+
<p class="mt-2 text-xs font-mono bg-muted/60 border border-border/40 rounded-md px-3 py-1.5 inline-block text-muted-foreground">
|
|
44
|
+
ahoy.track "<%= RubyCms::Analytics::Report::EVENT_CONVERSION %>", goal: "contact_form"
|
|
45
|
+
</p>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<% end %>
|
|
49
|
+
</div>
|