rails_onboarding 0.6.1
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1351 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/rails_onboarding/admin/chart_controller.js +25 -0
- data/app/assets/javascripts/rails_onboarding/admin/filter_controller.js +49 -0
- data/app/assets/javascripts/rails_onboarding/admin/flash_controller.js +21 -0
- data/app/assets/javascripts/rails_onboarding/admin/flow_editor_controller.js +86 -0
- data/app/assets/javascripts/rails_onboarding/application.js +231 -0
- data/app/assets/javascripts/rails_onboarding/milestone_celebration_controller.js +177 -0
- data/app/assets/javascripts/rails_onboarding/milestone_dashboard_controller.js +132 -0
- data/app/assets/javascripts/rails_onboarding/milestone_detail_controller.js +34 -0
- data/app/assets/javascripts/rails_onboarding/navigation_controller.js +277 -0
- data/app/assets/javascripts/rails_onboarding/onboarding_controller.js +326 -0
- data/app/assets/javascripts/rails_onboarding/progress_controller.js +203 -0
- data/app/assets/javascripts/rails_onboarding/progressive_disclosure_controller.js +218 -0
- data/app/assets/javascripts/rails_onboarding/tooltip_controller.js +1237 -0
- data/app/assets/javascripts/rails_onboarding/tooltip_scheduler_controller.js +511 -0
- data/app/assets/javascripts/rails_onboarding/tour_controller.js +763 -0
- data/app/assets/stylesheets/rails_onboarding/accessibility.css +536 -0
- data/app/assets/stylesheets/rails_onboarding/admin.css +816 -0
- data/app/assets/stylesheets/rails_onboarding/application.css +1145 -0
- data/app/assets/stylesheets/rails_onboarding/flash_messages.css +237 -0
- data/app/assets/stylesheets/rails_onboarding/milestones.css +545 -0
- data/app/assets/stylesheets/rails_onboarding/mobile.css +856 -0
- data/app/assets/stylesheets/rails_onboarding/progressive_disclosure.css +299 -0
- data/app/assets/stylesheets/rails_onboarding/tooltips.css +468 -0
- data/app/assets/stylesheets/rails_onboarding/tour.css +495 -0
- data/app/assets/stylesheets/rails_onboarding/utilities.css +393 -0
- data/app/controllers/concerns/rails_onboarding/admin_authorization.rb +98 -0
- data/app/controllers/concerns/rails_onboarding/rate_limitable.rb +104 -0
- data/app/controllers/rails_onboarding/ab_tests_controller.rb +257 -0
- data/app/controllers/rails_onboarding/admin/ab_tests_controller.rb +121 -0
- data/app/controllers/rails_onboarding/admin/base_controller.rb +68 -0
- data/app/controllers/rails_onboarding/admin/dashboard_controller.rb +189 -0
- data/app/controllers/rails_onboarding/admin/flows_controller.rb +175 -0
- data/app/controllers/rails_onboarding/admin/users_controller.rb +366 -0
- data/app/controllers/rails_onboarding/application_controller.rb +9 -0
- data/app/controllers/rails_onboarding/milestones_controller.rb +126 -0
- data/app/controllers/rails_onboarding/onboarding_controller.rb +409 -0
- data/app/controllers/rails_onboarding/progressive_features_controller.rb +134 -0
- data/app/controllers/rails_onboarding/templates_controller.rb +162 -0
- data/app/controllers/rails_onboarding/test_sessions_controller.rb +16 -0
- data/app/controllers/rails_onboarding/tooltips_controller.rb +92 -0
- data/app/helpers/rails_onboarding/application_helper.rb +4 -0
- data/app/helpers/rails_onboarding/i18n_helper.rb +137 -0
- data/app/helpers/rails_onboarding/personalization_helper.rb +184 -0
- data/app/helpers/rails_onboarding/templates_helper.rb +253 -0
- data/app/jobs/rails_onboarding/application_job.rb +4 -0
- data/app/mailers/rails_onboarding/application_mailer.rb +6 -0
- data/app/mailers/rails_onboarding/onboarding_mailer.rb +72 -0
- data/app/models/concerns/rails_onboarding/ab_testable.rb +149 -0
- data/app/models/concerns/rails_onboarding/onboardable.rb +649 -0
- data/app/models/concerns/rails_onboarding/personalizable.rb +211 -0
- data/app/models/concerns/rails_onboarding/progressive_disclosure.rb +276 -0
- data/app/models/rails_onboarding/analytics.rb +255 -0
- data/app/models/rails_onboarding/analytics_event.rb +168 -0
- data/app/models/rails_onboarding/application_record.rb +5 -0
- data/app/models/rails_onboarding/flow.rb +41 -0
- data/app/services/rails_onboarding/milestone_service.rb +105 -0
- data/app/views/layouts/rails_onboarding/admin.html.erb +106 -0
- data/app/views/layouts/rails_onboarding/application.html.erb +65 -0
- data/app/views/rails_onboarding/ab_tests/index.html.erb +209 -0
- data/app/views/rails_onboarding/ab_tests/results.html.erb +253 -0
- data/app/views/rails_onboarding/admin/ab_tests/_ab_test_card.html.erb +24 -0
- data/app/views/rails_onboarding/admin/ab_tests/index.html.erb +57 -0
- data/app/views/rails_onboarding/admin/ab_tests/show.html.erb +41 -0
- data/app/views/rails_onboarding/admin/dashboard/index.html.erb +224 -0
- data/app/views/rails_onboarding/admin/flows/_form.html.erb +147 -0
- data/app/views/rails_onboarding/admin/flows/edit.html.erb +7 -0
- data/app/views/rails_onboarding/admin/flows/index.html.erb +125 -0
- data/app/views/rails_onboarding/admin/flows/new.html.erb +7 -0
- data/app/views/rails_onboarding/admin/flows/preview.html.erb +40 -0
- data/app/views/rails_onboarding/admin/flows/show.html.erb +114 -0
- data/app/views/rails_onboarding/admin/users/index.html.erb +180 -0
- data/app/views/rails_onboarding/admin/users/show.html.erb +187 -0
- data/app/views/rails_onboarding/milestones/index.html.erb +64 -0
- data/app/views/rails_onboarding/milestones/show.html.erb +57 -0
- data/app/views/rails_onboarding/onboarding/_custom_footer.html.erb +2 -0
- data/app/views/rails_onboarding/onboarding/_progress_indicator.html.erb +56 -0
- data/app/views/rails_onboarding/onboarding/_step_navigation.html.erb +44 -0
- data/app/views/rails_onboarding/onboarding/step.html.erb +38 -0
- data/app/views/rails_onboarding/onboarding/welcome.html.erb +37 -0
- data/app/views/rails_onboarding/onboarding_mailer/completion_email.html.erb +109 -0
- data/app/views/rails_onboarding/onboarding_mailer/completion_email.text.erb +29 -0
- data/app/views/rails_onboarding/onboarding_mailer/reminder_email.html.erb +99 -0
- data/app/views/rails_onboarding/onboarding_mailer/reminder_email.text.erb +20 -0
- data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.html.erb +105 -0
- data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.text.erb +19 -0
- data/app/views/rails_onboarding/onboarding_mailer/welcome_email.html.erb +74 -0
- data/app/views/rails_onboarding/onboarding_mailer/welcome_email.text.erb +17 -0
- data/app/views/rails_onboarding/shared/_flash.html.erb +25 -0
- data/app/views/rails_onboarding/shared/_milestone_badge.html.erb +22 -0
- data/app/views/rails_onboarding/shared/_milestone_celebration.html.erb +44 -0
- data/app/views/rails_onboarding/shared/_onboarding_banner.html.erb +48 -0
- data/app/views/rails_onboarding/templates/index.html.erb +255 -0
- data/config/importmap.rb +33 -0
- data/config/locales/en.yml +62 -0
- data/config/locales/es.yml +62 -0
- data/config/locales/fr.yml +62 -0
- data/config/routes.rb +79 -0
- data/lib/generators/rails_onboarding/install_generator.rb +124 -0
- data/lib/generators/rails_onboarding/templates/README +120 -0
- data/lib/generators/rails_onboarding/templates/add_analytics_to_rails_onboarding.rb +33 -0
- data/lib/generators/rails_onboarding/templates/add_milestone_tracking_to_users.rb +38 -0
- data/lib/generators/rails_onboarding/templates/add_onboarding_indexes.rb +93 -0
- data/lib/generators/rails_onboarding/templates/add_onboarding_to_users.rb +51 -0
- data/lib/generators/rails_onboarding/templates/add_robustness_fields_to_users.rb.tt +20 -0
- data/lib/generators/rails_onboarding/templates/create_rails_onboarding_flows.rb +18 -0
- data/lib/generators/rails_onboarding/templates/onboarding.css +18 -0
- data/lib/generators/rails_onboarding/templates/rails_onboarding.rb +75 -0
- data/lib/generators/rails_onboarding/update_generator.rb +73 -0
- data/lib/rails_onboarding/api_mode.rb +367 -0
- data/lib/rails_onboarding/backfill.rb +130 -0
- data/lib/rails_onboarding/background_jobs.rb +412 -0
- data/lib/rails_onboarding/caching.rb +180 -0
- data/lib/rails_onboarding/cdn_support.rb +202 -0
- data/lib/rails_onboarding/configuration/ab_testing.rb +24 -0
- data/lib/rails_onboarding/configuration/analytics.rb +29 -0
- data/lib/rails_onboarding/configuration/integrations.rb +31 -0
- data/lib/rails_onboarding/configuration/milestones.rb +92 -0
- data/lib/rails_onboarding/configuration/personalization.rb +25 -0
- data/lib/rails_onboarding/configuration/progressive_disclosure.rb +41 -0
- data/lib/rails_onboarding/configuration/rate_limiting.rb +18 -0
- data/lib/rails_onboarding/configuration/steps.rb +121 -0
- data/lib/rails_onboarding/configuration/templates.rb +85 -0
- data/lib/rails_onboarding/configuration/tooltips.rb +32 -0
- data/lib/rails_onboarding/configuration.rb +133 -0
- data/lib/rails_onboarding/configuration_errors.rb +19 -0
- data/lib/rails_onboarding/configuration_validator.rb +477 -0
- data/lib/rails_onboarding/controller_helpers.rb +153 -0
- data/lib/rails_onboarding/current.rb +13 -0
- data/lib/rails_onboarding/deprecation.rb +64 -0
- data/lib/rails_onboarding/devise_integration.rb +122 -0
- data/lib/rails_onboarding/engine.rb +245 -0
- data/lib/rails_onboarding/error_recovery.rb +173 -0
- data/lib/rails_onboarding/lazy_loading.rb +155 -0
- data/lib/rails_onboarding/multi_tenant.rb +306 -0
- data/lib/rails_onboarding/requirements_validator.rb +315 -0
- data/lib/rails_onboarding/responsive_helper.rb +218 -0
- data/lib/rails_onboarding/session_manager.rb +187 -0
- data/lib/rails_onboarding/skip_logic.rb +159 -0
- data/lib/rails_onboarding/turbo_compatibility.rb +229 -0
- data/lib/rails_onboarding/version.rb +3 -0
- data/lib/rails_onboarding.rb +44 -0
- data/lib/tasks/rails_onboarding_analytics.rake +136 -0
- data/lib/tasks/rails_onboarding_tasks.rake +162 -0
- metadata +303 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
class Analytics
|
|
3
|
+
# Default pagination settings to prevent memory issues
|
|
4
|
+
DEFAULT_PAGE_SIZE = 1000
|
|
5
|
+
MAX_PAGE_SIZE = 10000
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
# Summary methods for analytics reporting
|
|
9
|
+
|
|
10
|
+
def onboarding_completion_rate(date_range: nil)
|
|
11
|
+
events = base_events_query(date_range)
|
|
12
|
+
|
|
13
|
+
started_count = events.by_event_type(AnalyticsEvent::ONBOARDING_STARTED).count
|
|
14
|
+
return 0.0 if started_count.zero?
|
|
15
|
+
|
|
16
|
+
completed_count = events.by_event_type(AnalyticsEvent::ONBOARDING_COMPLETED).count
|
|
17
|
+
|
|
18
|
+
(completed_count.to_f / started_count * 100).round(2)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def onboarding_skip_rate(date_range: nil)
|
|
22
|
+
events = base_events_query(date_range)
|
|
23
|
+
|
|
24
|
+
started_count = events.by_event_type(AnalyticsEvent::ONBOARDING_STARTED).count
|
|
25
|
+
return 0.0 if started_count.zero?
|
|
26
|
+
|
|
27
|
+
skipped_count = events.by_event_type(AnalyticsEvent::ONBOARDING_SKIPPED).count
|
|
28
|
+
|
|
29
|
+
(skipped_count.to_f / started_count * 100).round(2)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def average_completion_time(date_range: nil)
|
|
33
|
+
events = base_events_query(date_range)
|
|
34
|
+
completion_events = events.by_event_type(AnalyticsEvent::ONBOARDING_COMPLETED)
|
|
35
|
+
|
|
36
|
+
# properties is serialized JSON stored in a plain text column, so
|
|
37
|
+
# filtering by a key inside it has to happen in Ruby, not SQL -
|
|
38
|
+
# JSON_EXTRACT is a MySQL/SQLite function name; PostgreSQL has no
|
|
39
|
+
# such function (it uses ->/->> on an actual json/jsonb column).
|
|
40
|
+
# Process in batches to prevent memory issues.
|
|
41
|
+
total_time = 0.0
|
|
42
|
+
count = 0
|
|
43
|
+
|
|
44
|
+
completion_events.find_each(batch_size: DEFAULT_PAGE_SIZE) do |event|
|
|
45
|
+
completion_time = event.properties&.dig("completion_time_seconds")
|
|
46
|
+
next if completion_time.nil?
|
|
47
|
+
|
|
48
|
+
total_time += completion_time.to_f
|
|
49
|
+
count += 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return 0.0 if count.zero?
|
|
53
|
+
|
|
54
|
+
(total_time / count).round(2)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def step_completion_rates(date_range: nil)
|
|
58
|
+
events = base_events_query(date_range)
|
|
59
|
+
|
|
60
|
+
step_events = events.by_event_type(AnalyticsEvent::ONBOARDING_STEP_COMPLETED)
|
|
61
|
+
|
|
62
|
+
# Process in batches to prevent memory issues
|
|
63
|
+
step_counts = Hash.new(0)
|
|
64
|
+
step_events.find_each(batch_size: DEFAULT_PAGE_SIZE) do |event|
|
|
65
|
+
step_name = event.properties&.dig("step_name")
|
|
66
|
+
step_counts[step_name] += 1 if step_name
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
total_users = events.by_event_type(AnalyticsEvent::ONBOARDING_STARTED).count
|
|
70
|
+
return {} if total_users.zero?
|
|
71
|
+
|
|
72
|
+
step_counts.transform_values do |count|
|
|
73
|
+
(count.to_f / total_users * 100).round(2)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def step_skip_rates(date_range: nil)
|
|
78
|
+
events = base_events_query(date_range)
|
|
79
|
+
|
|
80
|
+
skip_events = events.by_event_type(AnalyticsEvent::ONBOARDING_STEP_SKIPPED)
|
|
81
|
+
|
|
82
|
+
# Process in batches to prevent memory issues
|
|
83
|
+
step_skips = Hash.new(0)
|
|
84
|
+
skip_events.find_each(batch_size: DEFAULT_PAGE_SIZE) do |event|
|
|
85
|
+
step_name = event.properties&.dig("step_name")
|
|
86
|
+
step_skips[step_name] += 1 if step_name
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
total_users = events.by_event_type(AnalyticsEvent::ONBOARDING_STARTED).count
|
|
90
|
+
return {} if total_users.zero?
|
|
91
|
+
|
|
92
|
+
step_skips.transform_values do |count|
|
|
93
|
+
(count.to_f / total_users * 100).round(2)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def average_step_completion_times(date_range: nil)
|
|
98
|
+
events = base_events_query(date_range)
|
|
99
|
+
|
|
100
|
+
# properties is serialized JSON stored in a plain text column, so
|
|
101
|
+
# filtering by a key inside it has to happen in Ruby, not SQL (see
|
|
102
|
+
# average_completion_time above) - the time_spent > 0 check below
|
|
103
|
+
# already excludes events with no time_spent_seconds recorded.
|
|
104
|
+
step_events = events.by_event_type(AnalyticsEvent::ONBOARDING_STEP_COMPLETED)
|
|
105
|
+
|
|
106
|
+
# Process in batches to prevent memory issues
|
|
107
|
+
step_times = Hash.new { |h, k| h[k] = { total: 0.0, count: 0 } }
|
|
108
|
+
step_events.find_each(batch_size: DEFAULT_PAGE_SIZE) do |event|
|
|
109
|
+
step_name = event.properties&.dig("step_name")
|
|
110
|
+
time_spent = event.properties&.dig("time_spent_seconds").to_f
|
|
111
|
+
if step_name && time_spent > 0
|
|
112
|
+
step_times[step_name][:total] += time_spent
|
|
113
|
+
step_times[step_name][:count] += 1
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
step_times.transform_values do |data|
|
|
118
|
+
data[:count] > 0 ? (data[:total] / data[:count]).round(2) : 0.0
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def tooltip_engagement_rate(date_range: nil)
|
|
123
|
+
events = base_events_query(date_range)
|
|
124
|
+
|
|
125
|
+
shown_count = events.by_event_type(AnalyticsEvent::TOOLTIP_SHOWN).count
|
|
126
|
+
return 0.0 if shown_count.zero?
|
|
127
|
+
|
|
128
|
+
clicked_count = events.by_event_type(AnalyticsEvent::TOOLTIP_CLICKED).count
|
|
129
|
+
|
|
130
|
+
(clicked_count.to_f / shown_count * 100).round(2)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def tooltip_metrics_by_feature(date_range: nil)
|
|
134
|
+
events = base_events_query(date_range)
|
|
135
|
+
|
|
136
|
+
tooltip_events = events.where(event_type: [
|
|
137
|
+
AnalyticsEvent::TOOLTIP_SHOWN,
|
|
138
|
+
AnalyticsEvent::TOOLTIP_CLICKED,
|
|
139
|
+
AnalyticsEvent::TOOLTIP_DISMISSED
|
|
140
|
+
])
|
|
141
|
+
|
|
142
|
+
# Process in batches to prevent memory issues
|
|
143
|
+
feature_metrics = Hash.new { |h, k| h[k] = { shown: 0, clicked: 0, dismissed: 0 } }
|
|
144
|
+
tooltip_events.find_each(batch_size: DEFAULT_PAGE_SIZE) do |event|
|
|
145
|
+
feature = event.properties&.dig("tooltip_feature")
|
|
146
|
+
next unless feature
|
|
147
|
+
|
|
148
|
+
case event.event_type
|
|
149
|
+
when AnalyticsEvent::TOOLTIP_SHOWN
|
|
150
|
+
feature_metrics[feature][:shown] += 1
|
|
151
|
+
when AnalyticsEvent::TOOLTIP_CLICKED
|
|
152
|
+
feature_metrics[feature][:clicked] += 1
|
|
153
|
+
when AnalyticsEvent::TOOLTIP_DISMISSED
|
|
154
|
+
feature_metrics[feature][:dismissed] += 1
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
feature_metrics.map do |feature, metrics|
|
|
159
|
+
engagement_rate = metrics[:shown] > 0 ? (metrics[:clicked].to_f / metrics[:shown] * 100).round(2) : 0.0
|
|
160
|
+
|
|
161
|
+
{
|
|
162
|
+
feature: feature,
|
|
163
|
+
shown: metrics[:shown],
|
|
164
|
+
clicked: metrics[:clicked],
|
|
165
|
+
dismissed: metrics[:dismissed],
|
|
166
|
+
engagement_rate: engagement_rate
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def milestone_achievement_rates(date_range: nil)
|
|
172
|
+
events = base_events_query(date_range)
|
|
173
|
+
|
|
174
|
+
milestone_events = events.by_event_type(AnalyticsEvent::MILESTONE_ACHIEVED)
|
|
175
|
+
|
|
176
|
+
# Process in batches to prevent memory issues
|
|
177
|
+
milestone_counts = Hash.new(0)
|
|
178
|
+
milestone_events.find_each(batch_size: DEFAULT_PAGE_SIZE) do |event|
|
|
179
|
+
milestone_key = event.properties&.dig("milestone_key")
|
|
180
|
+
milestone_counts[milestone_key] += 1 if milestone_key
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
total_users = events.by_event_type(AnalyticsEvent::ONBOARDING_STARTED).count
|
|
184
|
+
return {} if total_users.zero?
|
|
185
|
+
|
|
186
|
+
milestone_counts.transform_values do |count|
|
|
187
|
+
(count.to_f / total_users * 100).round(2)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def funnel_analysis(date_range: nil)
|
|
192
|
+
events = base_events_query(date_range)
|
|
193
|
+
|
|
194
|
+
started = events.by_event_type(AnalyticsEvent::ONBOARDING_STARTED).count
|
|
195
|
+
|
|
196
|
+
# Build step completion counts in batches
|
|
197
|
+
step_completed_events = events.by_event_type(AnalyticsEvent::ONBOARDING_STEP_COMPLETED)
|
|
198
|
+
step_completion_counts = Hash.new(0)
|
|
199
|
+
step_completed_events.find_each(batch_size: DEFAULT_PAGE_SIZE) do |event|
|
|
200
|
+
step_name = event.properties&.dig("step_name").to_s
|
|
201
|
+
step_completion_counts[step_name] += 1 if step_name.present?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
steps = RailsOnboarding.configuration.steps.map.with_index do |step, index|
|
|
205
|
+
completed = step_completion_counts[step[:name].to_s] || 0
|
|
206
|
+
retention_rate = started > 0 ? (completed.to_f / started * 100).round(2) : 0.0
|
|
207
|
+
|
|
208
|
+
{
|
|
209
|
+
step_name: step[:name],
|
|
210
|
+
step_index: index,
|
|
211
|
+
step_title: step[:title],
|
|
212
|
+
users_reached: completed,
|
|
213
|
+
retention_rate: retention_rate
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
completed = events.by_event_type(AnalyticsEvent::ONBOARDING_COMPLETED).count
|
|
218
|
+
overall_completion = started > 0 ? (completed.to_f / started * 100).round(2) : 0.0
|
|
219
|
+
|
|
220
|
+
{
|
|
221
|
+
total_started: started,
|
|
222
|
+
overall_completion_rate: overall_completion,
|
|
223
|
+
steps: steps
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def daily_summary(date: Date.current)
|
|
228
|
+
date_range = date.beginning_of_day..date.end_of_day
|
|
229
|
+
|
|
230
|
+
{
|
|
231
|
+
date: date,
|
|
232
|
+
onboarding_started: base_events_query(date_range).by_event_type(AnalyticsEvent::ONBOARDING_STARTED).count,
|
|
233
|
+
onboarding_completed: base_events_query(date_range).by_event_type(AnalyticsEvent::ONBOARDING_COMPLETED).count,
|
|
234
|
+
onboarding_skipped: base_events_query(date_range).by_event_type(AnalyticsEvent::ONBOARDING_SKIPPED).count,
|
|
235
|
+
tooltips_shown: base_events_query(date_range).by_event_type(AnalyticsEvent::TOOLTIP_SHOWN).count,
|
|
236
|
+
tooltips_clicked: base_events_query(date_range).by_event_type(AnalyticsEvent::TOOLTIP_CLICKED).count,
|
|
237
|
+
milestones_achieved: base_events_query(date_range).by_event_type(AnalyticsEvent::MILESTONE_ACHIEVED).count
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private
|
|
242
|
+
|
|
243
|
+
def base_events_query(date_range = nil)
|
|
244
|
+
return AnalyticsEvent.none unless AnalyticsEvent.table_exists?
|
|
245
|
+
|
|
246
|
+
query = AnalyticsEvent.all
|
|
247
|
+
query = query.by_date_range(date_range.begin, date_range.end) if date_range
|
|
248
|
+
query
|
|
249
|
+
rescue ActiveRecord::StatementInvalid
|
|
250
|
+
# Return empty relation if table doesn't exist
|
|
251
|
+
AnalyticsEvent.none
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
class AnalyticsEvent < ApplicationRecord
|
|
3
|
+
self.table_name = "rails_onboarding_analytics_events"
|
|
4
|
+
|
|
5
|
+
belongs_to :user, polymorphic: true, optional: true
|
|
6
|
+
|
|
7
|
+
# Rails 8 compatibility: Handle polymorphic associations gracefully
|
|
8
|
+
def user=(user_instance)
|
|
9
|
+
if user_instance
|
|
10
|
+
klass = user_instance.class
|
|
11
|
+
unless klass.respond_to?(:has_query_constraints?)
|
|
12
|
+
klass.define_singleton_method(:has_query_constraints?) { false }
|
|
13
|
+
end
|
|
14
|
+
unless klass.respond_to?(:composite_primary_key?)
|
|
15
|
+
klass.define_singleton_method(:composite_primary_key?) { false }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
super(user_instance)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
validates :event_type, presence: true
|
|
22
|
+
validates :occurred_at, presence: true
|
|
23
|
+
|
|
24
|
+
serialize :properties, coder: JSON
|
|
25
|
+
|
|
26
|
+
scope :by_event_type, ->(type) { where(event_type: type) }
|
|
27
|
+
scope :by_user, ->(user) { where(user: user) }
|
|
28
|
+
scope :by_date_range, ->(start_date, end_date) { where(occurred_at: start_date..end_date) }
|
|
29
|
+
scope :recent, ->(limit = 100) { order(occurred_at: :desc).limit(limit) }
|
|
30
|
+
scope :with_user, -> { includes(:user) } # Eager load users to prevent N+1 queries
|
|
31
|
+
scope :ordered, -> { order(occurred_at: :asc) } # Consistent ordering for pagination
|
|
32
|
+
|
|
33
|
+
# Event types
|
|
34
|
+
ONBOARDING_STARTED = "onboarding_started".freeze
|
|
35
|
+
ONBOARDING_STEP_STARTED = "onboarding_step_started".freeze
|
|
36
|
+
ONBOARDING_STEP_COMPLETED = "onboarding_step_completed".freeze
|
|
37
|
+
ONBOARDING_STEP_SKIPPED = "onboarding_step_skipped".freeze
|
|
38
|
+
ONBOARDING_COMPLETED = "onboarding_completed".freeze
|
|
39
|
+
ONBOARDING_SKIPPED = "onboarding_skipped".freeze
|
|
40
|
+
TOOLTIP_SHOWN = "tooltip_shown".freeze
|
|
41
|
+
TOOLTIP_DISMISSED = "tooltip_dismissed".freeze
|
|
42
|
+
TOOLTIP_CLICKED = "tooltip_clicked".freeze
|
|
43
|
+
MILESTONE_ACHIEVED = "milestone_achieved".freeze
|
|
44
|
+
|
|
45
|
+
def self.track_event(user:, event_type:, properties: {}, session_id: nil)
|
|
46
|
+
return unless RailsOnboarding.configuration.enable_analytics
|
|
47
|
+
return unless table_exists?
|
|
48
|
+
|
|
49
|
+
create!(
|
|
50
|
+
user: user,
|
|
51
|
+
event_type: event_type,
|
|
52
|
+
properties: properties,
|
|
53
|
+
session_id: session_id,
|
|
54
|
+
occurred_at: Time.current
|
|
55
|
+
)
|
|
56
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
57
|
+
# Gracefully handle missing table - analytics is optional
|
|
58
|
+
Rails.logger.warn "RailsOnboarding Analytics: #{e.message}" if defined?(Rails)
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.track_onboarding_started(user:, session_id: nil)
|
|
63
|
+
track_event(
|
|
64
|
+
user: user,
|
|
65
|
+
event_type: ONBOARDING_STARTED,
|
|
66
|
+
properties: {
|
|
67
|
+
user_created_at: user.created_at,
|
|
68
|
+
total_steps: RailsOnboarding.configuration.total_steps
|
|
69
|
+
},
|
|
70
|
+
session_id: session_id
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.track_step_started(user:, step_name:, step_index:, session_id: nil)
|
|
75
|
+
track_event(
|
|
76
|
+
user: user,
|
|
77
|
+
event_type: ONBOARDING_STEP_STARTED,
|
|
78
|
+
properties: {
|
|
79
|
+
step_name: step_name,
|
|
80
|
+
step_index: step_index,
|
|
81
|
+
progress_percentage: user.onboarding_progress
|
|
82
|
+
},
|
|
83
|
+
session_id: session_id
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.track_step_completed(user:, step_name:, step_index:, time_spent: nil, session_id: nil)
|
|
88
|
+
track_event(
|
|
89
|
+
user: user,
|
|
90
|
+
event_type: ONBOARDING_STEP_COMPLETED,
|
|
91
|
+
properties: {
|
|
92
|
+
step_name: step_name,
|
|
93
|
+
step_index: step_index,
|
|
94
|
+
time_spent_seconds: time_spent,
|
|
95
|
+
progress_percentage: user.onboarding_progress
|
|
96
|
+
},
|
|
97
|
+
session_id: session_id
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.track_step_skipped(user:, step_name:, step_index:, session_id: nil)
|
|
102
|
+
track_event(
|
|
103
|
+
user: user,
|
|
104
|
+
event_type: ONBOARDING_STEP_SKIPPED,
|
|
105
|
+
properties: {
|
|
106
|
+
step_name: step_name,
|
|
107
|
+
step_index: step_index,
|
|
108
|
+
progress_percentage: user.onboarding_progress
|
|
109
|
+
},
|
|
110
|
+
session_id: session_id
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.track_onboarding_completed(user:, completion_time: nil, was_skipped: false, session_id: nil)
|
|
115
|
+
track_event(
|
|
116
|
+
user: user,
|
|
117
|
+
event_type: was_skipped ? ONBOARDING_SKIPPED : ONBOARDING_COMPLETED,
|
|
118
|
+
properties: {
|
|
119
|
+
completion_time_seconds: completion_time,
|
|
120
|
+
was_skipped: was_skipped,
|
|
121
|
+
total_steps: RailsOnboarding.configuration.total_steps
|
|
122
|
+
},
|
|
123
|
+
session_id: session_id
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.track_tooltip_interaction(user:, tooltip_feature:, action:, session_id: nil)
|
|
128
|
+
event_type = case action.to_s
|
|
129
|
+
when "shown" then TOOLTIP_SHOWN
|
|
130
|
+
when "dismissed" then TOOLTIP_DISMISSED
|
|
131
|
+
when "clicked" then TOOLTIP_CLICKED
|
|
132
|
+
else "tooltip_interaction"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
track_event(
|
|
136
|
+
user: user,
|
|
137
|
+
event_type: event_type,
|
|
138
|
+
properties: {
|
|
139
|
+
tooltip_feature: tooltip_feature,
|
|
140
|
+
action: action
|
|
141
|
+
},
|
|
142
|
+
session_id: session_id
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.track_milestone_achieved(user:, milestone_key:, points_earned:, session_id: nil)
|
|
147
|
+
track_event(
|
|
148
|
+
user: user,
|
|
149
|
+
event_type: MILESTONE_ACHIEVED,
|
|
150
|
+
properties: {
|
|
151
|
+
milestone_key: milestone_key,
|
|
152
|
+
points_earned: points_earned,
|
|
153
|
+
total_points: user.total_milestone_points
|
|
154
|
+
},
|
|
155
|
+
session_id: session_id
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.track_custom_event(user:, event_name:, event_data: {}, session_id: nil)
|
|
160
|
+
track_event(
|
|
161
|
+
user: user,
|
|
162
|
+
event_type: event_name,
|
|
163
|
+
properties: event_data,
|
|
164
|
+
session_id: session_id
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
class Flow < ApplicationRecord
|
|
3
|
+
self.table_name = "rails_onboarding_flows"
|
|
4
|
+
|
|
5
|
+
serialize :steps, coder: JSON
|
|
6
|
+
|
|
7
|
+
validates :name, presence: true
|
|
8
|
+
|
|
9
|
+
scope :active, -> { where(active: true) }
|
|
10
|
+
|
|
11
|
+
# Hash-style reader so views written against the old session-hash flows
|
|
12
|
+
# (flow[:name], flow[:steps], ...) keep working against this AR-backed model.
|
|
13
|
+
def [](key)
|
|
14
|
+
public_send(key)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# JSON round-trips a saved flow's steps back with string keys, but every
|
|
18
|
+
# view reads them with symbols (step[:icon], step[:title], ...). Normalize
|
|
19
|
+
# on read so it doesn't matter whether a step hash was just assigned in
|
|
20
|
+
# memory or came back from the database.
|
|
21
|
+
def steps
|
|
22
|
+
Array(super).map { |step| step.is_a?(Hash) ? step.with_indifferent_access : step }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.seed_default!
|
|
26
|
+
create!(
|
|
27
|
+
name: "Current Configuration",
|
|
28
|
+
description: "Flow from current configuration",
|
|
29
|
+
steps: RailsOnboarding.configuration.steps,
|
|
30
|
+
active: true
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.activate!(flow)
|
|
35
|
+
transaction do
|
|
36
|
+
where.not(id: flow.id).update_all(active: false)
|
|
37
|
+
flow.update!(active: true)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module RailsOnboarding
|
|
2
|
+
class MilestoneService
|
|
3
|
+
def self.check_and_award_milestones(user, trigger, conditions = {})
|
|
4
|
+
return [] unless RailsOnboarding.configuration.enable_milestones
|
|
5
|
+
return [] unless user
|
|
6
|
+
|
|
7
|
+
eligible_milestones = RailsOnboarding.configuration.milestones_for_trigger(trigger, conditions)
|
|
8
|
+
awarded_milestones = []
|
|
9
|
+
|
|
10
|
+
eligible_milestones.each do |milestone|
|
|
11
|
+
next if user.milestone_achieved?(milestone[:key])
|
|
12
|
+
|
|
13
|
+
if milestone_conditions_met?(user, milestone, conditions)
|
|
14
|
+
achieved_milestone = user.achieve_milestone!(milestone[:key])
|
|
15
|
+
awarded_milestones << achieved_milestone if achieved_milestone
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
awarded_milestones
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.check_onboarding_step_milestones(user, step_name)
|
|
23
|
+
check_and_award_milestones(user, :onboarding_step_completed, { step: step_name.to_sym })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.check_onboarding_completion_milestones(user)
|
|
27
|
+
check_and_award_milestones(user, :onboarding_completed)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.check_early_adopter_milestone(user)
|
|
31
|
+
return [] unless user.created_at > 1.hour.ago
|
|
32
|
+
check_and_award_milestones(user, :custom, { early_adopter: true })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Award a milestone requested by the client (e.g. the milestones API),
|
|
36
|
+
# but only if the user genuinely qualifies for it right now. Milestones are
|
|
37
|
+
# normally granted by the server-side event helpers above; this exists for
|
|
38
|
+
# clients that want to claim one explicitly, and it re-derives eligibility
|
|
39
|
+
# from the user's actual state so a request can't grant an unearned award.
|
|
40
|
+
#
|
|
41
|
+
# @return [Hash, nil] the awarded milestone config, or nil if the user is
|
|
42
|
+
# not eligible / already has it / milestones are disabled
|
|
43
|
+
def self.claim_if_eligible(user, milestone_key)
|
|
44
|
+
return nil unless RailsOnboarding.configuration.enable_milestones
|
|
45
|
+
return nil unless user
|
|
46
|
+
return nil if user.milestone_achieved?(milestone_key)
|
|
47
|
+
|
|
48
|
+
milestone = RailsOnboarding.configuration.milestone_by_key(milestone_key)
|
|
49
|
+
return nil unless milestone
|
|
50
|
+
return nil unless user_eligible_for?(user, milestone)
|
|
51
|
+
|
|
52
|
+
user.achieve_milestone!(milestone[:key])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Whether +user+ has actually met the trigger a milestone is awarded for,
|
|
58
|
+
# checked against their real record - not against caller-supplied values.
|
|
59
|
+
# Any trigger/condition we can't confirm server-side is denied.
|
|
60
|
+
def self.user_eligible_for?(user, milestone)
|
|
61
|
+
case milestone[:trigger]
|
|
62
|
+
when :onboarding_step_completed
|
|
63
|
+
step = milestone.dig(:conditions, :step)
|
|
64
|
+
step.present? && user.step_completed?(step)
|
|
65
|
+
when :onboarding_completed
|
|
66
|
+
user.onboarding_completed?
|
|
67
|
+
when :custom
|
|
68
|
+
custom_conditions_met?(user, milestone[:conditions])
|
|
69
|
+
else
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Custom-trigger milestones can carry arbitrary host-defined conditions, so
|
|
75
|
+
# only the ones we know how to verify from the user's record are honored;
|
|
76
|
+
# everything else (including an empty condition set) is treated as unearned.
|
|
77
|
+
def self.custom_conditions_met?(user, conditions)
|
|
78
|
+
return false if conditions.blank?
|
|
79
|
+
|
|
80
|
+
conditions.all? do |key, value|
|
|
81
|
+
case key.to_sym
|
|
82
|
+
when :early_adopter
|
|
83
|
+
value == true && user.created_at.present? && user.created_at > 1.hour.ago
|
|
84
|
+
else
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.milestone_conditions_met?(user, milestone, trigger_conditions)
|
|
91
|
+
return true unless milestone[:conditions]
|
|
92
|
+
|
|
93
|
+
milestone[:conditions].all? do |key, value|
|
|
94
|
+
case key
|
|
95
|
+
when :step
|
|
96
|
+
trigger_conditions[:step] == value.to_sym
|
|
97
|
+
when :early_adopter
|
|
98
|
+
user.created_at > 1.hour.ago
|
|
99
|
+
else
|
|
100
|
+
trigger_conditions[key] == value
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>RailsOnboarding Admin</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= csp_meta_tag %>
|
|
8
|
+
|
|
9
|
+
<%= stylesheet_link_tag "rails_onboarding/application", media: "all" %>
|
|
10
|
+
<%= stylesheet_link_tag "rails_onboarding/admin", media: "all" %>
|
|
11
|
+
<%= javascript_include_tag "rails_onboarding/application", "data-turbo-track": "reload", defer: true %>
|
|
12
|
+
</head>
|
|
13
|
+
|
|
14
|
+
<body class="admin-layout">
|
|
15
|
+
<div class="admin-container">
|
|
16
|
+
<!-- Sidebar Navigation -->
|
|
17
|
+
<aside class="admin-sidebar">
|
|
18
|
+
<div class="admin-sidebar-header">
|
|
19
|
+
<h1 class="admin-logo">
|
|
20
|
+
<span class="admin-logo-icon">๐</span>
|
|
21
|
+
RailsOnboarding
|
|
22
|
+
</h1>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<nav class="admin-nav">
|
|
26
|
+
<%= link_to admin_dashboard_path, class: "admin-nav-item #{current_page?(admin_dashboard_path) ? 'onboarding-active' : ''}" do %>
|
|
27
|
+
<span class="admin-nav-icon">๐</span>
|
|
28
|
+
Dashboard
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<%= link_to admin_users_path, class: "admin-nav-item #{current_page?(admin_users_path) ? 'onboarding-active' : ''}" do %>
|
|
32
|
+
<span class="admin-nav-icon">๐ฅ</span>
|
|
33
|
+
Users
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<%= link_to admin_flows_path, class: "admin-nav-item #{current_page?(admin_flows_path) ? 'onboarding-active' : ''}" do %>
|
|
37
|
+
<span class="admin-nav-icon">๐</span>
|
|
38
|
+
Flows
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<%= link_to admin_ab_tests_path, class: "admin-nav-item #{current_page?(admin_ab_tests_path) ? 'onboarding-active' : ''}" do %>
|
|
42
|
+
<span class="admin-nav-icon">๐งช</span>
|
|
43
|
+
A/B Tests
|
|
44
|
+
<% end %>
|
|
45
|
+
|
|
46
|
+
<%# Leaves the engine for the host app's root. Guarded because a host
|
|
47
|
+
app is not required to define a root route, and a missing one here
|
|
48
|
+
would break every admin page. %>
|
|
49
|
+
<% if main_app.respond_to?(:root_path) %>
|
|
50
|
+
<%= link_to main_app.root_path, class: "admin-nav-item admin-nav-item-home" do %>
|
|
51
|
+
<span class="admin-nav-icon">๐ </span>
|
|
52
|
+
Home
|
|
53
|
+
<% end %>
|
|
54
|
+
<% end %>
|
|
55
|
+
</nav>
|
|
56
|
+
|
|
57
|
+
<div class="admin-sidebar-footer">
|
|
58
|
+
<div class="admin-user-info">
|
|
59
|
+
<% if respond_to?(:current_user) && current_user %>
|
|
60
|
+
<div class="admin-user-avatar">
|
|
61
|
+
<%= current_user.email&.first&.upcase || 'A' %>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="admin-user-details">
|
|
64
|
+
<div class="admin-user-name"><%= current_user.email || 'Admin' %></div>
|
|
65
|
+
<div class="admin-user-role">Administrator</div>
|
|
66
|
+
</div>
|
|
67
|
+
<% end %>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</aside>
|
|
71
|
+
|
|
72
|
+
<!-- Main Content -->
|
|
73
|
+
<main class="admin-main">
|
|
74
|
+
<!-- Header -->
|
|
75
|
+
<header class="admin-header">
|
|
76
|
+
<div class="admin-header-content">
|
|
77
|
+
<h2 class="admin-page-title"><%= yield :page_title %></h2>
|
|
78
|
+
<div class="admin-header-actions">
|
|
79
|
+
<%= yield :header_actions %>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</header>
|
|
83
|
+
|
|
84
|
+
<!-- Flash Messages -->
|
|
85
|
+
<% if flash.any? %>
|
|
86
|
+
<div class="admin-flash-messages">
|
|
87
|
+
<% flash.each do |type, message| %>
|
|
88
|
+
<div class="admin-flash-message admin-flash-<%= type %>" data-controller="flash">
|
|
89
|
+
<span class="admin-flash-icon">
|
|
90
|
+
<%= type == 'notice' ? 'โ' : 'โ ' %>
|
|
91
|
+
</span>
|
|
92
|
+
<span class="admin-flash-text"><%= message %></span>
|
|
93
|
+
<button class="admin-flash-close" data-action="click->flash#dismiss">ร</button>
|
|
94
|
+
</div>
|
|
95
|
+
<% end %>
|
|
96
|
+
</div>
|
|
97
|
+
<% end %>
|
|
98
|
+
|
|
99
|
+
<!-- Page Content -->
|
|
100
|
+
<div class="admin-content">
|
|
101
|
+
<%= yield %>
|
|
102
|
+
</div>
|
|
103
|
+
</main>
|
|
104
|
+
</div>
|
|
105
|
+
</body>
|
|
106
|
+
</html>
|