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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +9 -1
  4. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +3 -3
  5. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +8 -8
  6. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +15 -5
  7. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +57 -12
  8. data/app/controllers/concerns/ruby_cms/page_tracking.rb +15 -0
  9. data/app/controllers/ruby_cms/admin/analytics_controller.rb +12 -0
  10. data/app/controllers/ruby_cms/admin/commands_controller.rb +6 -6
  11. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +65 -13
  12. data/app/services/ruby_cms/analytics/report.rb +119 -7
  13. data/app/services/ruby_cms/command_runner.rb +3 -3
  14. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -67
  15. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +21 -15
  16. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +36 -16
  17. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +29 -13
  18. data/app/views/ruby_cms/admin/analytics/partials/_landing_pages.html.erb +27 -15
  19. data/app/views/ruby_cms/admin/analytics/partials/_os_stats.html.erb +29 -18
  20. data/app/views/ruby_cms/admin/analytics/partials/_popular_pages.html.erb +17 -12
  21. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +25 -14
  22. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +27 -15
  23. data/app/views/ruby_cms/admin/analytics/partials/_top_visitors.html.erb +20 -10
  24. data/app/views/ruby_cms/admin/analytics/partials/_utm_sources.html.erb +27 -15
  25. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +8 -1
  26. data/config/locales/en.yml +69 -5
  27. data/config/locales/nl.yml +98 -0
  28. data/lib/generators/ruby_cms/install_generator.rb +21 -6
  29. data/lib/generators/ruby_cms/templates/ruby_cms.rb +40 -3
  30. data/lib/ruby_cms/settings_registry.rb +35 -0
  31. data/lib/ruby_cms/version.rb +1 -1
  32. 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: "page_view", time: @range).joins(:visit).merge(visits)
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
- returning_tokens = Ahoy::Visit
340
- .where(started_at: ...@start_date)
341
- .distinct
342
- .pluck(:visitor_token)
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: returning_tokens).distinct.count(: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 = [ "bundle", "exec", "rake", task.to_s ]
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([ 0, size - max_bytes ].max)
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-4">
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-3 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
+ 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
- <%# ── Stat cards ── %>
40
- <div class="grid grid-cols-2 lg:grid-cols-4 gap-2">
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
- <div class="rounded-lg border border-border/60 bg-white p-2 shadow-sm">
52
- <div class="flex items-center justify-between">
53
- <p class="text-sm font-medium text-muted-foreground"><%= t("ruby_cms.admin.analytics.unique_visitors", default: "Unique Visitors") %></p>
54
- </div>
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
- <div class="rounded-lg border border-border/60 bg-white p-2 shadow-sm">
68
- <div class="flex items-center justify-between">
69
- <p class="text-sm font-medium text-muted-foreground"><%= t("ruby_cms.admin.analytics.total_sessions", default: "Sessions") %></p>
70
- </div>
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
- <div class="rounded-lg border border-border/60 bg-white p-2 shadow-sm">
78
- <div class="flex items-center justify-between">
79
- <p class="text-sm font-medium text-muted-foreground"><%= t("ruby_cms.admin.analytics.bounce_rate", default: "Bounce Rate") %></p>
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
- <div class="mt-1 flex items-baseline gap-2">
82
- <p class="text-lg font-semibold tracking-tight text-foreground tabular-nums"><%= @bounce_rate %>%</p>
83
- <span class="inline-flex items-center text-xs font-medium <%= @bounce_rate.to_f > 70 ? 'text-destructive' : @bounce_rate.to_f > 50 ? 'text-amber-600' : 'text-emerald-600' %>">
84
- <%= @bounce_rate.to_f > 70 ? "High" : @bounce_rate.to_f > 50 ? "Average" : "Good" %>
85
- </span>
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
- <%# ── Charts ── %>
92
- <div class="grid grid-cols-1 xl:grid-cols-2 gap-2">
93
- <%= render "ruby_cms/admin/analytics/partials/daily_activity_chart" %>
94
- <%= render "ruby_cms/admin/analytics/partials/hourly_activity_chart" %>
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
- <%# ── Content: pages + visitors ── %>
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
- <%# ── Sources & tech ── %>
106
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
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
- <%# ── Tech & activity ── %>
113
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
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-lg border border-amber-200 bg-amber-50/50 shadow-sm">
122
- <div class="px-3 py-2 border-b border-amber-200/60">
123
- <p class="text-sm font-semibold text-amber-900 flex items-center gap-2">
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"></path></svg>
125
- <%= t("ruby_cms.admin.analytics.suspicious_activity", default: "Suspicious activity") %>
126
- </p>
127
- <p class="text-sm text-amber-700/80 mt-0.5">Potential bot-like traffic signals.</p>
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="px-3 py-2 divide-y divide-amber-200/40">
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 first:pt-0 last:pb-0">
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-2">
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-lg border border-border/60 bg-white p-3 shadow-sm">
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-1 text-xl font-semibold tracking-tight text-foreground"><%= card[:value].to_s %></p>
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-[11px] text-muted-foreground"><%= card[:description] %></p>
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-lg border border-border/60 bg-white shadow-sm">
2
- <div class="px-4 py-3 border-b border-border/40">
3
- <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.browsers_devices", default: "Browsers & Devices") %></p>
4
- <p class="text-sm text-muted-foreground">Environment breakdown.</p>
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-3">
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-medium uppercase tracking-wider text-muted-foreground mb-2">Browsers</p>
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-primary/60 rounded-full" style="width: <%= pct %>%"></div>
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-medium uppercase tracking-wider text-muted-foreground mb-2">Devices</p>
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="flex gap-3">
37
- <% @device_stats.sort_by { |_, v| -v }.first(4).each do |device, count| %>
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
- <div class="flex-1 rounded-lg border border-border/40 bg-muted/30 p-2.5 text-center">
40
- <p class="text-lg font-semibold text-foreground tabular-nums"><%= pct %>%</p>
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 text-center">
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 %>