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.
Files changed (148) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1351 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/rails_onboarding/admin/chart_controller.js +25 -0
  6. data/app/assets/javascripts/rails_onboarding/admin/filter_controller.js +49 -0
  7. data/app/assets/javascripts/rails_onboarding/admin/flash_controller.js +21 -0
  8. data/app/assets/javascripts/rails_onboarding/admin/flow_editor_controller.js +86 -0
  9. data/app/assets/javascripts/rails_onboarding/application.js +231 -0
  10. data/app/assets/javascripts/rails_onboarding/milestone_celebration_controller.js +177 -0
  11. data/app/assets/javascripts/rails_onboarding/milestone_dashboard_controller.js +132 -0
  12. data/app/assets/javascripts/rails_onboarding/milestone_detail_controller.js +34 -0
  13. data/app/assets/javascripts/rails_onboarding/navigation_controller.js +277 -0
  14. data/app/assets/javascripts/rails_onboarding/onboarding_controller.js +326 -0
  15. data/app/assets/javascripts/rails_onboarding/progress_controller.js +203 -0
  16. data/app/assets/javascripts/rails_onboarding/progressive_disclosure_controller.js +218 -0
  17. data/app/assets/javascripts/rails_onboarding/tooltip_controller.js +1237 -0
  18. data/app/assets/javascripts/rails_onboarding/tooltip_scheduler_controller.js +511 -0
  19. data/app/assets/javascripts/rails_onboarding/tour_controller.js +763 -0
  20. data/app/assets/stylesheets/rails_onboarding/accessibility.css +536 -0
  21. data/app/assets/stylesheets/rails_onboarding/admin.css +816 -0
  22. data/app/assets/stylesheets/rails_onboarding/application.css +1145 -0
  23. data/app/assets/stylesheets/rails_onboarding/flash_messages.css +237 -0
  24. data/app/assets/stylesheets/rails_onboarding/milestones.css +545 -0
  25. data/app/assets/stylesheets/rails_onboarding/mobile.css +856 -0
  26. data/app/assets/stylesheets/rails_onboarding/progressive_disclosure.css +299 -0
  27. data/app/assets/stylesheets/rails_onboarding/tooltips.css +468 -0
  28. data/app/assets/stylesheets/rails_onboarding/tour.css +495 -0
  29. data/app/assets/stylesheets/rails_onboarding/utilities.css +393 -0
  30. data/app/controllers/concerns/rails_onboarding/admin_authorization.rb +98 -0
  31. data/app/controllers/concerns/rails_onboarding/rate_limitable.rb +104 -0
  32. data/app/controllers/rails_onboarding/ab_tests_controller.rb +257 -0
  33. data/app/controllers/rails_onboarding/admin/ab_tests_controller.rb +121 -0
  34. data/app/controllers/rails_onboarding/admin/base_controller.rb +68 -0
  35. data/app/controllers/rails_onboarding/admin/dashboard_controller.rb +189 -0
  36. data/app/controllers/rails_onboarding/admin/flows_controller.rb +175 -0
  37. data/app/controllers/rails_onboarding/admin/users_controller.rb +366 -0
  38. data/app/controllers/rails_onboarding/application_controller.rb +9 -0
  39. data/app/controllers/rails_onboarding/milestones_controller.rb +126 -0
  40. data/app/controllers/rails_onboarding/onboarding_controller.rb +409 -0
  41. data/app/controllers/rails_onboarding/progressive_features_controller.rb +134 -0
  42. data/app/controllers/rails_onboarding/templates_controller.rb +162 -0
  43. data/app/controllers/rails_onboarding/test_sessions_controller.rb +16 -0
  44. data/app/controllers/rails_onboarding/tooltips_controller.rb +92 -0
  45. data/app/helpers/rails_onboarding/application_helper.rb +4 -0
  46. data/app/helpers/rails_onboarding/i18n_helper.rb +137 -0
  47. data/app/helpers/rails_onboarding/personalization_helper.rb +184 -0
  48. data/app/helpers/rails_onboarding/templates_helper.rb +253 -0
  49. data/app/jobs/rails_onboarding/application_job.rb +4 -0
  50. data/app/mailers/rails_onboarding/application_mailer.rb +6 -0
  51. data/app/mailers/rails_onboarding/onboarding_mailer.rb +72 -0
  52. data/app/models/concerns/rails_onboarding/ab_testable.rb +149 -0
  53. data/app/models/concerns/rails_onboarding/onboardable.rb +649 -0
  54. data/app/models/concerns/rails_onboarding/personalizable.rb +211 -0
  55. data/app/models/concerns/rails_onboarding/progressive_disclosure.rb +276 -0
  56. data/app/models/rails_onboarding/analytics.rb +255 -0
  57. data/app/models/rails_onboarding/analytics_event.rb +168 -0
  58. data/app/models/rails_onboarding/application_record.rb +5 -0
  59. data/app/models/rails_onboarding/flow.rb +41 -0
  60. data/app/services/rails_onboarding/milestone_service.rb +105 -0
  61. data/app/views/layouts/rails_onboarding/admin.html.erb +106 -0
  62. data/app/views/layouts/rails_onboarding/application.html.erb +65 -0
  63. data/app/views/rails_onboarding/ab_tests/index.html.erb +209 -0
  64. data/app/views/rails_onboarding/ab_tests/results.html.erb +253 -0
  65. data/app/views/rails_onboarding/admin/ab_tests/_ab_test_card.html.erb +24 -0
  66. data/app/views/rails_onboarding/admin/ab_tests/index.html.erb +57 -0
  67. data/app/views/rails_onboarding/admin/ab_tests/show.html.erb +41 -0
  68. data/app/views/rails_onboarding/admin/dashboard/index.html.erb +224 -0
  69. data/app/views/rails_onboarding/admin/flows/_form.html.erb +147 -0
  70. data/app/views/rails_onboarding/admin/flows/edit.html.erb +7 -0
  71. data/app/views/rails_onboarding/admin/flows/index.html.erb +125 -0
  72. data/app/views/rails_onboarding/admin/flows/new.html.erb +7 -0
  73. data/app/views/rails_onboarding/admin/flows/preview.html.erb +40 -0
  74. data/app/views/rails_onboarding/admin/flows/show.html.erb +114 -0
  75. data/app/views/rails_onboarding/admin/users/index.html.erb +180 -0
  76. data/app/views/rails_onboarding/admin/users/show.html.erb +187 -0
  77. data/app/views/rails_onboarding/milestones/index.html.erb +64 -0
  78. data/app/views/rails_onboarding/milestones/show.html.erb +57 -0
  79. data/app/views/rails_onboarding/onboarding/_custom_footer.html.erb +2 -0
  80. data/app/views/rails_onboarding/onboarding/_progress_indicator.html.erb +56 -0
  81. data/app/views/rails_onboarding/onboarding/_step_navigation.html.erb +44 -0
  82. data/app/views/rails_onboarding/onboarding/step.html.erb +38 -0
  83. data/app/views/rails_onboarding/onboarding/welcome.html.erb +37 -0
  84. data/app/views/rails_onboarding/onboarding_mailer/completion_email.html.erb +109 -0
  85. data/app/views/rails_onboarding/onboarding_mailer/completion_email.text.erb +29 -0
  86. data/app/views/rails_onboarding/onboarding_mailer/reminder_email.html.erb +99 -0
  87. data/app/views/rails_onboarding/onboarding_mailer/reminder_email.text.erb +20 -0
  88. data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.html.erb +105 -0
  89. data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.text.erb +19 -0
  90. data/app/views/rails_onboarding/onboarding_mailer/welcome_email.html.erb +74 -0
  91. data/app/views/rails_onboarding/onboarding_mailer/welcome_email.text.erb +17 -0
  92. data/app/views/rails_onboarding/shared/_flash.html.erb +25 -0
  93. data/app/views/rails_onboarding/shared/_milestone_badge.html.erb +22 -0
  94. data/app/views/rails_onboarding/shared/_milestone_celebration.html.erb +44 -0
  95. data/app/views/rails_onboarding/shared/_onboarding_banner.html.erb +48 -0
  96. data/app/views/rails_onboarding/templates/index.html.erb +255 -0
  97. data/config/importmap.rb +33 -0
  98. data/config/locales/en.yml +62 -0
  99. data/config/locales/es.yml +62 -0
  100. data/config/locales/fr.yml +62 -0
  101. data/config/routes.rb +79 -0
  102. data/lib/generators/rails_onboarding/install_generator.rb +124 -0
  103. data/lib/generators/rails_onboarding/templates/README +120 -0
  104. data/lib/generators/rails_onboarding/templates/add_analytics_to_rails_onboarding.rb +33 -0
  105. data/lib/generators/rails_onboarding/templates/add_milestone_tracking_to_users.rb +38 -0
  106. data/lib/generators/rails_onboarding/templates/add_onboarding_indexes.rb +93 -0
  107. data/lib/generators/rails_onboarding/templates/add_onboarding_to_users.rb +51 -0
  108. data/lib/generators/rails_onboarding/templates/add_robustness_fields_to_users.rb.tt +20 -0
  109. data/lib/generators/rails_onboarding/templates/create_rails_onboarding_flows.rb +18 -0
  110. data/lib/generators/rails_onboarding/templates/onboarding.css +18 -0
  111. data/lib/generators/rails_onboarding/templates/rails_onboarding.rb +75 -0
  112. data/lib/generators/rails_onboarding/update_generator.rb +73 -0
  113. data/lib/rails_onboarding/api_mode.rb +367 -0
  114. data/lib/rails_onboarding/backfill.rb +130 -0
  115. data/lib/rails_onboarding/background_jobs.rb +412 -0
  116. data/lib/rails_onboarding/caching.rb +180 -0
  117. data/lib/rails_onboarding/cdn_support.rb +202 -0
  118. data/lib/rails_onboarding/configuration/ab_testing.rb +24 -0
  119. data/lib/rails_onboarding/configuration/analytics.rb +29 -0
  120. data/lib/rails_onboarding/configuration/integrations.rb +31 -0
  121. data/lib/rails_onboarding/configuration/milestones.rb +92 -0
  122. data/lib/rails_onboarding/configuration/personalization.rb +25 -0
  123. data/lib/rails_onboarding/configuration/progressive_disclosure.rb +41 -0
  124. data/lib/rails_onboarding/configuration/rate_limiting.rb +18 -0
  125. data/lib/rails_onboarding/configuration/steps.rb +121 -0
  126. data/lib/rails_onboarding/configuration/templates.rb +85 -0
  127. data/lib/rails_onboarding/configuration/tooltips.rb +32 -0
  128. data/lib/rails_onboarding/configuration.rb +133 -0
  129. data/lib/rails_onboarding/configuration_errors.rb +19 -0
  130. data/lib/rails_onboarding/configuration_validator.rb +477 -0
  131. data/lib/rails_onboarding/controller_helpers.rb +153 -0
  132. data/lib/rails_onboarding/current.rb +13 -0
  133. data/lib/rails_onboarding/deprecation.rb +64 -0
  134. data/lib/rails_onboarding/devise_integration.rb +122 -0
  135. data/lib/rails_onboarding/engine.rb +245 -0
  136. data/lib/rails_onboarding/error_recovery.rb +173 -0
  137. data/lib/rails_onboarding/lazy_loading.rb +155 -0
  138. data/lib/rails_onboarding/multi_tenant.rb +306 -0
  139. data/lib/rails_onboarding/requirements_validator.rb +315 -0
  140. data/lib/rails_onboarding/responsive_helper.rb +218 -0
  141. data/lib/rails_onboarding/session_manager.rb +187 -0
  142. data/lib/rails_onboarding/skip_logic.rb +159 -0
  143. data/lib/rails_onboarding/turbo_compatibility.rb +229 -0
  144. data/lib/rails_onboarding/version.rb +3 -0
  145. data/lib/rails_onboarding.rb +44 -0
  146. data/lib/tasks/rails_onboarding_analytics.rake +136 -0
  147. data/lib/tasks/rails_onboarding_tasks.rake +162 -0
  148. 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,5 @@
1
+ module RailsOnboarding
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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>