ruby_cms 0.1.2 → 0.1.3

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -0
  3. data/app/components/ruby_cms/admin/admin_page_header.rb +2 -4
  4. data/app/components/ruby_cms/admin/admin_resource_card.rb +2 -2
  5. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +37 -24
  6. data/app/helpers/ruby_cms/content_blocks_helper.rb +7 -3
  7. data/app/helpers/ruby_cms/settings_helper.rb +19 -17
  8. data/app/models/ruby_cms/permission.rb +1 -1
  9. data/app/services/ruby_cms/analytics/report.rb +37 -3
  10. data/app/views/ruby_cms/admin/analytics/index.html.erb +103 -108
  11. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +28 -32
  12. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -2
  13. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +34 -20
  14. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +27 -27
  15. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +19 -19
  16. data/app/views/ruby_cms/admin/analytics/partials/_landing_pages.html.erb +21 -0
  17. data/app/views/ruby_cms/admin/analytics/partials/_os_stats.html.erb +26 -0
  18. data/app/views/ruby_cms/admin/analytics/partials/_popular_pages.html.erb +34 -0
  19. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +16 -14
  20. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +3 -3
  21. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +14 -14
  22. data/app/views/ruby_cms/admin/analytics/partials/_top_visitors.html.erb +28 -0
  23. data/app/views/ruby_cms/admin/analytics/partials/_utm_sources.html.erb +21 -0
  24. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +44 -48
  25. data/lib/generators/ruby_cms/install_generator.rb +2 -1
  26. data/lib/ruby_cms/cli.rb +1 -1
  27. data/lib/ruby_cms/version.rb +1 -1
  28. data/lib/tasks/admin.rake +120 -0
  29. data/log/test.log +7284 -0
  30. metadata +7 -2
  31. data/lib/tasks/ruby_cms.rake +0 -27
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1a4b15129e38c223a849cc78aff0bd173af8a104a07acc0f7195298475f4420
4
- data.tar.gz: a9ea71bfc0ddcb2a89ba1ec85acd3fd7f9cc03799aa91e0e1eb805a081b1b6aa
3
+ metadata.gz: b498f6774c5c9d39e255eb655c136925ac8af538a48edb49c9db3421c17388bb
4
+ data.tar.gz: e9d077f831552bdf815afa1af9468b5dd895fed1f809cb7f2a748c44492c6c66
5
5
  SHA512:
6
- metadata.gz: a2b7113dbd5bae7fd3233398459efca6c3f497f54d22f63d9a4b7db0577d27cb4db73abf4ebb93b3f981ca5f33d34f458af08bc75bf74bb0dfb1f5cf02783625
7
- data.tar.gz: 33442c384ce70c04a1a7420632225e3a282c9929b29d28a76b92dcb76dd19d5ab15957e0c4b70bfca34dcb59b1f5ab72d7c2e388b0d2b50913dc9f67d3a30350
6
+ metadata.gz: 057ace1576f400a416183a52bb14651c71bab4384ceeddcbf951b1ab17d781c8548fe41af9b45ccdadef33515f42399241568ba480d69cbb056adcc46e1e5a5a
7
+ data.tar.gz: 7a02feff4b17fc21391c50c0c745e90a9e4d653cf53d1ed0c7ab6f74ddb599787585fd9353c623fa7a9c4457e034ab286d8e9e90b0e1fe7017da67cce11c82ce
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.3] - 2026-03-23
4
+
5
+ - Update analytics pages (dashboard/detail views)
6
+ - Update admin settings UI (including bulk action table)
7
+
3
8
  ## [0.1.2] - 2026-03-23
4
9
 
5
10
  - Update settings page and the bulk action table
@@ -71,10 +71,8 @@ module RubyCms
71
71
  end
72
72
  end
73
73
 
74
- def render_actions(&block)
75
- div(class: "flex items-center gap-3 flex-shrink-0") do
76
- yield
77
- end
74
+ def render_actions(&)
75
+ div(class: "flex items-center gap-3 flex-shrink-0", &)
78
76
  end
79
77
  end
80
78
  end
@@ -47,8 +47,8 @@ module RubyCms
47
47
  CANCEL_CLASS = "inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg border border-border bg-background text-foreground hover:bg-muted transition-colors"
48
48
  SUBMIT_CLASS = "inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm transition-colors"
49
49
 
50
- def view_template(&block)
51
- div(class: CARD_CLASS, &block)
50
+ def view_template(&)
51
+ div(class: CARD_CLASS, &)
52
52
  end
53
53
  end
54
54
  end
@@ -42,36 +42,49 @@ module RubyCms
42
42
 
43
43
  def render_selection_info
44
44
  div(class: "flex items-center gap-3") do
45
- div(class: "flex items-center gap-2") do
46
- div(class: "size-6 rounded-full bg-primary/10 flex items-center justify-center") do
47
- span(
48
- class: "text-xs font-bold text-primary tabular-nums",
49
- data: {
50
- "#{@controller_name}-target": "selectedCount"
51
- }
52
- ) { "0" }
53
- end
54
- span(class: "text-sm font-medium text-foreground") { "selected" }
55
- end
45
+ render_selection_left
56
46
  div(class: "h-4 w-px bg-border/60")
57
- button(
58
- type: "button",
59
- class: "text-xs font-medium text-muted-foreground hover:text-foreground transition-colors",
60
- data: {
61
- "#{@controller_name}-target": "selectAllButton",
62
- action: "click->#{@controller_name}#selectAll"
63
- }
64
- ) { "Select all" }
65
- button(
66
- type: "button",
67
- class: "text-xs font-medium text-muted-foreground hover:text-foreground transition-colors",
47
+ render_selection_buttons
48
+ end
49
+ end
50
+
51
+ def render_selection_left
52
+ div(class: "flex items-center gap-2") do
53
+ render_selected_count_badge
54
+ span(class: "text-sm font-medium text-foreground") { "selected" }
55
+ end
56
+ end
57
+
58
+ def render_selected_count_badge
59
+ div(class: "size-6 rounded-full bg-primary/10 flex items-center justify-center") do
60
+ span(
61
+ class: "text-xs font-bold text-primary tabular-nums",
68
62
  data: {
69
- action: "click->#{@controller_name}#clearSelection"
63
+ "#{@controller_name}-target": "selectedCount"
70
64
  }
71
- ) { "Clear" }
65
+ ) { "0" }
72
66
  end
73
67
  end
74
68
 
69
+ def render_selection_buttons
70
+ button(
71
+ type: "button",
72
+ class: "text-xs font-medium text-muted-foreground hover:text-foreground transition-colors",
73
+ data: {
74
+ "#{@controller_name}-target": "selectAllButton",
75
+ action: "click->#{@controller_name}#selectAll"
76
+ }
77
+ ) { "Select all" }
78
+
79
+ button(
80
+ type: "button",
81
+ class: "text-xs font-medium text-muted-foreground hover:text-foreground transition-colors",
82
+ data: {
83
+ action: "click->#{@controller_name}#clearSelection"
84
+ }
85
+ ) { "Clear" }
86
+ end
87
+
75
88
  def render_action_buttons
76
89
  div(class: "flex items-center gap-2") do
77
90
  @bulk_action_buttons.each do |button_config|
@@ -114,14 +114,14 @@ module RubyCms
114
114
  def build_css_class(options)
115
115
  user_class = options.delete(:class)
116
116
  return user_class.to_s if user_class.present? && !visual_editor_preview_edit_mode?
117
- return user_class.to_s if !visual_editor_preview_edit_mode?
117
+ return user_class.to_s unless visual_editor_preview_edit_mode?
118
118
 
119
119
  ["ruby_cms-content-block", "content-block", user_class].compact.join(" ")
120
120
  end
121
121
 
122
122
  def build_data_attributes(key, options)
123
123
  user_data = options.delete(:data).to_h
124
- return user_data if !visual_editor_preview_edit_mode?
124
+ return user_data unless visual_editor_preview_edit_mode?
125
125
 
126
126
  { content_key: key, block_id: key.to_s }.merge(user_data)
127
127
  end
@@ -133,7 +133,11 @@ module RubyCms
133
133
  return false unless controller.respond_to?(:action_name) && controller.action_name.to_s == "page_preview"
134
134
 
135
135
  begin
136
- defined?(@edit_mode) ? (@edit_mode ? true : false) : true
136
+ if controller.instance_variable_defined?(:@edit_mode)
137
+ controller.instance_variable_get(:@edit_mode) ? true : false
138
+ else
139
+ true
140
+ end
137
141
  rescue StandardError
138
142
  true
139
143
  end
@@ -40,7 +40,7 @@ module RubyCms
40
40
  return nil unless key_str.start_with?("nav_show_")
41
41
 
42
42
  nav_key = key_str.delete_prefix("nav_show_")
43
- item = RubyCms.nav_registry.find { |e| e[:key].to_s == nav_key }
43
+ item = RubyCms.nav_registry.find {|e| e[:key].to_s == nav_key }
44
44
  item&.dig(:icon)
45
45
  end
46
46
 
@@ -85,31 +85,33 @@ module RubyCms
85
85
 
86
86
  def render_boolean_setting_field(entry:, value:, tab:)
87
87
  checked = ActiveModel::Type::Boolean.new.cast(value)
88
+ input_id = setting_input_id(entry)
89
+
88
90
  hidden = hidden_field_tag("preferences[#{entry.key}]", "false")
89
91
  checkbox = check_box_tag(
90
92
  "preferences[#{entry.key}]",
91
93
  "true",
92
94
  checked,
93
- id: setting_input_id(entry),
95
+ id: input_id,
94
96
  class: "peer sr-only",
95
97
  data: autosave_data(entry.key, tab)
96
98
  )
97
- label = content_tag(
98
- :label,
99
- "",
100
- for: setting_input_id(entry),
101
- class: "relative inline-flex h-7 w-12 cursor-pointer items-center rounded-full " \
102
- "border border-border bg-muted transition-colors peer-checked:bg-primary peer-checked:border-primary"
103
- ) do
104
- content_tag(
105
- :span,
106
- "",
107
- class: "inline-block h-5 w-5 transform rounded-full bg-background shadow-sm ring-1 ring-border transition " \
108
- "translate-x-1 peer-checked:translate-x-6"
109
- )
110
- end
111
99
 
112
- content_tag(:div, class: "inline-flex items-center shrink-0") { hidden + checkbox + label }
100
+ track = content_tag(:label, "", for: input_id,
101
+ class: "relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full " \
102
+ "border-2 border-transparent bg-input transition-colors " \
103
+ "peer-checked:bg-primary " \
104
+ "peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-ring " \
105
+ "peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background")
106
+
107
+ thumb = content_tag(:span, "",
108
+ class: "pointer-events-none absolute left-[3px] top-1/2 -translate-y-1/2 h-5 w-5 rounded-full " \
109
+ "bg-background shadow-lg ring-0 transition-transform " \
110
+ "translate-x-0 peer-checked:translate-x-5")
111
+
112
+ content_tag(:div, class: "relative inline-flex items-center shrink-0") do
113
+ safe_join([hidden, checkbox, track, thumb])
114
+ end
113
115
  end
114
116
 
115
117
  def render_json_setting_field(entry:, value:, tab:)
@@ -46,7 +46,7 @@ module RubyCms
46
46
  end
47
47
 
48
48
  def matching_templates(user)
49
- user_keys = RubyCms::UserPermission.where(user: user)
49
+ user_keys = RubyCms::UserPermission.where(user:)
50
50
  .joins(:permission)
51
51
  .pluck("permissions.key")
52
52
  templates.select {|_, tmpl| (tmpl[:keys] - user_keys).empty? }
@@ -25,10 +25,18 @@ module RubyCms
25
25
 
26
26
  def dashboard_stats
27
27
  Rails.cache.fetch(cache_key("dashboard"), expires_in: cache_duration) do
28
+ total_views = page_view_events.count
29
+ total_sessions = visits.distinct.count(:visit_token)
30
+ unique_visitors = visits.distinct.count(:visitor_token)
31
+
28
32
  {
29
- total_page_views: page_view_events.count,
30
- unique_visitors: visits.distinct.count(:visitor_token),
31
- total_sessions: visits.distinct.count(:visit_token),
33
+ total_page_views: total_views,
34
+ unique_visitors: unique_visitors,
35
+ total_sessions: total_sessions,
36
+ pages_per_session: total_sessions.positive? ? (total_views.to_f / total_sessions).round(1) : 0,
37
+ bounce_rate: compute_bounce_rate,
38
+ new_visitor_percentage: compute_new_visitor_percentage,
39
+ avg_daily_views: days_in_range.positive? ? (total_views.to_f / days_in_range).round(0).to_i : 0,
32
40
  popular_pages: popular_pages_data,
33
41
  top_visitors: top_visitors_data,
34
42
  hourly_activity: hourly_activity_data,
@@ -339,6 +347,32 @@ module RubyCms
339
347
  []
340
348
  end
341
349
 
350
+ def compute_bounce_rate
351
+ total = visits.distinct.count(:visit_token)
352
+ return 0 unless total.positive?
353
+
354
+ event_counts = page_view_events.group(:visit_id).count
355
+ single_page = event_counts.count { |_, c| c == 1 }
356
+ ((single_page.to_f / total) * 100).round(1)
357
+ rescue StandardError
358
+ 0
359
+ end
360
+
361
+ def compute_new_visitor_percentage
362
+ total = visits.distinct.count(:visitor_token)
363
+ return 0 unless total.positive?
364
+
365
+ returning_tokens = Ahoy::Visit
366
+ .where("started_at < ?", @start_date)
367
+ .distinct
368
+ .pluck(:visitor_token)
369
+
370
+ new_count = visits.where.not(visitor_token: returning_tokens).distinct.count(:visitor_token)
371
+ ((new_count.to_f / total) * 100).round(0).to_i
372
+ rescue StandardError
373
+ 0
374
+ end
375
+
342
376
  def days_in_range
343
377
  (@end_date.to_date - @start_date.to_date + 1).to_i
344
378
  end
@@ -3,9 +3,10 @@
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">
7
- <div class="flex items-start justify-between gap-4">
8
- <div class="inline-flex items-center rounded-lg bg-gray-100 p-1">
6
+ <div class="space-y-4">
7
+ <%# ── Period selector + date range ── %>
8
+ <div class="flex flex-wrap items-start justify-between gap-4">
9
+ <div class="inline-flex items-center rounded-lg border border-border/60 bg-white p-1 shadow-sm">
9
10
  <% [["Day", "day"], ["Week", "week"], ["Month", "month"], ["Year", "year"]].each do |label, value| %>
10
11
  <% start_date = case value
11
12
  when "day" then Date.current
@@ -16,143 +17,137 @@
16
17
  <% active = params[:period] == value || (params[:period].blank? && value == @period.to_s) %>
17
18
  <%= link_to label,
18
19
  ruby_cms_admin_analytics_path(start_date:, end_date: Date.current, period: value),
19
- class: "px-3 py-1.5 text-sm font-medium rounded-md transition #{active ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}" %>
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'}" %>
20
21
  <% end %>
21
22
  </div>
22
23
 
23
24
  <%= form_with url: ruby_cms_admin_analytics_path, method: :get, local: true, class: "flex items-end gap-2" do |f| %>
24
25
  <%= f.hidden_field :period, value: @period %>
25
26
  <div class="flex flex-col gap-1">
26
- <span class="text-xs font-medium text-gray-500">From</span>
27
- <%= f.date_field :start_date, value: @start_date, class: "h-9 rounded-md border border-gray-200 bg-white px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200" %>
27
+ <span class="text-xs font-medium text-muted-foreground">From</span>
28
+ <%= f.date_field :start_date, value: @start_date, class: "h-9 rounded-md border border-border bg-white px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/20" %>
28
29
  </div>
29
30
  <div class="flex flex-col gap-1">
30
- <span class="text-xs font-medium text-gray-500">To</span>
31
- <%= f.date_field :end_date, value: @end_date, class: "h-9 rounded-md border border-gray-200 bg-white px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200" %>
31
+ <span class="text-xs font-medium text-muted-foreground">To</span>
32
+ <%= f.date_field :end_date, value: @end_date, class: "h-9 rounded-md border border-border bg-white px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/20" %>
32
33
  </div>
33
34
  <%= f.submit t("ruby_cms.admin.analytics.apply_filters", default: "Apply"),
34
- class: "h-9 inline-flex items-center justify-center rounded-md bg-gray-900 px-4 text-sm font-medium text-white shadow-sm hover:bg-gray-800 transition-colors" %>
35
+ class: "h-9 inline-flex items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors cursor-pointer" %>
35
36
  <% end %>
36
37
  </div>
37
38
 
38
- <div class="grid grid-cols-3 gap-4">
39
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
40
- <p class="text-sm font-medium text-gray-500"><%= t("ruby_cms.admin.analytics.total_page_views", default: "Total page views") %></p>
41
- <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@total_page_views) %></p>
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>
50
+
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>
42
65
  </div>
43
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
44
- <p class="text-sm font-medium text-gray-500"><%= t("ruby_cms.admin.analytics.unique_visitors", default: "Unique visitors") %></p>
45
- <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@unique_visitors) %></p>
66
+
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>
73
+ </div>
74
+ <p class="mt-0.5 text-[10px] text-muted-foreground"><%= @pages_per_session %> pages / session</p>
46
75
  </div>
47
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
48
- <p class="text-sm font-medium text-gray-500"><%= t("ruby_cms.admin.analytics.total_sessions", default: "Total sessions") %></p>
49
- <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@total_sessions) %></p>
76
+
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>
80
+ </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>
86
+ </div>
87
+ <p class="mt-0.5 text-[10px] text-muted-foreground">Single-page sessions</p>
50
88
  </div>
51
89
  </div>
52
90
 
53
- <div class="grid grid-cols-2 gap-4">
91
+ <%# ── Charts ── %>
92
+ <div class="grid grid-cols-1 xl:grid-cols-2 gap-2">
54
93
  <%= render "ruby_cms/admin/analytics/partials/daily_activity_chart" %>
55
94
  <%= render "ruby_cms/admin/analytics/partials/hourly_activity_chart" %>
56
95
  </div>
57
96
 
58
- <div class="grid grid-cols-3 gap-4">
59
- <div class="col-span-2 rounded-lg border border-gray-200/80 bg-white shadow-sm">
60
- <div class="flex items-center justify-between gap-4 px-6 py-4 border-b border-gray-100">
61
- <div>
62
- <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.popular_pages", default: "Popular pages") %></p>
63
- <p class="text-sm text-gray-500">Most viewed pages in selected range.</p>
64
- </div>
65
- </div>
66
-
67
- <% if @popular_pages.present? %>
68
- <div class="divide-y divide-gray-100">
69
- <% @popular_pages.first(8).each_with_index do |(page_name, count), idx| %>
70
- <div class="flex items-center justify-between gap-4 px-6 py-4">
71
- <div class="min-w-0">
72
- <p class="text-sm font-medium text-gray-900 truncate"><%= "#{idx + 1}. #{page_name}" %></p>
73
- <p class="text-sm text-gray-500">Page</p>
74
- </div>
75
- <div class="flex items-center gap-3 flex-shrink-0">
76
- <span class="text-sm font-semibold tabular-nums text-gray-900"><%= number_with_delimiter(count) %></span>
77
- <%= link_to "Details",
78
- page_details_ruby_cms_admin_analytics_path(page_name:, start_date: @start_date, end_date: @end_date, period: @period),
79
- class: "text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" %>
80
- </div>
81
- </div>
82
- <% end %>
83
- </div>
84
- <% else %>
85
- <div class="px-6 py-12 text-center">
86
- <p class="text-sm font-medium text-gray-900"><%= t("ruby_cms.admin.analytics.no_data", default: "No data yet.") %></p>
87
- <p class="mt-1 text-sm text-gray-500">Track some traffic to see page analytics.</p>
88
- </div>
89
- <% end %>
97
+ <%# ── Content: pages + visitors ── %>
98
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-2">
99
+ <div class="lg:col-span-2">
100
+ <%= render "ruby_cms/admin/analytics/partials/popular_pages" %>
90
101
  </div>
102
+ <%= render "ruby_cms/admin/analytics/partials/top_visitors" %>
103
+ </div>
91
104
 
92
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
93
- <div class="flex items-center justify-between gap-4 px-6 py-4 border-b border-gray-100">
94
- <div>
95
- <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.top_visitors", default: "Top visitors") %></p>
96
- <p class="text-sm text-gray-500">Most active IPs.</p>
97
- </div>
98
- </div>
105
+ <%# ── Sources & tech ── %>
106
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
107
+ <%= render "ruby_cms/admin/analytics/partials/top_referrers" %>
108
+ <%= render "ruby_cms/admin/analytics/partials/landing_pages" %>
109
+ <%= render "ruby_cms/admin/analytics/partials/utm_sources" %>
110
+ </div>
99
111
 
100
- <% if @top_visitors.present? %>
101
- <div class="divide-y divide-gray-100">
102
- <% @top_visitors.first(6).each_with_index do |(ip, count), idx| %>
103
- <div class="flex items-center justify-between gap-4 px-6 py-4">
104
- <p class="text-sm font-medium text-gray-900 tabular-nums truncate"><%= "#{idx + 1}. #{ip}" %></p>
105
- <div class="flex items-center gap-3 flex-shrink-0">
106
- <span class="text-sm font-semibold tabular-nums text-gray-900"><%= number_with_delimiter(count) %></span>
107
- <%= link_to "Details",
108
- visitor_details_ruby_cms_admin_analytics_path(ip_address: ip, start_date: @start_date, end_date: @end_date, period: @period),
109
- class: "text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" %>
110
- </div>
111
- </div>
112
- <% end %>
113
- </div>
114
- <% else %>
115
- <div class="px-6 py-12 text-center">
116
- <p class="text-sm font-medium text-gray-900"><%= t("ruby_cms.admin.analytics.no_data", default: "No data yet.") %></p>
117
- <p class="mt-1 text-sm text-gray-500">No visitors in selected range.</p>
118
- </div>
119
- <% end %>
120
- </div>
112
+ <%# ── Tech & activity ── %>
113
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
114
+ <%= render "ruby_cms/admin/analytics/partials/browser_device" %>
115
+ <%= render "ruby_cms/admin/analytics/partials/os_stats" %>
116
+ <%= render "ruby_cms/admin/analytics/partials/recent_activity" %>
121
117
  </div>
122
118
 
123
- <div class="grid grid-cols-3 gap-4">
124
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
125
- <div class="px-6 py-4 border-b border-gray-100">
126
- <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.suspicious_activity", default: "Suspicious activity") %></p>
127
- <p class="text-sm text-gray-500">Potential bot-like traffic signals.</p>
119
+ <%# ── Suspicious activity ── %>
120
+ <% 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>
128
128
  </div>
129
- <div class="px-6 py-4">
130
- <% if @suspicious_activity.present? %>
131
- <ul class="space-y-2">
132
- <% @suspicious_activity.first(6).each do |item| %>
133
- <li class="flex items-center justify-between gap-3">
134
- <span class="text-sm text-gray-700"><%= item[:description] %></span>
135
- <span class="text-sm font-semibold tabular-nums text-gray-900"><%= number_with_delimiter(item[:count]) %></span>
136
- </li>
137
- <% end %>
138
- </ul>
139
- <% else %>
140
- <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.none_detected", default: "None detected in selected range.") %></p>
129
+ <div class="px-3 py-2 divide-y divide-amber-200/40">
130
+ <% @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">
132
+ <span class="text-sm text-amber-800"><%= item[:description] %></span>
133
+ <span class="text-sm font-semibold tabular-nums text-amber-900"><%= number_with_delimiter(item[:count]) %></span>
134
+ </div>
141
135
  <% end %>
142
136
  </div>
143
137
  </div>
138
+ <% end %>
144
139
 
145
- <%= render "ruby_cms/admin/analytics/partials/recent_activity" %>
146
- <%= render "ruby_cms/admin/analytics/partials/top_referrers" %>
147
- <%= render "ruby_cms/admin/analytics/partials/browser_device" %>
148
- </div>
149
-
150
- <% Array(@extra_cards).each do |card| %>
151
- <div class="rounded-lg border border-gray-200/80 bg-white p-6 shadow-sm">
152
- <p class="text-sm font-semibold text-gray-900"><%= card[:title].to_s %></p>
153
- <p class="mt-2 text-2xl font-semibold tracking-tight text-gray-900"><%= card[:value].to_s %></p>
154
- <% if card[:description].present? %>
155
- <p class="mt-1 text-sm text-gray-500"><%= card[:description] %></p>
140
+ <%# ── Extra cards hook ── %>
141
+ <% if Array(@extra_cards).any? %>
142
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
143
+ <% @extra_cards.each do |card| %>
144
+ <div class="rounded-lg border border-border/60 bg-white p-3 shadow-sm">
145
+ <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>
147
+ <% if card[:description].present? %>
148
+ <p class="mt-1 text-[11px] text-muted-foreground"><%= card[:description] %></p>
149
+ <% end %>
150
+ </div>
156
151
  <% end %>
157
152
  </div>
158
153
  <% end %>