ruby_cms 0.2.0.5 → 0.2.0.8
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 +160 -67
- data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +21 -15
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +36 -16
- 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/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 +1 -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,190 @@
|
|
|
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
|
+
<%= render "ruby_cms/admin/analytics/partials/exit_pages" %>
|
|
206
|
+
<%= render "ruby_cms/admin/analytics/partials/conversions" %>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
119
209
|
<%# ── Suspicious activity ── %>
|
|
120
210
|
<% 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
|
-
|
|
211
|
+
<div class="rounded-xl border border-amber-200 bg-amber-50/60 shadow-sm overflow-hidden">
|
|
212
|
+
<div class="px-4 py-3 border-b border-amber-200/60 flex items-center gap-3">
|
|
213
|
+
<div class="rounded-md bg-amber-100 p-1.5 flex-shrink-0">
|
|
214
|
+
<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>
|
|
215
|
+
</div>
|
|
216
|
+
<div>
|
|
217
|
+
<p class="text-sm font-semibold text-amber-900"><%= t("ruby_cms.admin.analytics.suspicious_activity", default: "Suspicious activity") %></p>
|
|
218
|
+
<p class="text-xs text-amber-700/80">Potential bot-like traffic signals</p>
|
|
219
|
+
</div>
|
|
128
220
|
</div>
|
|
129
|
-
<div class="
|
|
221
|
+
<div class="divide-y divide-amber-200/40">
|
|
130
222
|
<% @suspicious_activity.first(6).each do |item| %>
|
|
131
|
-
<div class="flex items-center justify-between gap-3 py-2
|
|
223
|
+
<div class="flex items-center justify-between gap-3 px-4 py-2.5">
|
|
132
224
|
<span class="text-sm text-amber-800"><%= item[:description] %></span>
|
|
133
225
|
<span class="text-sm font-semibold tabular-nums text-amber-900"><%= number_with_delimiter(item[:count]) %></span>
|
|
134
226
|
</div>
|
|
@@ -139,17 +231,18 @@
|
|
|
139
231
|
|
|
140
232
|
<%# ── Extra cards hook ── %>
|
|
141
233
|
<% if Array(@extra_cards).any? %>
|
|
142
|
-
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-
|
|
234
|
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
143
235
|
<% @extra_cards.each do |card| %>
|
|
144
|
-
<div class="rounded-
|
|
236
|
+
<div class="rounded-xl border border-border/60 bg-white p-4 shadow-sm">
|
|
145
237
|
<p class="text-sm font-medium text-muted-foreground"><%= card[:title].to_s %></p>
|
|
146
|
-
<p class="mt-
|
|
238
|
+
<p class="mt-2 text-2xl font-bold tracking-tight text-foreground"><%= card[:value].to_s %></p>
|
|
147
239
|
<% if card[:description].present? %>
|
|
148
|
-
<p class="mt-1 text-
|
|
240
|
+
<p class="mt-1.5 text-xs text-muted-foreground"><%= card[:description] %></p>
|
|
149
241
|
<% end %>
|
|
150
242
|
</div>
|
|
151
243
|
<% end %>
|
|
152
244
|
</div>
|
|
153
245
|
<% end %>
|
|
246
|
+
|
|
154
247
|
</div>
|
|
155
248
|
<% 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 %>
|