ruby_cms 0.1.1 → 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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +68 -164
  4. data/app/components/ruby_cms/admin/admin_page.rb +19 -19
  5. data/app/components/ruby_cms/admin/admin_page_header.rb +79 -0
  6. data/app/components/ruby_cms/admin/admin_resource_card.rb +55 -0
  7. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +4 -4
  8. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +5 -5
  9. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +1 -1
  10. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +15 -13
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +13 -11
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +9 -9
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +2 -2
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +8 -8
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +9 -9
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +3 -4
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +46 -32
  18. data/app/controllers/ruby_cms/admin/base_controller.rb +10 -4
  19. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +4 -3
  20. data/app/controllers/ruby_cms/admin/locale_controller.rb +2 -1
  21. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +25 -7
  22. data/app/helpers/ruby_cms/content_blocks_helper.rb +7 -3
  23. data/app/helpers/ruby_cms/settings_helper.rb +32 -20
  24. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +53 -12
  25. data/app/models/ruby_cms/permission.rb +38 -9
  26. data/app/models/ruby_cms/permittable.rb +0 -2
  27. data/app/services/ruby_cms/analytics/report.rb +37 -3
  28. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +2 -2
  29. data/app/views/layouts/ruby_cms/admin.html.erb +13 -17
  30. data/app/views/ruby_cms/admin/analytics/index.html.erb +103 -108
  31. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +28 -32
  32. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -2
  33. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +34 -20
  34. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +27 -27
  35. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +19 -19
  36. data/app/views/ruby_cms/admin/analytics/partials/_landing_pages.html.erb +21 -0
  37. data/app/views/ruby_cms/admin/analytics/partials/_os_stats.html.erb +26 -0
  38. data/app/views/ruby_cms/admin/analytics/partials/_popular_pages.html.erb +34 -0
  39. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +16 -14
  40. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +3 -3
  41. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +14 -14
  42. data/app/views/ruby_cms/admin/analytics/partials/_top_visitors.html.erb +28 -0
  43. data/app/views/ruby_cms/admin/analytics/partials/_utm_sources.html.erb +21 -0
  44. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +44 -48
  45. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +0 -11
  46. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +204 -85
  47. data/app/views/ruby_cms/admin/settings/index.html.erb +214 -175
  48. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +32 -2
  49. data/app/views/ruby_cms/admin/users/_row.html.erb +4 -1
  50. data/config/locales/en.yml +4 -0
  51. data/lib/generators/ruby_cms/install_generator.rb +2 -1
  52. data/lib/ruby_cms/cli.rb +1 -1
  53. data/lib/ruby_cms/engine.rb +20 -12
  54. data/lib/ruby_cms/version.rb +1 -1
  55. data/lib/ruby_cms.rb +24 -0
  56. data/lib/tasks/admin.rake +120 -0
  57. data/log/test.log +7284 -0
  58. metadata +10 -4
  59. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +0 -17
  60. data/lib/tasks/ruby_cms.rake +0 -27
@@ -112,8 +112,7 @@ export default class extends Controller {
112
112
 
113
113
  if (count > 0) {
114
114
  bulkBar.classList.remove("hidden");
115
- const itemName = this.itemNameValue || "item";
116
- selectedCount.textContent = `${count} ${itemName}${count === 1 ? "" : "s"} selected:`;
115
+ selectedCount.textContent = `${count}`;
117
116
  } else {
118
117
  bulkBar.classList.add("hidden");
119
118
  }
@@ -143,10 +142,14 @@ export default class extends Controller {
143
142
  const itemId = String(row.getAttribute("data-item-id") || "");
144
143
  if (selectedIdsStr.includes(itemId)) {
145
144
  row.setAttribute("data-state", "selected");
146
- row.classList.add("bg-gray-50");
145
+ row.classList.add("bg-primary/5");
146
+ row.classList.remove("hover:bg-muted/50");
147
147
  } else {
148
148
  row.removeAttribute("data-state");
149
- row.classList.remove("bg-gray-50");
149
+ row.classList.remove("bg-primary/5");
150
+ if (!row.classList.contains("hover:bg-muted/50")) {
151
+ row.classList.add("hover:bg-muted/50");
152
+ }
150
153
  }
151
154
  });
152
155
  }
@@ -302,6 +305,41 @@ export default class extends Controller {
302
305
  this.isProcessing = false;
303
306
  }
304
307
 
308
+ rowClick(event) {
309
+ const row = event.currentTarget;
310
+ const target = event.target;
311
+
312
+ if (
313
+ target.closest('input[type="checkbox"]') ||
314
+ target.closest("button") ||
315
+ target.closest("a") ||
316
+ target.closest("[data-action*='stopPropagation']")
317
+ ) {
318
+ return;
319
+ }
320
+
321
+ if (event.ctrlKey || event.metaKey) {
322
+ event.preventDefault();
323
+ const checkbox = row.querySelector(
324
+ 'input[type="checkbox"][data-item-id]',
325
+ );
326
+ if (checkbox) {
327
+ checkbox.checked = !checkbox.checked;
328
+ this.updateBulkBar();
329
+ }
330
+ return;
331
+ }
332
+
333
+ const clickUrl = row.dataset.clickUrl;
334
+ if (clickUrl) {
335
+ if (window.Turbo) {
336
+ window.Turbo.visit(clickUrl);
337
+ } else {
338
+ window.location.href = clickUrl;
339
+ }
340
+ }
341
+ }
342
+
305
343
  stopPropagation(event) {
306
344
  event.stopPropagation();
307
345
  }
@@ -517,20 +555,23 @@ export default class extends Controller {
517
555
 
518
556
  showNotification(message, type = "info") {
519
557
  const toast = document.createElement("div");
520
- toast.className = `fixed top-4 right-4 z-50 px-4 py-2 rounded-md shadow-lg text-white max-w-sm ${
521
- type === "success"
522
- ? "bg-green-600"
523
- : type === "error"
524
- ? "bg-red-600"
525
- : "bg-blue-600"
558
+ const colorMap = {
559
+ success: "bg-emerald-600",
560
+ error: "bg-destructive",
561
+ info: "bg-primary",
562
+ };
563
+ toast.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-white text-sm max-w-sm animate-in slide-in-from-top-2 ${
564
+ colorMap[type] || colorMap.info
526
565
  }`;
527
566
  toast.textContent = message;
528
567
 
529
568
  document.body.appendChild(toast);
530
569
 
531
570
  setTimeout(() => {
532
- toast.remove();
533
- }, 5000);
571
+ toast.style.opacity = "0";
572
+ toast.style.transition = "opacity 200ms ease-out";
573
+ setTimeout(() => toast.remove(), 200);
574
+ }, 4000);
534
575
  }
535
576
 
536
577
  clearItemIdsFromUrl() {
@@ -9,20 +9,49 @@ module RubyCms
9
9
 
10
10
  validates :key, presence: true, uniqueness: true
11
11
 
12
- DEFAULT_KEYS = %w[
13
- manage_admin
14
- manage_permissions
15
- manage_content_blocks
16
- manage_visitor_errors
17
- manage_analytics
18
- ].freeze
12
+ DEFAULT_KEYS = RubyCms::DEFAULT_PERMISSION_KEYS
19
13
 
20
14
  class << self
21
15
  def ensure_defaults!
22
- DEFAULT_KEYS.each do |k|
23
- find_or_create_by!(key: k) {|p| p.name = k.humanize }
16
+ all_keys.each do |k|
17
+ find_or_create_by!(key: k) {|p| p.name = k.titleize }
24
18
  end
25
19
  end
20
+
21
+ def all_keys
22
+ (DEFAULT_KEYS + RubyCms.extra_permission_keys.map(&:to_s)).uniq.freeze
23
+ end
24
+
25
+ def templates
26
+ RubyCms.permission_templates
27
+ end
28
+
29
+ def register_keys(*keys)
30
+ RubyCms.register_permission_keys(*keys)
31
+ end
32
+
33
+ def register_template(name, label:, keys:, description: nil)
34
+ RubyCms.register_permission_template(name, label:, keys:, description:)
35
+ end
36
+
37
+ def apply_template!(user, template_name)
38
+ tmpl = templates[template_name.to_sym]
39
+ raise ArgumentError, "Unknown template: #{template_name}" unless tmpl
40
+
41
+ ensure_defaults!
42
+ perms = where(key: tmpl[:keys])
43
+ perms.each do |perm|
44
+ RubyCms::UserPermission.find_or_create_by!(user: user, permission: perm)
45
+ end
46
+ end
47
+
48
+ def matching_templates(user)
49
+ user_keys = RubyCms::UserPermission.where(user:)
50
+ .joins(:permission)
51
+ .pluck("permissions.key")
52
+ templates.select {|_, tmpl| (tmpl[:keys] - user_keys).empty? }
53
+ .keys
54
+ end
26
55
  end
27
56
  end
28
57
  end
@@ -12,9 +12,7 @@ module RubyCms
12
12
  k = permission_key.to_s
13
13
  return false unless RubyCms::Permission.exists?(key: k)
14
14
 
15
- # Treat manage_admin as a super-permission for admin features.
16
15
  cms_permission_keys_cached.include?(k) ||
17
- cms_permission_keys_cached.include?("manage_admin") ||
18
16
  record&.can_edit?(self)
19
17
  end
20
18
 
@@ -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
@@ -76,7 +76,7 @@
76
76
 
77
77
  <div class="px-3 py-3 flex-shrink-0 bg-[#FAF9F5]" data-ruby-cms--mobile-menu-target="sidebarContent">
78
78
  <div class="mb-2">
79
- <%= form_with url: ruby_cms_admin_locale_path, method: :patch, scope: nil, data: { turbo: true } do |form| %>
79
+ <%= form_with url: ruby_cms_admin_locale_path, method: :patch, scope: nil, data: { turbo: false } do |form| %>
80
80
  <div class="relative">
81
81
  <div class="flex items-center gap-3 w-full px-3 py-2.5 text-sm font-medium text-gray-600 rounded-md no-underline transition-colors hover:bg-blue-500 hover:text-white">
82
82
  <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
@@ -94,7 +94,7 @@
94
94
  {
95
95
  class: "absolute inset-0 w-full h-full opacity-0 cursor-pointer",
96
96
  aria: { label: "Locale" },
97
- onchange: "this.form.requestSubmit()"
97
+ onchange: "this.form.submit()"
98
98
  } %>
99
99
  </div>
100
100
  <% end %>
@@ -39,34 +39,30 @@
39
39
  <% else %>
40
40
  <div class="flex-1 flex flex-col min-h-0 p-6 overflow-y-auto bg-gray-50">
41
41
  <div class="mx-auto w-full max-w-7xl">
42
+ <%# Page header – prefer AdminPageHeader component rendered by views.
43
+ Legacy content_for fallback kept for pages that haven't migrated. %>
42
44
  <% if content_for?(:title) || content_for?(:header_actions) %>
43
- <header class="flex-shrink-0 mb-4 <%= yield(:header_styles) if content_for?(:header_styles) %>">
45
+ <header class="flex-shrink-0 mb-4">
46
+ <% if content_for?(:breadcrumbs) %>
47
+ <nav class="mb-1 text-sm text-muted-foreground" aria-label="Breadcrumb">
48
+ <ol class="flex items-center flex-wrap gap-y-1">
49
+ <%= yield :breadcrumbs %>
50
+ </ol>
51
+ </nav>
52
+ <% end %>
44
53
  <div class="flex flex-wrap items-center justify-between gap-4">
45
- <div>
54
+ <div class="min-w-0">
46
55
  <% if content_for?(:title) %>
47
- <h1 class="text-lg font-semibold tracking-tight text-gray-900"><%= yield :title %></h1>
48
- <% end %>
49
- <% if content_for?(:breadcrumbs) %>
50
- <nav class="mt-0.5 text-sm text-gray-500" aria-label="Breadcrumb">
51
- <ol class="flex items-center flex-wrap gap-x-2 gap-y-1">
52
- <%= yield :breadcrumbs %>
53
- </ol>
54
- </nav>
56
+ <h1 class="text-lg font-semibold tracking-tight text-foreground"><%= yield :title %></h1>
55
57
  <% end %>
56
58
  </div>
57
59
  <% if content_for?(:header_actions) %>
58
- <div class="flex items-center flex-shrink-0">
60
+ <div class="flex items-center gap-3 flex-shrink-0">
59
61
  <%= yield :header_actions %>
60
62
  </div>
61
63
  <% end %>
62
64
  </div>
63
65
  </header>
64
- <% elsif content_for?(:breadcrumbs) %>
65
- <nav class="mb-4" aria-label="Breadcrumb">
66
- <ol class="flex items-center space-x-2 text-sm text-gray-600">
67
- <%= content_for :breadcrumbs %>
68
- </ol>
69
- </nav>
70
66
  <% end %>
71
67
 
72
68
  <%= render "layouts/ruby_cms/admin_flash_messages" %>
@@ -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 %>