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
@@ -4,76 +4,72 @@
4
4
  show_security = high_volume || suspicious_ua
5
5
  %>
6
6
  <%= admin_page(
7
- title: t("ruby_cms.admin.analytics.page_details_title", default: "Page details"),
8
- subtitle: "#{@start_date} → #{@end_date}",
7
+ title: @page_name.to_s.humanize,
8
+ subtitle: "Page analytics · #{@start_date} → #{@end_date}",
9
9
  content_card: false
10
10
  ) do %>
11
11
  <div class="space-y-6">
12
12
  <div class="flex items-start justify-between gap-4">
13
- <div class="min-w-0">
14
- <p class="text-sm font-medium text-gray-500">Page</p>
15
- <p class="mt-1 text-lg font-semibold tracking-tight text-gray-900 truncate"><%= @page_name %></p>
16
- </div>
17
- <div class="flex items-center gap-2 flex-shrink-0">
13
+ <div class="flex items-center gap-2">
18
14
  <%= render "ruby_cms/admin/analytics/partials/security_alert" if show_security %>
19
- <%= render "ruby_cms/admin/analytics/partials/back_button", path: ruby_cms_admin_analytics_path(start_date: @start_date, end_date: @end_date, period: @period) %>
20
15
  </div>
16
+ <%= render "ruby_cms/admin/analytics/partials/back_button", path: ruby_cms_admin_analytics_path(start_date: @start_date, end_date: @end_date, period: @period) %>
21
17
  </div>
22
18
 
23
19
  <div class="grid grid-cols-3 gap-4">
24
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
25
- <p class="text-sm font-medium text-gray-500">Total views</p>
26
- <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@page_stats[:total_views]) %></p>
20
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
21
+ <p class="text-sm font-medium text-muted-foreground">Total views</p>
22
+ <p class="mt-2 text-2xl font-semibold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@page_stats[:total_views]) %></p>
27
23
  </div>
28
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
29
- <p class="text-sm font-medium text-gray-500">Unique visitors</p>
30
- <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_delimiter(@page_stats[:unique_visitors]) %></p>
24
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
25
+ <p class="text-sm font-medium text-muted-foreground">Unique visitors</p>
26
+ <p class="mt-2 text-2xl font-semibold tracking-tight text-foreground tabular-nums"><%= number_with_delimiter(@page_stats[:unique_visitors]) %></p>
31
27
  </div>
32
- <div class="rounded-lg border border-gray-200/80 bg-white p-5 shadow-sm">
33
- <p class="text-sm font-medium text-gray-500">Avg/day</p>
34
- <p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums"><%= number_with_precision(@page_stats[:avg_views_per_day], precision: 1) %></p>
28
+ <div class="rounded-lg border border-border/60 bg-white p-6 shadow-sm">
29
+ <p class="text-sm font-medium text-muted-foreground">Avg / day</p>
30
+ <p class="mt-2 text-2xl font-semibold tracking-tight text-foreground tabular-nums"><%= number_with_precision(@page_stats[:avg_views_per_day], precision: 1) %></p>
35
31
  </div>
36
32
  </div>
37
33
 
38
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm overflow-hidden">
39
- <div class="px-6 py-4 border-b border-gray-100">
40
- <p class="text-sm font-semibold text-gray-900">Page views</p>
41
- <p class="text-sm text-gray-500">Recent views for this page.</p>
34
+ <div class="rounded-lg border border-border/60 bg-white shadow-sm overflow-hidden">
35
+ <div class="px-6 py-4 border-b border-border/40">
36
+ <p class="text-sm font-semibold text-foreground">Page views</p>
37
+ <p class="text-sm text-muted-foreground">Recent views for this page.</p>
42
38
  </div>
43
39
 
44
40
  <div class="overflow-x-auto">
45
41
  <table class="min-w-full text-sm">
46
- <thead class="bg-gray-50">
47
- <tr class="text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
42
+ <thead class="bg-muted/30">
43
+ <tr class="text-left text-xs font-medium uppercase tracking-wider text-muted-foreground">
48
44
  <th class="px-6 py-3">IP Address</th>
49
45
  <th class="px-6 py-3">Browser</th>
50
46
  <th class="px-6 py-3">Referrer</th>
51
47
  <th class="px-6 py-3">Time</th>
52
48
  </tr>
53
49
  </thead>
54
- <tbody class="divide-y divide-gray-100">
50
+ <tbody class="divide-y divide-border/40">
55
51
  <% if @page_views.any? %>
56
52
  <% @page_views.each do |event| %>
57
53
  <% visit = event.visit %>
58
54
  <% ip = visit&.ip %>
59
- <tr class="hover:bg-gray-50 transition-colors">
60
- <td class="px-6 py-3 font-medium text-gray-900 tabular-nums">
55
+ <tr class="hover:bg-muted/30 transition-colors">
56
+ <td class="px-6 py-3 font-medium text-foreground tabular-nums">
61
57
  <% if ip.present? && ip != "Unknown" %>
62
58
  <%= link_to ip,
63
59
  visitor_details_ruby_cms_admin_analytics_path(ip_address: ip, start_date: @start_date, end_date: @end_date, period: @period),
64
- class: "text-gray-900 hover:underline" %>
60
+ class: "text-primary hover:underline" %>
65
61
  <% else %>
66
- <span class="text-gray-500"><%= ip || "Unknown" %></span>
62
+ <span class="text-muted-foreground"><%= ip || "Unknown" %></span>
67
63
  <% end %>
68
64
  </td>
69
- <td class="px-6 py-3 text-gray-700"><%= visit&.browser || "Unknown" %></td>
70
- <td class="px-6 py-3 text-gray-700 truncate max-w-[26rem]"><%= visit&.referrer.presence || "Direct" %></td>
71
- <td class="px-6 py-3 text-gray-700"><%= time_ago_in_words(event.time) %> ago</td>
65
+ <td class="px-6 py-3 text-foreground"><%= visit&.browser || "Unknown" %></td>
66
+ <td class="px-6 py-3 text-foreground truncate max-w-[26rem]"><%= visit&.referrer.presence || "Direct" %></td>
67
+ <td class="px-6 py-3 text-muted-foreground"><%= time_ago_in_words(event.time) %> ago</td>
72
68
  </tr>
73
69
  <% end %>
74
70
  <% else %>
75
71
  <tr>
76
- <td colspan="4" class="px-6 py-10 text-center text-sm text-gray-500">No page views recorded for this page</td>
72
+ <td colspan="4" class="px-6 py-10 text-center text-sm text-muted-foreground">No page views recorded for this page</td>
77
73
  </tr>
78
74
  <% end %>
79
75
  </tbody>
@@ -1,3 +1,4 @@
1
- <%= link_to path, class: "inline-flex items-center justify-center rounded-md border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-900 shadow-sm hover:bg-gray-50 transition-colors" do %>
2
- <%= t("ruby_cms.admin.analytics.back", default: "Back") %>
1
+ <%= link_to path, class: "inline-flex items-center justify-center rounded-md border border-border bg-white px-3 py-2 text-sm font-medium text-foreground shadow-sm hover:bg-muted transition-colors" do %>
2
+ <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
3
+ <%= t("ruby_cms.admin.analytics.back", default: "Back") %>
3
4
  <% end %>
@@ -1,40 +1,54 @@
1
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
- <div class="px-6 py-4 border-b border-gray-100">
3
- <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.browsers_devices", default: "Browsers & devices") %></p>
4
- <p class="text-sm text-gray-500">Environment breakdown.</p>
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>
5
5
  </div>
6
6
 
7
- <div class="px-6 py-4 space-y-4">
7
+ <div class="px-4 py-3 space-y-3">
8
8
  <% if @browser_stats.present? && @browser_stats.any? %>
9
9
  <div>
10
- <p class="text-xs font-semibold uppercase tracking-wider text-gray-500">Browsers</p>
11
- <ul class="mt-2 space-y-2">
10
+ <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">Browsers</p>
11
+ <% total = @browser_stats.values.sum.to_f %>
12
+ <div class="space-y-2.5">
12
13
  <% @browser_stats.sort_by { |_, v| -v }.first(4).each do |browser, count| %>
13
- <li class="flex items-center justify-between gap-3">
14
- <span class="text-sm text-gray-700 truncate"><%= browser %></span>
15
- <span class="text-sm font-semibold tabular-nums text-gray-900"><%= number_with_delimiter(count) %></span>
16
- </li>
14
+ <% pct = total.positive? ? (count / total * 100).round(1) : 0 %>
15
+ <div>
16
+ <div class="flex items-center justify-between gap-3 mb-1">
17
+ <span class="text-sm text-foreground"><%= browser %></span>
18
+ <div class="flex items-center gap-2 flex-shrink-0">
19
+ <span class="text-xs text-muted-foreground tabular-nums"><%= pct %>%</span>
20
+ <span class="text-sm font-semibold tabular-nums text-foreground"><%= number_with_delimiter(count) %></span>
21
+ </div>
22
+ </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>
25
+ </div>
26
+ </div>
17
27
  <% end %>
18
- </ul>
28
+ </div>
19
29
  </div>
20
30
  <% end %>
21
31
 
22
32
  <% if @device_stats.present? && @device_stats.any? %>
23
33
  <div>
24
- <p class="text-xs font-semibold uppercase tracking-wider text-gray-500">Devices</p>
25
- <ul class="mt-2 space-y-2">
34
+ <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">Devices</p>
35
+ <% total = @device_stats.values.sum.to_f %>
36
+ <div class="flex gap-3">
26
37
  <% @device_stats.sort_by { |_, v| -v }.first(4).each do |device, count| %>
27
- <li class="flex items-center justify-between gap-3">
28
- <span class="text-sm text-gray-700 truncate"><%= device %></span>
29
- <span class="text-sm font-semibold tabular-nums text-gray-900"><%= number_with_delimiter(count) %></span>
30
- </li>
38
+ <% 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>
42
+ </div>
31
43
  <% end %>
32
- </ul>
44
+ </div>
33
45
  </div>
34
46
  <% end %>
35
47
 
36
48
  <% if (@browser_stats.blank? || @browser_stats.empty?) && (@device_stats.blank? || @device_stats.empty?) %>
37
- <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.no_data", default: "No data yet.") %></p>
49
+ <div class="py-6 text-center">
50
+ <p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.no_data", default: "No data yet.") %></p>
51
+ </div>
38
52
  <% end %>
39
53
  </div>
40
54
  </div>
@@ -1,58 +1,58 @@
1
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
- <div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-gray-100">
1
+ <div class="rounded-lg border border-border/60 bg-white shadow-sm">
2
+ <div class="flex items-center justify-between gap-3 px-4 py-2 border-b border-border/40">
3
3
  <div>
4
- <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.daily_activity", default: "Daily activity") %></p>
5
- <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.page_views_visitors_over_time", default: "Page views and visitors over time") %></p>
4
+ <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.daily_activity", default: "Daily Activity") %></p>
5
+ <p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.page_views_visitors_over_time", default: "Page views and visitors over time") %></p>
6
6
  </div>
7
- <div class="flex items-center gap-3 text-sm text-gray-500">
8
- <span class="inline-flex items-center gap-2"><span class="h-2 w-2 rounded-full bg-sky-500"></span><%= t("ruby_cms.admin.analytics.views", default: "Views") %></span>
9
- <span class="inline-flex items-center gap-2"><span class="h-2 w-2 rounded-full bg-emerald-500"></span><%= t("ruby_cms.admin.analytics.visitors", default: "Visitors") %></span>
7
+ <div class="flex items-center gap-4 text-sm text-muted-foreground">
8
+ <span class="inline-flex items-center gap-1.5"><span class="h-2 w-2 rounded-full bg-primary"></span><%= t("ruby_cms.admin.analytics.views", default: "Views") %></span>
9
+ <span class="inline-flex items-center gap-1.5"><span class="h-2 w-2 rounded-full bg-emerald-500"></span><%= t("ruby_cms.admin.analytics.visitors", default: "Visitors") %></span>
10
10
  </div>
11
11
  </div>
12
12
 
13
- <div class="px-6 py-4">
13
+ <div class="px-4 py-3">
14
14
  <% if @daily_activity.present? && @daily_activity.any? %>
15
15
  <%
16
- daily_activity_arr = @daily_activity.is_a?(Hash) ? @daily_activity.to_a : @daily_activity
17
- max_views = daily_activity_arr.map { |_k, v| v.to_i }.max || 0
16
+ daily_arr = @daily_activity.is_a?(Hash) ? @daily_activity.to_a : @daily_activity
17
+ max_views = daily_arr.map { |_k, v| v.to_i }.max || 0
18
18
  max_visitors = (@daily_visitors || {}).values.map(&:to_i).max || 0
19
19
  max_value = [max_views, max_visitors].max
20
20
  max_value = 1 if max_value.zero?
21
- chart_height = 200
21
+ chart_h = 130
22
22
  %>
23
23
 
24
- <div class="grid grid-cols-[3.5rem_1fr] gap-4">
25
- <div class="flex flex-col justify-between h-[<%= chart_height %>px] text-xs text-gray-500">
24
+ <div class="grid grid-cols-[2.75rem_1fr] gap-2">
25
+ <div class="flex flex-col justify-between h-[<%= chart_h %>px] text-xs text-muted-foreground tabular-nums">
26
26
  <% [100, 75, 50, 25, 0].each do |pct| %>
27
- <div class="tabular-nums"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
27
+ <div class="text-right"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
28
28
  <% end %>
29
29
  </div>
30
30
 
31
31
  <div class="relative">
32
32
  <div class="absolute inset-0 flex flex-col justify-between pointer-events-none">
33
- <% 5.times do %>
34
- <div class="h-px bg-gray-100"></div>
35
- <% end %>
33
+ <% 5.times do %><div class="h-px bg-border/40"></div><% end %>
36
34
  </div>
37
35
 
38
- <div class="h-[<%= chart_height %>px] flex items-end gap-2">
39
- <% daily_activity_arr.each do |date, views_count| %>
36
+ <div class="h-[<%= chart_h %>px] flex items-end gap-1">
37
+ <% daily_arr.each do |date, views_count| %>
40
38
  <% visitors_count = (@daily_visitors || {})[date] || 0 %>
41
- <% views_h = [((views_count.to_f / max_value) * chart_height).round, 2].max %>
42
- <% visitors_h = [((visitors_count.to_f / max_value) * chart_height).round, 2].max %>
43
- <div class="flex flex-col items-center gap-2 flex-1 min-w-0">
44
- <div class="w-full flex items-end justify-center gap-1" title="<%= format_chart_date(date) %> — Views: <%= number_with_delimiter(views_count) %>, Visitors: <%= number_with_delimiter(visitors_count) %>">
45
- <div class="w-2 rounded-sm bg-sky-500/90" style="height:<%= views_h %>px"></div>
46
- <div class="w-2 rounded-sm bg-emerald-500/90" style="height:<%= visitors_h %>px"></div>
39
+ <% views_h = [((views_count.to_f / max_value) * chart_h).round, 2].max %>
40
+ <% visitors_h = [((visitors_count.to_f / max_value) * chart_h).round, 2].max %>
41
+ <div class="flex flex-col items-center gap-1.5 flex-1 min-w-0 group">
42
+ <div class="w-full flex items-end justify-center gap-px" title="<%= format_chart_date(date) %> — Views: <%= number_with_delimiter(views_count) %>, Visitors: <%= number_with_delimiter(visitors_count) %>">
43
+ <div class="flex-1 max-w-[8px] rounded-t bg-primary/80 group-hover:bg-primary transition-colors" style="height:<%= views_h %>px"></div>
44
+ <div class="flex-1 max-w-[8px] rounded-t bg-emerald-500/80 group-hover:bg-emerald-500 transition-colors" style="height:<%= visitors_h %>px"></div>
47
45
  </div>
48
- <div class="text-[11px] text-gray-500 truncate w-full text-center"><%= format_chart_date_short(date) %></div>
46
+ <div class="text-[10px] text-muted-foreground truncate w-full text-center"><%= format_chart_date_short(date) %></div>
49
47
  </div>
50
48
  <% end %>
51
49
  </div>
52
50
  </div>
53
51
  </div>
54
52
  <% else %>
55
- <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.no_daily_data", default: "No daily activity data") %></p>
53
+ <div class="flex items-center justify-center h-40">
54
+ <p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.no_daily_data", default: "No daily activity data") %></p>
55
+ </div>
56
56
  <% end %>
57
57
  </div>
58
58
  </div>
@@ -1,10 +1,10 @@
1
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
- <div class="px-6 py-4 border-b border-gray-100">
3
- <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.hourly_activity", default: "Hourly activity") %></p>
4
- <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.views_by_hour", default: "Views by hour") %></p>
1
+ <div class="rounded-lg border border-border/60 bg-white shadow-sm">
2
+ <div class="px-4 py-2 border-b border-border/40">
3
+ <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.hourly_activity", default: "Hourly Activity") %></p>
4
+ <p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.views_by_hour", default: "Views by hour") %></p>
5
5
  </div>
6
6
 
7
- <div class="px-6 py-4">
7
+ <div class="px-4 py-3">
8
8
  <% if @hourly_activity.present? && @hourly_activity.any? %>
9
9
  <%
10
10
  hourly_arr = @hourly_activity.is_a?(Hash) ? @hourly_activity.to_a : @hourly_activity
@@ -14,38 +14,38 @@
14
14
  [hours, total]
15
15
  end
16
16
  max_value = grouped.map { |_, c| c }.max || 1
17
- chart_height = 200
17
+ chart_h = 130
18
18
  %>
19
19
 
20
- <div class="grid grid-cols-[3.5rem_1fr] gap-4">
21
- <div class="flex flex-col justify-between h-[<%= chart_height %>px] text-xs text-gray-500">
20
+ <div class="grid grid-cols-[2.75rem_1fr] gap-2">
21
+ <div class="flex flex-col justify-between h-[<%= chart_h %>px] text-xs text-muted-foreground tabular-nums">
22
22
  <% [100, 75, 50, 25, 0].each do |pct| %>
23
- <div class="tabular-nums"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
23
+ <div class="text-right"><%= number_with_delimiter((max_value * pct / 100.0).round) %></div>
24
24
  <% end %>
25
25
  </div>
26
26
 
27
27
  <div class="relative">
28
28
  <div class="absolute inset-0 flex flex-col justify-between pointer-events-none">
29
- <% 5.times do %>
30
- <div class="h-px bg-gray-100"></div>
31
- <% end %>
29
+ <% 5.times do %><div class="h-px bg-border/40"></div><% end %>
32
30
  </div>
33
31
 
34
- <div class="h-[<%= chart_height %>px] flex items-end gap-2">
32
+ <div class="h-[<%= chart_h %>px] flex items-end gap-1.5">
35
33
  <% grouped.each do |hours, count| %>
36
- <% h = [((count.to_f / max_value) * chart_height).round, 2].max %>
37
- <div class="flex flex-col items-center gap-2 flex-1 min-w-0">
38
- <div class="w-full flex items-end justify-center" title="<%= hours.size > 1 ? "#{hours.first}:00-#{hours.last}:00" : "#{hours.first}:00" %> — <%= number_with_delimiter(count) %> views">
39
- <div class="w-3 rounded-sm bg-emerald-500/90" style="height:<%= h %>px"></div>
34
+ <% h = [((count.to_f / max_value) * chart_h).round, 2].max %>
35
+ <div class="flex flex-col items-center gap-1.5 flex-1 min-w-0 group">
36
+ <div class="w-full flex items-end justify-center" title="<%= hours.size > 1 ? "#{hours.first}:00–#{hours.last}:00" : "#{hours.first}:00" %> — <%= number_with_delimiter(count) %> views">
37
+ <div class="w-full max-w-[12px] rounded-t bg-emerald-500/80 group-hover:bg-emerald-500 transition-colors" style="height:<%= h %>px"></div>
40
38
  </div>
41
- <div class="text-[11px] text-gray-500 truncate w-full text-center"><%= hours.size > 1 ? "#{hours.first}-#{hours.last}h" : "#{hours.first}h" %></div>
39
+ <div class="text-[10px] text-muted-foreground truncate w-full text-center"><%= hours.size > 1 ? "#{hours.first}–#{hours.last}h" : "#{hours.first}h" %></div>
42
40
  </div>
43
41
  <% end %>
44
42
  </div>
45
43
  </div>
46
44
  </div>
47
45
  <% else %>
48
- <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.no_hourly_data", default: "No hourly activity data") %></p>
46
+ <div class="flex items-center justify-center h-40">
47
+ <p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.no_hourly_data", default: "No hourly activity data") %></p>
48
+ </div>
49
49
  <% end %>
50
50
  </div>
51
51
  </div>
@@ -0,0 +1,21 @@
1
+ <div class="rounded-lg border border-border/60 bg-white shadow-sm">
2
+ <div class="px-6 py-4 border-b border-border/40">
3
+ <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.landing_pages", default: "Landing Pages") %></p>
4
+ <p class="text-sm text-muted-foreground">First pages visitors see.</p>
5
+ </div>
6
+
7
+ <div class="divide-y divide-border/40">
8
+ <% if @landing_pages.present? && @landing_pages.any? %>
9
+ <% @landing_pages.first(6).each do |page, count| %>
10
+ <div class="flex items-center justify-between gap-3 px-6 py-3">
11
+ <span class="text-sm text-foreground truncate" title="<%= page %>"><%= truncate(page.to_s.gsub(%r{https?://[^/]+}, ''), length: 40) %></span>
12
+ <span class="text-sm font-semibold tabular-nums text-foreground flex-shrink-0"><%= number_with_delimiter(count) %></span>
13
+ </div>
14
+ <% end %>
15
+ <% else %>
16
+ <div class="px-6 py-10 text-center">
17
+ <p class="text-sm text-muted-foreground">No landing page data</p>
18
+ </div>
19
+ <% end %>
20
+ </div>
21
+ </div>
@@ -0,0 +1,26 @@
1
+ <div class="rounded-lg border border-border/60 bg-white shadow-sm">
2
+ <div class="px-6 py-4 border-b border-border/40">
3
+ <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.operating_systems", default: "Operating Systems") %></p>
4
+ <p class="text-sm text-muted-foreground">Visitor OS breakdown.</p>
5
+ </div>
6
+
7
+ <div class="divide-y divide-border/40">
8
+ <% if @os_stats.present? && @os_stats.any? %>
9
+ <% total = @os_stats.values.sum.to_f %>
10
+ <% @os_stats.sort_by { |_, v| -v }.first(6).each do |os, count| %>
11
+ <% pct = total.positive? ? (count / total * 100).round(1) : 0 %>
12
+ <div class="flex items-center justify-between gap-3 px-6 py-3">
13
+ <span class="text-sm text-foreground"><%= os %></span>
14
+ <div class="flex items-center gap-3 flex-shrink-0">
15
+ <span class="text-xs text-muted-foreground tabular-nums"><%= pct %>%</span>
16
+ <span class="text-sm font-semibold tabular-nums text-foreground"><%= number_with_delimiter(count) %></span>
17
+ </div>
18
+ </div>
19
+ <% end %>
20
+ <% else %>
21
+ <div class="px-6 py-10 text-center">
22
+ <p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.no_data", default: "No data yet.") %></p>
23
+ </div>
24
+ <% end %>
25
+ </div>
26
+ </div>
@@ -0,0 +1,34 @@
1
+ <div class="rounded-lg border border-border/60 bg-white shadow-sm">
2
+ <div class="flex items-center justify-between gap-3 px-4 py-3 border-b border-border/40">
3
+ <div>
4
+ <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.popular_pages", default: "Popular Pages") %></p>
5
+ <p class="text-sm text-muted-foreground">Most viewed pages in selected range.</p>
6
+ </div>
7
+ </div>
8
+
9
+ <% if @popular_pages.present? %>
10
+ <% total = @popular_pages.values.sum.to_f %>
11
+ <div class="divide-y divide-border/40">
12
+ <% @popular_pages.first(8).each_with_index do |(page_name, count), idx| %>
13
+ <% pct = total.positive? ? (count / total * 100).round(1) : 0 %>
14
+ <%= link_to page_details_ruby_cms_admin_analytics_path(page_name:, start_date: @start_date, end_date: @end_date, period: @period),
15
+ class: "group relative flex items-center justify-between gap-2 px-4 py-2 hover:bg-muted/50 transition-colors" do %>
16
+ <div class="absolute inset-y-0 left-0 bg-primary/5 transition-all" style="width: <%= pct %>%"></div>
17
+ <div class="relative z-10 flex items-center gap-2 min-w-0">
18
+ <span class="text-xs font-medium text-muted-foreground tabular-nums w-5 text-right flex-shrink-0"><%= idx + 1 %></span>
19
+ <p class="text-xs font-medium text-foreground truncate group-hover:text-primary"><%= page_name.to_s.humanize %></p>
20
+ </div>
21
+ <div class="relative z-10 flex items-center gap-2 flex-shrink-0">
22
+ <span class="text-xs text-muted-foreground tabular-nums"><%= pct %>%</span>
23
+ <span class="text-xs font-semibold tabular-nums text-foreground"><%= number_with_delimiter(count) %></span>
24
+ </div>
25
+ <% end %>
26
+ <% end %>
27
+ </div>
28
+ <% else %>
29
+ <div class="px-4 py-8 text-center">
30
+ <p class="text-sm font-medium text-foreground"><%= t("ruby_cms.admin.analytics.no_data", default: "No data yet.") %></p>
31
+ <p class="mt-1 text-sm text-muted-foreground">Track some traffic to see page analytics.</p>
32
+ </div>
33
+ <% end %>
34
+ </div>
@@ -1,31 +1,33 @@
1
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
- <div class="px-6 py-4 border-b border-gray-100">
3
- <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.recent_activity", default: "Recent activity") %></p>
4
- <p class="text-sm text-gray-500">Latest page views.</p>
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.recent_activity", default: "Recent Activity") %></p>
4
+ <p class="text-sm text-muted-foreground">Latest page views.</p>
5
5
  </div>
6
6
 
7
7
  <% if @recent_page_views.present? && @recent_page_views.any? %>
8
- <div class="divide-y divide-gray-100">
8
+ <div class="divide-y divide-border/40">
9
9
  <% @recent_page_views.first(6).each do |event| %>
10
10
  <% page_name = event.respond_to?(:page_name) ? event.page_name : nil %>
11
11
  <% if page_name.present? %>
12
12
  <%= link_to page_details_ruby_cms_admin_analytics_path(page_name: page_name, start_date: @start_date, end_date: @end_date, period: @period),
13
- class: "block px-6 py-4 hover:bg-gray-50 transition-colors" do %>
14
- <p class="text-sm font-medium text-gray-900 truncate"><%= page_name.to_s.humanize %></p>
15
- <p class="mt-1 text-sm text-gray-500"><%= time_ago_in_words(event.time) %> ago</p>
13
+ class: "group flex items-center justify-between gap-2 px-4 py-2 hover:bg-muted/50 transition-colors" do %>
14
+ <div class="min-w-0">
15
+ <p class="text-xs font-medium text-foreground truncate group-hover:text-primary"><%= page_name.to_s.humanize %></p>
16
+ <p class="text-[10px] text-muted-foreground mt-0.5"><%= time_ago_in_words(event.time) %> ago</p>
17
+ </div>
16
18
  <% end %>
17
19
  <% else %>
18
- <div class="px-6 py-4">
19
- <p class="text-sm font-medium text-gray-900"><%= t("ruby_cms.admin.analytics.page", default: "Page") %></p>
20
- <p class="mt-1 text-sm text-gray-500"><%= time_ago_in_words(event.time) %> ago</p>
20
+ <div class="px-4 py-2">
21
+ <p class="text-xs font-medium text-foreground"><%= t("ruby_cms.admin.analytics.page", default: "Page") %></p>
22
+ <p class="text-[10px] text-muted-foreground mt-0.5"><%= time_ago_in_words(event.time) %> ago</p>
21
23
  </div>
22
24
  <% end %>
23
25
  <% end %>
24
26
  </div>
25
27
  <% else %>
26
- <div class="px-6 py-12 text-center">
27
- <p class="text-sm font-medium text-gray-900"><%= t("ruby_cms.admin.analytics.no_activity", default: "No activity recorded") %></p>
28
- <p class="mt-1 text-sm text-gray-500">No page views in selected range.</p>
28
+ <div class="px-4 py-10 text-center">
29
+ <p class="text-sm font-medium text-foreground"><%= t("ruby_cms.admin.analytics.no_activity", default: "No activity recorded") %></p>
30
+ <p class="mt-1 text-sm text-muted-foreground">No page views in selected range.</p>
29
31
  </div>
30
32
  <% end %>
31
33
  </div>
@@ -1,4 +1,4 @@
1
- <div class="inline-flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-1.5 text-sm font-medium text-amber-900">
2
- <span aria-hidden="true">⚠</span>
3
- <span><%= t("ruby_cms.admin.analytics.security_alert", default: "Security alert") %></span>
1
+ <div class="inline-flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-3 py-1.5 text-sm font-medium text-amber-900">
2
+ <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>
3
+ <%= t("ruby_cms.admin.analytics.security_alert", default: "Security alert") %>
4
4
  </div>
@@ -1,21 +1,21 @@
1
- <div class="rounded-lg border border-gray-200/80 bg-white shadow-sm">
2
- <div class="px-6 py-4 border-b border-gray-100">
3
- <p class="text-sm font-semibold text-gray-900"><%= t("ruby_cms.admin.analytics.top_referrers", default: "Top referrers") %></p>
4
- <p class="text-sm text-gray-500">Where visitors came from.</p>
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.top_referrers", default: "Top Referrers") %></p>
4
+ <p class="text-sm text-muted-foreground">Where visitors came from.</p>
5
5
  </div>
6
6
 
7
- <div class="px-6 py-4">
7
+ <div class="divide-y divide-border/40">
8
8
  <% if @top_referrers.present? && @top_referrers.any? %>
9
- <ul class="space-y-2">
10
- <% @top_referrers.first(8).each do |referrer, count| %>
11
- <li class="flex items-center justify-between gap-3">
12
- <span class="text-sm text-gray-700 truncate" title="<%= referrer %>"><%= referrer %></span>
13
- <span class="text-sm font-semibold tabular-nums text-gray-900"><%= number_with_delimiter(count) %></span>
14
- </li>
15
- <% end %>
16
- </ul>
9
+ <% @top_referrers.first(6).each do |referrer, count| %>
10
+ <div class="flex items-center justify-between gap-3 px-4 py-2">
11
+ <span class="text-xs text-foreground truncate" title="<%= referrer %>"><%= truncate(referrer.to_s.gsub(%r{https?://}, ''), length: 40) %></span>
12
+ <span class="text-xs font-semibold tabular-nums text-foreground flex-shrink-0"><%= number_with_delimiter(count) %></span>
13
+ </div>
14
+ <% end %>
17
15
  <% else %>
18
- <p class="text-sm text-gray-500"><%= t("ruby_cms.admin.analytics.no_referrers", default: "No referrers recorded") %></p>
16
+ <div class="px-4 py-8 text-center">
17
+ <p class="text-sm text-muted-foreground"><%= t("ruby_cms.admin.analytics.no_referrers", default: "No referrers recorded") %></p>
18
+ </div>
19
19
  <% end %>
20
20
  </div>
21
21
  </div>
@@ -0,0 +1,28 @@
1
+ <div class="rounded-lg border border-border/60 bg-white shadow-sm">
2
+ <div class="flex items-center justify-between gap-3 px-4 py-3 border-b border-border/40">
3
+ <div>
4
+ <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.top_visitors", default: "Top Visitors") %></p>
5
+ <p class="text-sm text-muted-foreground">Most active IPs.</p>
6
+ </div>
7
+ </div>
8
+
9
+ <% if @top_visitors.present? %>
10
+ <div class="divide-y divide-border/40">
11
+ <% @top_visitors.first(8).each_with_index do |(ip, count), idx| %>
12
+ <%= link_to visitor_details_ruby_cms_admin_analytics_path(ip_address: ip, start_date: @start_date, end_date: @end_date, period: @period),
13
+ class: "group flex items-center justify-between gap-2 px-4 py-2 hover:bg-muted/50 transition-colors" do %>
14
+ <div class="flex items-center gap-2 min-w-0">
15
+ <span class="text-xs font-medium text-muted-foreground tabular-nums w-5 text-right flex-shrink-0"><%= idx + 1 %></span>
16
+ <p class="text-xs font-medium text-foreground tabular-nums truncate group-hover:text-primary"><%= ip %></p>
17
+ </div>
18
+ <span class="text-xs font-semibold tabular-nums text-foreground flex-shrink-0"><%= number_with_delimiter(count) %></span>
19
+ <% end %>
20
+ <% end %>
21
+ </div>
22
+ <% else %>
23
+ <div class="px-4 py-8 text-center">
24
+ <p class="text-sm font-medium text-foreground"><%= t("ruby_cms.admin.analytics.no_data", default: "No data yet.") %></p>
25
+ <p class="mt-1 text-sm text-muted-foreground">No visitors in selected range.</p>
26
+ </div>
27
+ <% end %>
28
+ </div>
@@ -0,0 +1,21 @@
1
+ <div class="rounded-lg border border-border/60 bg-white shadow-sm">
2
+ <div class="px-6 py-4 border-b border-border/40">
3
+ <p class="text-sm font-semibold text-foreground"><%= t("ruby_cms.admin.analytics.utm_sources", default: "UTM Sources") %></p>
4
+ <p class="text-sm text-muted-foreground">Campaign traffic breakdown.</p>
5
+ </div>
6
+
7
+ <div class="divide-y divide-border/40">
8
+ <% if @utm_sources.present? && @utm_sources.any? %>
9
+ <% @utm_sources.first(6).each do |source, count| %>
10
+ <div class="flex items-center justify-between gap-3 px-6 py-3">
11
+ <span class="text-sm text-foreground truncate"><%= source %></span>
12
+ <span class="text-sm font-semibold tabular-nums text-foreground flex-shrink-0"><%= number_with_delimiter(count) %></span>
13
+ </div>
14
+ <% end %>
15
+ <% else %>
16
+ <div class="px-6 py-10 text-center">
17
+ <p class="text-sm text-muted-foreground">No UTM campaign data</p>
18
+ </div>
19
+ <% end %>
20
+ </div>
21
+ </div>