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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +68 -164
- data/app/components/ruby_cms/admin/admin_page.rb +19 -19
- data/app/components/ruby_cms/admin/admin_page_header.rb +79 -0
- data/app/components/ruby_cms/admin/admin_resource_card.rb +55 -0
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +4 -4
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +5 -5
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +1 -1
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +15 -13
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +13 -11
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +9 -9
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +2 -2
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +8 -8
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +9 -9
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +3 -4
- data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +46 -32
- data/app/controllers/ruby_cms/admin/base_controller.rb +10 -4
- data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +4 -3
- data/app/controllers/ruby_cms/admin/locale_controller.rb +2 -1
- data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +25 -7
- data/app/helpers/ruby_cms/content_blocks_helper.rb +7 -3
- data/app/helpers/ruby_cms/settings_helper.rb +32 -20
- data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +53 -12
- data/app/models/ruby_cms/permission.rb +38 -9
- data/app/models/ruby_cms/permittable.rb +0 -2
- data/app/services/ruby_cms/analytics/report.rb +37 -3
- data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +2 -2
- data/app/views/layouts/ruby_cms/admin.html.erb +13 -17
- data/app/views/ruby_cms/admin/analytics/index.html.erb +103 -108
- data/app/views/ruby_cms/admin/analytics/page_details.html.erb +28 -32
- data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -2
- data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +34 -20
- data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +27 -27
- data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +19 -19
- data/app/views/ruby_cms/admin/analytics/partials/_landing_pages.html.erb +21 -0
- data/app/views/ruby_cms/admin/analytics/partials/_os_stats.html.erb +26 -0
- data/app/views/ruby_cms/admin/analytics/partials/_popular_pages.html.erb +34 -0
- data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +16 -14
- data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +3 -3
- data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +14 -14
- data/app/views/ruby_cms/admin/analytics/partials/_top_visitors.html.erb +28 -0
- data/app/views/ruby_cms/admin/analytics/partials/_utm_sources.html.erb +21 -0
- data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +44 -48
- data/app/views/ruby_cms/admin/content_blocks/index.html.erb +0 -11
- data/app/views/ruby_cms/admin/content_blocks/show.html.erb +204 -85
- data/app/views/ruby_cms/admin/settings/index.html.erb +214 -175
- data/app/views/ruby_cms/admin/user_permissions/index.html.erb +32 -2
- data/app/views/ruby_cms/admin/users/_row.html.erb +4 -1
- data/config/locales/en.yml +4 -0
- data/lib/generators/ruby_cms/install_generator.rb +2 -1
- data/lib/ruby_cms/cli.rb +1 -1
- data/lib/ruby_cms/engine.rb +20 -12
- data/lib/ruby_cms/version.rb +1 -1
- data/lib/ruby_cms.rb +24 -0
- data/lib/tasks/admin.rake +120 -0
- data/log/test.log +7284 -0
- metadata +10 -4
- data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +0 -17
- 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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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.
|
|
533
|
-
|
|
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 =
|
|
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
|
-
|
|
23
|
-
find_or_create_by!(key: k) {|p| p.name = k.
|
|
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:
|
|
30
|
-
unique_visitors:
|
|
31
|
-
total_sessions:
|
|
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:
|
|
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.
|
|
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
|
|
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-
|
|
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-
|
|
7
|
-
|
|
8
|
-
|
|
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-
|
|
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-
|
|
27
|
-
<%= f.date_field :start_date, value: @start_date, class: "h-9 rounded-md border border-
|
|
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-
|
|
31
|
-
<%= f.date_field :end_date, value: @end_date, class: "h-9 rounded-md border border-
|
|
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-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
<
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
<p class="text-sm text-
|
|
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-
|
|
130
|
-
<%
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 %>
|