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,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
|