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,649 @@
1
+ module RailsOnboarding
2
+ module Onboardable
3
+ extend ActiveSupport::Concern
4
+
5
+ # Maximum sizes for JSON fields to prevent memory issues
6
+ MAX_TOOLTIPS_SHOWN = 1000
7
+ MAX_MILESTONES_ACHIEVED = 500
8
+ MAX_JSON_SIZE_BYTES = 65_535 # ~64KB for TEXT columns
9
+
10
+ included do
11
+ # Add fields via migration or expect them in the host model
12
+ # The host app should have these columns:
13
+ # - onboarding_completed: boolean
14
+ # - onboarding_completed_at: datetime
15
+ # - onboarding_current_step: string
16
+ # - onboarding_skipped: boolean
17
+ # - feature_tooltips_shown: jsonb/text (serialized)
18
+ # - milestones_achieved: text (serialized JSON array)
19
+ # - milestone_points: integer
20
+ # - last_milestone_at: datetime
21
+
22
+ # Fix for Rails 8: Use the new serialize syntax
23
+ if columns_hash["feature_tooltips_shown"]&.type == :text
24
+ serialize :feature_tooltips_shown, coder: JSON
25
+ end
26
+
27
+ if columns_hash["milestones_achieved"]&.type == :text
28
+ serialize :milestones_achieved, coder: JSON
29
+ end
30
+
31
+ # Association with analytics events (only if ActiveRecord is available)
32
+ if respond_to?(:has_many)
33
+ has_many :analytics_events,
34
+ as: :user,
35
+ class_name: "RailsOnboarding::AnalyticsEvent",
36
+ dependent: :destroy
37
+ end
38
+
39
+ # Validations for JSON field sizes (only if ActiveRecord validations are available)
40
+ if respond_to?(:validate)
41
+ validate :validate_tooltips_size, if: :feature_tooltips_shown_changed?
42
+ validate :validate_milestones_size, if: :milestones_achieved_changed?
43
+ end
44
+ end
45
+
46
+ # Determines if the user needs to go through onboarding
47
+ #
48
+ # This method checks the configuration setting and user state to determine
49
+ # if onboarding should be shown. It supports different strategies:
50
+ # - :new_users - Only users created within the last hour
51
+ # - :all_users - All users who haven't completed onboarding
52
+ # - Proc - Custom logic defined in configuration
53
+ #
54
+ # @return [Boolean] true if onboarding is needed, false otherwise
55
+ def needs_onboarding?
56
+ return false if onboarding_completed?
57
+ return false if onboarding_skipped?
58
+
59
+ case RailsOnboarding.configuration.onboarding_required_for
60
+ when :new_users
61
+ # Users imported before the gem was installed can have a NULL
62
+ # created_at. An unknown signup time is not a recent one, so they're
63
+ # treated as existing users rather than crashing on every request.
64
+ created_at.present? && created_at > 1.hour.ago
65
+ when :all_users
66
+ true
67
+ when Proc
68
+ RailsOnboarding.configuration.onboarding_required_for.call(self)
69
+ else
70
+ true
71
+ end
72
+ end
73
+
74
+ # Calculate onboarding progress as a percentage
75
+ #
76
+ # Returns a value between 0 and 100 representing how far the user has
77
+ # progressed through the onboarding flow. Calculation is based on
78
+ # current step position divided by total steps.
79
+ #
80
+ # @return [Integer] progress percentage (0-100)
81
+ # @example
82
+ # user.onboarding_progress #=> 75
83
+ def onboarding_progress
84
+ return 100 if onboarding_completed?
85
+ return 0 unless onboarding_current_step
86
+
87
+ current_index = RailsOnboarding.configuration.step_index(onboarding_current_step)
88
+ return 0 if current_index.nil?
89
+
90
+ total_steps = RailsOnboarding.configuration.total_steps
91
+
92
+ ((current_index + 1).to_f / total_steps * 100).round
93
+ end
94
+
95
+ # Alias for API compatibility
96
+ alias_method :onboarding_progress_percentage, :onboarding_progress
97
+
98
+ def current_onboarding_step
99
+ return nil if onboarding_completed?
100
+
101
+ step_name = onboarding_current_step || RailsOnboarding.configuration.steps.first[:name]
102
+ step = RailsOnboarding.configuration.step_by_name(step_name)
103
+
104
+ step || RailsOnboarding.configuration.steps.first
105
+ end
106
+
107
+ # Alias for performance testing - returns the same as current_onboarding_step
108
+ alias_method :current_step_info, :current_onboarding_step
109
+
110
+ def next_onboarding_step
111
+ return nil if onboarding_completed?
112
+
113
+ current_index = RailsOnboarding.configuration.step_index(onboarding_current_step)
114
+ return RailsOnboarding.configuration.steps.first unless current_index
115
+
116
+ next_step = RailsOnboarding.configuration.steps[current_index + 1]
117
+ next_step
118
+ end
119
+
120
+ # Complete the current onboarding step and advance to the next
121
+ #
122
+ # This method handles the core onboarding flow logic:
123
+ # 1. Validates the step exists in the configuration
124
+ # 2. Tracks step completion for analytics
125
+ # 3. Checks and awards any relevant milestones
126
+ # 4. Advances to the next step or completes onboarding if on the last step
127
+ #
128
+ # @param step_name [String, Symbol] the name of the step to complete
129
+ # @param session_id [String] optional session identifier for tracking
130
+ # @param time_spent [Integer] optional time spent on step in seconds
131
+ # @return [void]
132
+ # @raise [ActiveRecord::RecordInvalid] if the update fails
133
+ def complete_onboarding_step!(step_name, session_id: nil, time_spent: nil)
134
+ current_index = RailsOnboarding.configuration.step_index(step_name)
135
+
136
+ if current_index.nil?
137
+ # If step not found, complete onboarding
138
+ complete_onboarding!(session_id: session_id)
139
+ return
140
+ end
141
+
142
+ next_step = RailsOnboarding.configuration.steps[current_index + 1]
143
+
144
+ if next_step
145
+ update!(onboarding_current_step: next_step[:name])
146
+ else
147
+ complete_onboarding!(session_id: session_id)
148
+ end
149
+
150
+ # Track step completion and check for milestones only after the step
151
+ # change above has actually persisted - otherwise a failed save would
152
+ # still leave behind an analytics event (and possibly an awarded
153
+ # milestone) for a step completion that never happened.
154
+ AnalyticsEvent.track_step_completed(
155
+ user: self,
156
+ step_name: step_name,
157
+ step_index: current_index,
158
+ time_spent: time_spent,
159
+ session_id: session_id
160
+ )
161
+ check_and_achieve_step_milestones(step_name, session_id: session_id)
162
+ end
163
+
164
+ def complete_onboarding!(session_id: nil, completion_time: nil)
165
+ persist_and_track!(
166
+ onboarding_completed: true,
167
+ onboarding_completed_at: Time.current,
168
+ onboarding_current_step: nil
169
+ ) do
170
+ AnalyticsEvent.track_onboarding_completed(
171
+ user: self,
172
+ completion_time: completion_time,
173
+ was_skipped: false,
174
+ session_id: session_id
175
+ )
176
+ check_and_achieve_completion_milestones(session_id: session_id)
177
+ end
178
+ end
179
+
180
+ def skip_onboarding!(session_id: nil)
181
+ persist_and_track!(
182
+ onboarding_completed: true,
183
+ onboarding_completed_at: Time.current,
184
+ onboarding_skipped: true,
185
+ onboarding_current_step: nil
186
+ ) do
187
+ AnalyticsEvent.track_onboarding_completed(
188
+ user: self,
189
+ completion_time: nil,
190
+ was_skipped: true,
191
+ session_id: session_id
192
+ )
193
+ end
194
+ end
195
+
196
+ def reset_onboarding!
197
+ update!(
198
+ onboarding_completed: false,
199
+ onboarding_completed_at: nil,
200
+ onboarding_skipped: false,
201
+ onboarding_current_step: nil,
202
+ feature_tooltips_shown: {}
203
+ )
204
+ end
205
+
206
+ # Feature tooltips
207
+ def show_feature_tooltip?(feature)
208
+ return false unless RailsOnboarding.configuration.enable_tooltips
209
+ return false unless RailsOnboarding.configuration.feature_tooltips[feature.to_s]
210
+
211
+ shown_tooltips = (feature_tooltips_shown || {})
212
+ !shown_tooltips[feature.to_s]
213
+ end
214
+
215
+ def mark_tooltip_shown!(feature, session_id: nil)
216
+ self.feature_tooltips_shown ||= {}
217
+
218
+ # Trim old entries if approaching limit
219
+ if feature_tooltips_shown.size >= MAX_TOOLTIPS_SHOWN
220
+ trim_oldest_tooltips
221
+ end
222
+
223
+ self.feature_tooltips_shown[feature.to_s] = Time.current.iso8601
224
+
225
+ persist_and_track! do
226
+ AnalyticsEvent.track_tooltip_interaction(
227
+ user: self,
228
+ tooltip_feature: feature,
229
+ action: "shown",
230
+ session_id: session_id
231
+ )
232
+ end
233
+ end
234
+
235
+ # Reset all tooltips (mark all as not shown)
236
+ def reset_tooltips!
237
+ self.feature_tooltips_shown = {}
238
+ save!
239
+ end
240
+
241
+ def track_tooltip_interaction!(feature, action, session_id: nil)
242
+ AnalyticsEvent.track_tooltip_interaction(
243
+ user: self,
244
+ tooltip_feature: feature,
245
+ action: action,
246
+ session_id: session_id
247
+ )
248
+ end
249
+
250
+ # Milestones
251
+ def achieved_milestones
252
+ milestones = milestones_achieved || []
253
+
254
+ # Warn about old format if any string entries are found
255
+ if milestones.any? { |m| m.is_a?(String) }
256
+ RailsOnboarding::Deprecation.deprecate_format(
257
+ "Storing milestones as array of strings",
258
+ new_format: "array of hashes with 'key' and 'achieved_at' fields",
259
+ version: "2.0.0"
260
+ )
261
+ end
262
+
263
+ milestones.map { |m| m.is_a?(Hash) ? m["key"] : m.to_s }
264
+ end
265
+
266
+ def milestone_achieved?(milestone_key)
267
+ achieved_milestones.include?(milestone_key.to_s)
268
+ end
269
+
270
+ # Get the timestamp when a specific milestone was achieved
271
+ #
272
+ # This method supports both old (string array) and new (hash array) formats
273
+ # for backwards compatibility. For old format data, it returns last_milestone_at
274
+ # as a fallback since per-milestone timestamps weren't stored.
275
+ #
276
+ # @param milestone_key [String, Symbol] the milestone key to lookup
277
+ # @return [Time, nil] the achievement timestamp, or nil if not achieved
278
+ # @example
279
+ # user.milestone_achieved_at(:first_login) #=> 2025-01-15 10:30:00 UTC
280
+ def milestone_achieved_at(milestone_key)
281
+ return nil unless milestone_achieved?(milestone_key)
282
+
283
+ # Support both old array format and new hash format
284
+ milestones = milestones_achieved || []
285
+ milestone = milestones.find do |m|
286
+ if m.is_a?(Hash)
287
+ m["key"].to_s == milestone_key.to_s
288
+ else
289
+ m.to_s == milestone_key.to_s
290
+ end
291
+ end
292
+
293
+ # Return timestamp if available, otherwise return last_milestone_at as fallback
294
+ if milestone.is_a?(Hash) && milestone["achieved_at"]
295
+ Time.parse(milestone["achieved_at"])
296
+ else
297
+ last_milestone_at
298
+ end
299
+ rescue StandardError
300
+ last_milestone_at
301
+ end
302
+
303
+ def achieve_milestone!(milestone_key, session_id: nil)
304
+ return false unless RailsOnboarding.configuration.enable_milestones
305
+ return false if milestone_achieved?(milestone_key)
306
+
307
+ milestone_config = RailsOnboarding.configuration.milestone_by_key(milestone_key)
308
+ return false unless milestone_config
309
+
310
+ points_earned = milestone_config[:points] || 0
311
+ achieved_at = Time.current
312
+
313
+ self.milestones_achieved ||= []
314
+ # Store as hash with timestamp for new achievements
315
+ self.milestones_achieved << {
316
+ "key" => milestone_key.to_s,
317
+ "achieved_at" => achieved_at.iso8601
318
+ }
319
+ self.milestone_points = (milestone_points || 0) + points_earned
320
+ self.last_milestone_at = achieved_at
321
+
322
+ persist_and_track! do
323
+ AnalyticsEvent.track_milestone_achieved(
324
+ user: self,
325
+ milestone_key: milestone_key,
326
+ points_earned: points_earned,
327
+ session_id: session_id
328
+ )
329
+ end
330
+
331
+ milestone_config
332
+ end
333
+
334
+ def total_milestone_points
335
+ milestone_points || 0
336
+ end
337
+
338
+ def milestones_available
339
+ return [] unless RailsOnboarding.configuration.enable_milestones
340
+ RailsOnboarding.configuration.milestones.reject { |m| milestone_achieved?(m[:key]) }
341
+ end
342
+
343
+ def recent_milestones(limit: 5)
344
+ return [] unless RailsOnboarding.configuration.enable_milestones
345
+
346
+ achieved_milestones.last(limit).map do |key|
347
+ RailsOnboarding.configuration.milestone_by_key(key)
348
+ end.compact
349
+ end
350
+
351
+ def start_onboarding!(session_id: nil)
352
+ return if onboarding_completed?
353
+
354
+ # Set initial step if not already set
355
+ if onboarding_current_step.nil? && RailsOnboarding.configuration.steps.any?
356
+ update!(onboarding_current_step: RailsOnboarding.configuration.steps.first[:name])
357
+ end
358
+
359
+ AnalyticsEvent.track_onboarding_started(
360
+ user: self,
361
+ session_id: session_id
362
+ )
363
+ end
364
+
365
+ def skip_onboarding_step!(step_name, session_id: nil)
366
+ current_index = RailsOnboarding.configuration.step_index(step_name)
367
+ return unless current_index
368
+
369
+ next_step = RailsOnboarding.configuration.steps[current_index + 1]
370
+
371
+ if next_step
372
+ update!(onboarding_current_step: next_step[:name])
373
+ else
374
+ complete_onboarding!(session_id: session_id)
375
+ end
376
+
377
+ AnalyticsEvent.track_step_skipped(
378
+ user: self,
379
+ step_name: step_name,
380
+ step_index: current_index,
381
+ session_id: session_id
382
+ )
383
+ end
384
+
385
+ # Rollback & Navigation Methods
386
+
387
+ def can_go_back?
388
+ return false unless onboarding_current_step
389
+ current_index = RailsOnboarding.configuration.step_index(onboarding_current_step)
390
+ current_index && current_index > 0
391
+ end
392
+
393
+ def previous_onboarding_step
394
+ return nil unless can_go_back?
395
+ current_index = RailsOnboarding.configuration.step_index(onboarding_current_step)
396
+ RailsOnboarding.configuration.steps[current_index - 1]
397
+ end
398
+
399
+ def go_back!(session_id: nil)
400
+ return false unless can_go_back?
401
+
402
+ prev_step = previous_onboarding_step
403
+ return false unless prev_step
404
+
405
+ from_step = onboarding_current_step
406
+
407
+ persist_and_track!(onboarding_current_step: prev_step[:name]) do
408
+ AnalyticsEvent.track_custom_event(
409
+ user: self,
410
+ event_name: "onboarding_step_back",
411
+ event_data: { from_step: from_step, to_step: prev_step[:name] },
412
+ session_id: session_id
413
+ )
414
+ end
415
+ true
416
+ end
417
+
418
+ def go_to_step!(step_name, session_id: nil)
419
+ step = RailsOnboarding.configuration.step_by_name(step_name)
420
+ return false unless step
421
+
422
+ from_step = onboarding_current_step
423
+
424
+ persist_and_track!(onboarding_current_step: step_name) do
425
+ AnalyticsEvent.track_custom_event(
426
+ user: self,
427
+ event_name: "onboarding_step_jump",
428
+ event_data: { from_step: from_step, to_step: step_name },
429
+ session_id: session_id
430
+ )
431
+ end
432
+ true
433
+ end
434
+
435
+ def restart_onboarding!(session_id: nil)
436
+ previous_step = onboarding_current_step
437
+ was_completed = onboarding_completed
438
+
439
+ reset_onboarding!
440
+ start_onboarding!(session_id: session_id)
441
+
442
+ AnalyticsEvent.track_custom_event(
443
+ user: self,
444
+ event_name: "onboarding_restarted",
445
+ event_data: { previous_step: previous_step, was_completed: was_completed },
446
+ session_id: session_id
447
+ )
448
+ end
449
+
450
+ # API-friendly aliases and helper methods
451
+ alias_method :complete_step, :complete_onboarding_step!
452
+ alias_method :skip_step, :skip_onboarding_step!
453
+ alias_method :next_step, :next_onboarding_step
454
+ alias_method :previous_step, :previous_onboarding_step
455
+ alias_method :reset_onboarding, :reset_onboarding!
456
+ alias_method :mark_tooltip_shown, :mark_tooltip_shown!
457
+
458
+ # Returns the onboarding steps for this user
459
+ #
460
+ # If multi-tenant support is enabled and the user has an organization,
461
+ # returns organization-specific steps. Otherwise returns the default steps.
462
+ #
463
+ # @return [Array<Hash>] Array of step configurations
464
+ def onboarding_steps
465
+ # Check for organization-specific steps via MultiTenant
466
+ if respond_to?(:current_organization_id) && current_organization_id
467
+ MultiTenant.steps_for(current_organization_id)
468
+ elsif respond_to?(:organization_id) && organization_id
469
+ MultiTenant.steps_for(organization_id)
470
+ else
471
+ RailsOnboarding.configuration.steps
472
+ end
473
+ end
474
+
475
+ # Check if a specific step has been completed
476
+ def step_completed?(step_name)
477
+ return false unless step_name
478
+
479
+ step_index = RailsOnboarding.configuration.step_index(step_name)
480
+ return false if step_index.nil?
481
+
482
+ # If onboarding is completed, all steps are completed
483
+ return true if onboarding_completed?
484
+
485
+ # Check if current step is past the requested step
486
+ current_index = RailsOnboarding.configuration.step_index(onboarding_current_step)
487
+ return false if current_index.nil?
488
+
489
+ current_index > step_index
490
+ end
491
+
492
+ # Get available tooltips that haven't been shown
493
+ def available_tooltips
494
+ return [] unless RailsOnboarding.configuration.enable_tooltips
495
+
496
+ RailsOnboarding.configuration.feature_tooltips.select do |key, _config|
497
+ show_feature_tooltip?(key)
498
+ end.map do |key, config|
499
+ {
500
+ id: key.to_s,
501
+ title: config[:title],
502
+ content: config[:content],
503
+ target: config[:target],
504
+ position: config[:position] || "top"
505
+ }
506
+ end
507
+ end
508
+
509
+ # Check if a specific tooltip has been shown to this user
510
+ #
511
+ # This method directly checks the user's feature_tooltips_shown hash
512
+ # to determine if a tooltip was marked as shown, regardless of whether
513
+ # the tooltip is configured in the application settings.
514
+ #
515
+ # @param tooltip_id [String, Symbol] the tooltip identifier
516
+ # @return [Boolean] true if the tooltip has been shown, false otherwise
517
+ def tooltip_shown?(tooltip_id)
518
+ shown_tooltips = feature_tooltips_shown || {}
519
+ shown_tooltips.key?(tooltip_id.to_s)
520
+ end
521
+
522
+ # Dismiss a tooltip (marks as shown and tracks as dismissed)
523
+ def dismiss_tooltip(tooltip_id, session_id: nil)
524
+ # Mark as shown without tracking (to avoid duplicate event)
525
+ self.feature_tooltips_shown ||= {}
526
+ self.feature_tooltips_shown[tooltip_id.to_s] = Time.current.iso8601
527
+ save!
528
+
529
+ # Track as dismissed, not shown
530
+ AnalyticsEvent.track_tooltip_interaction(
531
+ user: self,
532
+ tooltip_feature: tooltip_id,
533
+ action: "dismissed",
534
+ session_id: session_id
535
+ )
536
+ true
537
+ rescue StandardError => e
538
+ Rails.logger.error("Failed to dismiss tooltip: #{e.message}")
539
+ false
540
+ end
541
+
542
+ # Onboarding skipped check
543
+ def onboarding_skipped?
544
+ onboarding_skipped == true
545
+ end
546
+
547
+ private
548
+
549
+ # Persists +attributes+ (or just re-saves already-assigned attributes if
550
+ # none given), then runs the tracking block only after that succeeds.
551
+ # Analytics tracking (and, transitively, any milestone-achievement it
552
+ # triggers) must never fire for a state change that didn't actually get
553
+ # persisted - update!/save! raise on failure, so if that happens, this
554
+ # method never reaches the block.
555
+ def persist_and_track!(attributes = nil)
556
+ if attributes
557
+ update!(attributes)
558
+ else
559
+ save!
560
+ end
561
+
562
+ yield if block_given?
563
+ end
564
+
565
+ # Validation methods for JSON field sizes
566
+ def validate_tooltips_size
567
+ return unless feature_tooltips_shown.is_a?(Hash)
568
+
569
+ # Check number of entries
570
+ if feature_tooltips_shown.size > MAX_TOOLTIPS_SHOWN
571
+ errors.add(:feature_tooltips_shown, "cannot exceed #{MAX_TOOLTIPS_SHOWN} entries")
572
+ end
573
+
574
+ # Check serialized size
575
+ json_size = feature_tooltips_shown.to_json.bytesize
576
+ if json_size > MAX_JSON_SIZE_BYTES
577
+ errors.add(:feature_tooltips_shown, "size (#{json_size} bytes) exceeds maximum #{MAX_JSON_SIZE_BYTES} bytes")
578
+ end
579
+ end
580
+
581
+ def validate_milestones_size
582
+ return unless milestones_achieved.is_a?(Array)
583
+
584
+ # Check number of entries
585
+ if milestones_achieved.size > MAX_MILESTONES_ACHIEVED
586
+ errors.add(:milestones_achieved, "cannot exceed #{MAX_MILESTONES_ACHIEVED} entries")
587
+ end
588
+
589
+ # Check serialized size
590
+ json_size = milestones_achieved.to_json.bytesize
591
+ if json_size > MAX_JSON_SIZE_BYTES
592
+ errors.add(:milestones_achieved, "size (#{json_size} bytes) exceeds maximum #{MAX_JSON_SIZE_BYTES} bytes")
593
+ end
594
+ end
595
+
596
+ # Trim oldest tooltip entries to prevent exceeding limits
597
+ #
598
+ # This method is called automatically when approaching the MAX_TOOLTIPS_SHOWN limit
599
+ # to prevent unbounded growth of the JSON field. It keeps the 80% most recent
600
+ # tooltips to avoid frequent trimming operations.
601
+ #
602
+ # @return [void]
603
+ # @private
604
+ def trim_oldest_tooltips
605
+ return unless feature_tooltips_shown.is_a?(Hash)
606
+ return if feature_tooltips_shown.size < MAX_TOOLTIPS_SHOWN
607
+
608
+ # Sort by timestamp and keep only the most recent entries
609
+ keep_count = (MAX_TOOLTIPS_SHOWN * 0.8).to_i # Keep 80% to avoid frequent trimming
610
+ sorted_tooltips = feature_tooltips_shown.sort_by { |_, timestamp| timestamp }
611
+ self.feature_tooltips_shown = sorted_tooltips.last(keep_count).to_h
612
+ end
613
+
614
+ # Check for and award milestones triggered by completing a specific step
615
+ #
616
+ # This method is called automatically after a step is completed. It looks up
617
+ # all milestones configured with the :onboarding_step_completed trigger and
618
+ # awards them if their conditions match the completed step.
619
+ #
620
+ # @param step_name [String, Symbol] the name of the completed step
621
+ # @param session_id [String] optional session identifier for tracking
622
+ # @return [void]
623
+ # @private
624
+ def check_and_achieve_step_milestones(step_name, session_id: nil)
625
+ return unless RailsOnboarding.configuration.enable_milestones
626
+
627
+ matching_milestones = RailsOnboarding.configuration.milestones_for_trigger(
628
+ :onboarding_step_completed,
629
+ { step: step_name }
630
+ )
631
+
632
+ matching_milestones.each do |milestone|
633
+ achieve_milestone!(milestone[:key], session_id: session_id)
634
+ end
635
+ end
636
+
637
+ def check_and_achieve_completion_milestones(session_id: nil)
638
+ return unless RailsOnboarding.configuration.enable_milestones
639
+
640
+ matching_milestones = RailsOnboarding.configuration.milestones_for_trigger(
641
+ :onboarding_completed
642
+ )
643
+
644
+ matching_milestones.each do |milestone|
645
+ achieve_milestone!(milestone[:key], session_id: session_id)
646
+ end
647
+ end
648
+ end
649
+ end