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,409 @@
1
+ module RailsOnboarding
2
+ class OnboardingController < ApplicationController
3
+ include RailsOnboarding::RateLimitable
4
+
5
+ before_action :authenticate_user!
6
+ before_action :check_onboarding_status, except: [ :complete, :skip, :restart ]
7
+ before_action :set_step
8
+
9
+ # Error handling for common scenarios.
10
+ # rescue_from handlers are checked most-specific-last-registered-first, so
11
+ # the generic StandardError handler must come first or it will swallow
12
+ # RecordInvalid/RecordNotFound too (they're both StandardError subclasses)
13
+ # and the specific handlers below become dead code.
14
+ rescue_from StandardError, with: :handle_standard_error
15
+ rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
16
+ rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error
17
+
18
+ def show
19
+ unless @current_step
20
+ # Reset onboarding to first step
21
+ first_step = RailsOnboarding.configuration.steps.first
22
+
23
+ unless first_step
24
+ handle_configuration_error("No onboarding steps configured")
25
+ return
26
+ end
27
+
28
+ current_user.update!(onboarding_current_step: first_step[:name])
29
+ @current_step = current_user.current_onboarding_step
30
+ end
31
+
32
+ # Reset invalid step to first step
33
+ if current_user.onboarding_current_step &&
34
+ !RailsOnboarding.configuration.step_by_name(current_user.onboarding_current_step)
35
+ first_step = RailsOnboarding.configuration.steps.first
36
+ current_user.update!(onboarding_current_step: first_step[:name])
37
+ @current_step = current_user.current_onboarding_step
38
+ end
39
+
40
+ # Skip past steps whose :complete_if criteria are already satisfied.
41
+ # This is what lets a step live on a real host-app page: the host
42
+ # controller never has to know onboarding exists - landing back on
43
+ # /onboarding re-checks and moves on. May redirect (completion).
44
+ advance_completed_steps
45
+ return if performed?
46
+
47
+ # Track step entry - the funnel's "reached this step" signal. Emitted
48
+ # only on a user's first entry to a step, so refreshes and back-navigation
49
+ # don't record duplicate events.
50
+ if RailsOnboarding.configuration.enable_analytics && first_entry_to_step?(@current_step[:name])
51
+ RailsOnboarding::AnalyticsEvent.track_step_started(
52
+ user: current_user,
53
+ step_name: @current_step[:name],
54
+ step_index: RailsOnboarding.configuration.step_index(@current_step[:name])
55
+ )
56
+ end
57
+
58
+ # Steps that own a real page in the host app: route there instead of
59
+ # rendering a gem template, so the existing controller/view do the work.
60
+ if @current_step[:path]
61
+ redirect_to_step_page and return
62
+ end
63
+
64
+ # Dynamic action based on current step
65
+ step_template = @current_step[:name]
66
+
67
+ # Render the appropriate template
68
+ if template_exists?(step_template)
69
+ render step_template
70
+ else
71
+ render :step
72
+ end
73
+ end
74
+
75
+ def next
76
+ unless @current_step
77
+ respond_to do |format|
78
+ format.html { redirect_to onboarding_path, alert: "Invalid step. Redirecting to current step." }
79
+ format.turbo_stream do
80
+ render turbo_stream: turbo_stream.replace(
81
+ "flash-messages",
82
+ partial: "rails_onboarding/shared/flash",
83
+ locals: { alert: "Invalid step. Please try again." }
84
+ )
85
+ end
86
+ end
87
+ return
88
+ end
89
+
90
+ if params[:step_data].present?
91
+ # Process any step-specific data
92
+ process_step_data(@current_step[:name], params[:step_data])
93
+ end
94
+
95
+ # Award milestones for completing this step
96
+ awarded_milestones = []
97
+ if defined?(RailsOnboarding::MilestoneService)
98
+ awarded_milestones = RailsOnboarding::MilestoneService.check_onboarding_step_milestones(
99
+ current_user,
100
+ @current_step[:name]
101
+ ) || []
102
+ end
103
+
104
+ current_user.complete_onboarding_step!(@current_step[:name])
105
+
106
+ if current_user.onboarding_completed?
107
+ # Award completion milestones
108
+ if defined?(RailsOnboarding::MilestoneService)
109
+ completion_milestones = RailsOnboarding::MilestoneService.check_onboarding_completion_milestones(current_user) || []
110
+ awarded_milestones.concat(completion_milestones)
111
+ end
112
+
113
+ redirect_to_after_completion(awarded_milestones)
114
+ else
115
+ redirect_to onboarding_path(awarded_milestones: awarded_milestones.map { |m| m[:key] })
116
+ end
117
+ end
118
+
119
+ def complete
120
+ completion_milestones = []
121
+ if defined?(RailsOnboarding::MilestoneService)
122
+ completion_milestones = RailsOnboarding::MilestoneService.check_onboarding_completion_milestones(current_user) || []
123
+ end
124
+
125
+ current_user.complete_onboarding!
126
+
127
+ redirect_to_after_completion(completion_milestones)
128
+ end
129
+
130
+ def skip
131
+ if params[:skip_all] == "true"
132
+ current_user.skip_onboarding!
133
+ redirect_to_after_skip
134
+ elsif @current_step && @current_step[:skippable]
135
+ current_user.skip_onboarding_step!(@current_step[:name])
136
+ if current_user.onboarding_completed?
137
+ redirect_to_after_completion
138
+ else
139
+ redirect_to onboarding_path
140
+ end
141
+ else
142
+ respond_to do |format|
143
+ format.html do
144
+ redirect_to onboarding_path, alert: "This step cannot be skipped."
145
+ end
146
+ format.turbo_stream do
147
+ render turbo_stream: turbo_stream.replace(
148
+ "flash-messages",
149
+ partial: "rails_onboarding/shared/flash",
150
+ locals: { alert: "This step cannot be skipped." }
151
+ ), status: :unprocessable_entity
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ def back
158
+ unless current_user.can_go_back?
159
+ respond_to do |format|
160
+ format.html { redirect_to onboarding_path, alert: "Cannot go back from the first step." }
161
+ format.turbo_stream do
162
+ render turbo_stream: turbo_stream.replace(
163
+ "flash-messages",
164
+ partial: "rails_onboarding/shared/flash",
165
+ locals: { alert: "Cannot go back from the first step." }
166
+ ), status: :unprocessable_entity
167
+ end
168
+ end
169
+ return
170
+ end
171
+
172
+ session_id = RailsOnboarding::SessionManager.session_id(current_user, session)
173
+ current_user.go_back!(session_id: session_id)
174
+
175
+ redirect_to onboarding_path
176
+ end
177
+
178
+ def restart
179
+ session_id = RailsOnboarding::SessionManager.session_id(current_user, session)
180
+ current_user.restart_onboarding!(session_id: session_id)
181
+ RailsOnboarding::SessionManager.clear_session(current_user, session)
182
+
183
+ redirect_to onboarding_path, notice: "Onboarding has been restarted."
184
+ end
185
+
186
+ private
187
+
188
+ def authenticate_user!
189
+ # This should be overridden by the host app
190
+ # or use the host app's authentication
191
+ unless respond_to?(:current_user, true) && current_user.present?
192
+ redirect_to main_app.root_path
193
+ end
194
+ end
195
+
196
+ def check_onboarding_status
197
+ if current_user.onboarding_completed?
198
+ redirect_to_after_completion
199
+ end
200
+ end
201
+
202
+ def set_step
203
+ if current_user
204
+ @current_step = current_user.current_onboarding_step
205
+ @next_step = current_user.next_onboarding_step
206
+ @progress = current_user.onboarding_progress
207
+ @total_steps = RailsOnboarding.configuration.total_steps
208
+ end
209
+
210
+ @progress ||= 0
211
+ @total_steps ||= 4
212
+ end
213
+
214
+ # True the first time this user reaches the given step. Existing
215
+ # step_started events are matched in Ruby because `properties` is a
216
+ # JSON-serialized text column (not portably queryable via SQL). The set of
217
+ # a user's step_started events is bounded by the number of steps, so this
218
+ # stays cheap.
219
+ def first_entry_to_step?(step_name)
220
+ return true unless RailsOnboarding::AnalyticsEvent.table_exists?
221
+
222
+ RailsOnboarding::AnalyticsEvent
223
+ .where(user: current_user, event_type: RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_STARTED)
224
+ .none? { |event| event.properties.to_h["step_name"].to_s == step_name.to_s }
225
+ end
226
+
227
+ # Advance past every consecutive step whose :complete_if predicate is
228
+ # already satisfied. A loop (not a single check) because a returning user
229
+ # may have satisfied several steps at once. Redirects to the completion
230
+ # path when advancing finishes the flow, so callers must check performed?.
231
+ def advance_completed_steps
232
+ awarded_milestones = []
233
+ RailsOnboarding.configuration.total_steps.times do
234
+ break unless @current_step && step_criteria_met?(@current_step)
235
+
236
+ if defined?(RailsOnboarding::MilestoneService)
237
+ milestones = RailsOnboarding::MilestoneService.check_onboarding_step_milestones(
238
+ current_user,
239
+ @current_step[:name]
240
+ ) || []
241
+ awarded_milestones.concat(milestones)
242
+ end
243
+
244
+ current_user.complete_onboarding_step!(@current_step[:name])
245
+
246
+ if current_user.onboarding_completed?
247
+ if defined?(RailsOnboarding::MilestoneService)
248
+ completion_milestones = RailsOnboarding::MilestoneService.check_onboarding_completion_milestones(current_user) || []
249
+ awarded_milestones.concat(completion_milestones)
250
+ end
251
+ redirect_to_after_completion(awarded_milestones)
252
+ return
253
+ end
254
+
255
+ @current_step = current_user.current_onboarding_step
256
+ end
257
+
258
+ set_step
259
+ end
260
+
261
+ # A buggy :complete_if must not brick onboarding - if it raises, treat the
262
+ # step as not yet complete instead of letting the shared StandardError
263
+ # handler redirect back to /onboarding (which would re-raise on arrival,
264
+ # redirecting again in an endless browser loop).
265
+ def step_criteria_met?(step)
266
+ return false unless step[:complete_if].is_a?(Proc)
267
+
268
+ step[:complete_if].call(current_user)
269
+ rescue StandardError => e
270
+ Rails.logger.error("RailsOnboarding: complete_if for step '#{step[:name]}' raised #{e.class} - #{e.message}")
271
+ false
272
+ end
273
+
274
+ # Redirect to the host-app page that owns the current step. Returns false
275
+ # (without redirecting) when the configured path can't be resolved, so
276
+ # show can fall back to rendering a gem template instead of stranding the
277
+ # user in a redirect loop via the shared StandardError handler.
278
+ def redirect_to_step_page
279
+ path = resolve_onboarding_step_path(@current_step[:path])
280
+ redirect_to path
281
+ true
282
+ rescue StandardError => e
283
+ Rails.logger.error(
284
+ "RailsOnboarding: could not resolve path #{@current_step[:path].inspect} " \
285
+ "for step '#{@current_step[:name]}': #{e.class} - #{e.message}"
286
+ )
287
+ false
288
+ end
289
+
290
+ def redirect_to_after_completion(awarded_milestones = [])
291
+ path = RailsOnboarding.configuration.redirect_after_completion
292
+ notice = "Welcome! You've completed the onboarding."
293
+
294
+ if awarded_milestones.any?
295
+ milestone_text = awarded_milestones.map { |m| "#{m[:icon]} #{m[:title]}" }.join(", ")
296
+ notice += " Milestones achieved: #{milestone_text}"
297
+ end
298
+
299
+ redirect_to main_app.send(path), notice: notice
300
+ end
301
+
302
+ def redirect_to_after_skip
303
+ path = RailsOnboarding.configuration.redirect_after_skip
304
+ redirect_to main_app.send(path), notice: "You can explore features at your own pace."
305
+ end
306
+
307
+ def process_step_data(step_name, data)
308
+ # Sanitize input data before processing
309
+ sanitized_data = sanitize_step_data(data)
310
+
311
+ # Override in host app for custom processing
312
+ # Example:
313
+ # case step_name
314
+ # when :profile
315
+ # current_user.update(sanitized_data.permit(:timezone, :notifications_enabled))
316
+ # end
317
+
318
+ # Log sanitized data for security auditing
319
+ Rails.logger.info("Processing step data for #{step_name}: #{sanitized_data.inspect}")
320
+ end
321
+
322
+ def sanitize_step_data(data)
323
+ return {} unless data.is_a?(ActionController::Parameters) || data.is_a?(Hash)
324
+
325
+ # Convert to ActionController::Parameters if needed
326
+ params_data = data.is_a?(ActionController::Parameters) ? data : ActionController::Parameters.new(data)
327
+
328
+ # Remove any potentially dangerous keys
329
+ dangerous_keys = %w[authenticity_token _method controller action]
330
+ params_data.except(*dangerous_keys)
331
+ end
332
+
333
+ def template_exists?(name)
334
+ lookup_context.exists?(name, [ "rails_onboarding/onboarding" ], false)
335
+ end
336
+
337
+ # Error handling methods
338
+
339
+ def handle_validation_error(exception)
340
+ Rails.logger.error("Validation error in onboarding: #{exception.message}")
341
+ Rails.logger.error(exception.backtrace.join("\n")) if Rails.env.development?
342
+
343
+ error_message = "Unable to save changes: #{exception.record.errors.full_messages.join(', ')}"
344
+
345
+ respond_to do |format|
346
+ format.html do
347
+ redirect_to onboarding_path, alert: error_message
348
+ end
349
+ format.turbo_stream do
350
+ render turbo_stream: turbo_stream.replace(
351
+ "flash-messages",
352
+ partial: "rails_onboarding/shared/flash",
353
+ locals: { alert: error_message }
354
+ ), status: :unprocessable_entity
355
+ end
356
+ end
357
+ end
358
+
359
+ def handle_not_found(exception)
360
+ Rails.logger.error("Record not found in onboarding: #{exception.message}")
361
+
362
+ respond_to do |format|
363
+ format.html do
364
+ redirect_to main_app.root_path, alert: "Resource not found. Please try again."
365
+ end
366
+ format.turbo_stream do
367
+ render turbo_stream: turbo_stream.replace(
368
+ "flash-messages",
369
+ partial: "rails_onboarding/shared/flash",
370
+ locals: { alert: "Resource not found. Please try again." }
371
+ ), status: :not_found
372
+ end
373
+ end
374
+ end
375
+
376
+ def handle_standard_error(exception)
377
+ Rails.logger.error("Error in onboarding controller: #{exception.class} - #{exception.message}")
378
+ Rails.logger.error(exception.backtrace.join("\n"))
379
+
380
+ error_message = if Rails.env.production?
381
+ "An unexpected error occurred. Please try again or contact support."
382
+ else
383
+ "Error: #{exception.message}"
384
+ end
385
+
386
+ # Simple redirect for all error cases
387
+ redirect_to onboarding_path, alert: error_message
388
+ end
389
+
390
+ def handle_configuration_error(message)
391
+ Rails.logger.error("Configuration error: #{message}")
392
+
393
+ error_message = "Onboarding configuration error. Please contact support."
394
+
395
+ respond_to do |format|
396
+ format.html do
397
+ redirect_to main_app.root_path, alert: error_message
398
+ end
399
+ format.turbo_stream do
400
+ render turbo_stream: turbo_stream.replace(
401
+ "flash-messages",
402
+ partial: "rails_onboarding/shared/flash",
403
+ locals: { alert: error_message }
404
+ ), status: :internal_server_error
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOnboarding
4
+ # Controller for managing progressive feature disclosure
5
+ # Handles revealing and tracking progressive features
6
+ class ProgressiveFeaturesController < ApplicationController
7
+ include RailsOnboarding::RateLimitable
8
+
9
+ before_action :authenticate_user!
10
+ before_action :set_feature, only: [ :reveal, :dismiss ]
11
+
12
+ # GET /progressive_features
13
+ # List all progressive features and their status
14
+ def index
15
+ @all_features = RailsOnboarding.configuration.progressive_features || []
16
+ @revealed_features = current_user.all_revealed_features
17
+ @ready_features = current_user.features_ready_to_reveal
18
+
19
+ respond_to do |format|
20
+ format.html
21
+ format.json do
22
+ render json: {
23
+ all_features: @all_features,
24
+ revealed: @revealed_features,
25
+ ready_to_reveal: @ready_features.map { |f| f[:key] }
26
+ }
27
+ end
28
+ end
29
+ end
30
+
31
+ # GET /progressive_features/ready
32
+ # Get features that are ready to be revealed
33
+ def ready
34
+ @ready_features = current_user.features_ready_to_reveal
35
+
36
+ respond_to do |format|
37
+ format.json { render json: @ready_features }
38
+ end
39
+ end
40
+
41
+ # POST /progressive_features/:feature_key/reveal
42
+ # Manually reveal a feature
43
+ def reveal
44
+ if current_user.reveal_feature(@feature[:key], source: :manual, revealed_by: current_user.id)
45
+ respond_to do |format|
46
+ format.html do
47
+ flash[:success] = "Feature '#{@feature[:title]}' has been revealed!"
48
+ redirect_back(fallback_location: root_path)
49
+ end
50
+ format.json { render json: { success: true, feature: @feature } }
51
+ end
52
+ else
53
+ respond_to do |format|
54
+ format.html do
55
+ flash[:error] = "Failed to reveal feature"
56
+ redirect_back(fallback_location: root_path)
57
+ end
58
+ format.json { render json: { success: false }, status: :unprocessable_entity }
59
+ end
60
+ end
61
+ end
62
+
63
+ # POST /progressive_features/:feature_key/dismiss
64
+ # Dismiss a feature notification without revealing it
65
+ def dismiss
66
+ # Track dismissal in analytics
67
+ if current_user.respond_to?(:track_analytics_event)
68
+ current_user.track_analytics_event(
69
+ "feature_dismissed",
70
+ feature_key: @feature[:key].to_s
71
+ )
72
+ end
73
+
74
+ respond_to do |format|
75
+ format.json { render json: { success: true } }
76
+ end
77
+ end
78
+
79
+ # POST /progressive_features/reveal_all_ready
80
+ # Reveal all features that are currently ready
81
+ def reveal_all_ready
82
+ newly_revealed = current_user.reveal_ready_features!
83
+
84
+ respond_to do |format|
85
+ format.html do
86
+ flash[:success] = "Revealed #{newly_revealed.size} new features!"
87
+ redirect_back(fallback_location: root_path)
88
+ end
89
+ format.json { render json: { success: true, revealed: newly_revealed } }
90
+ end
91
+ end
92
+
93
+ # GET /progressive_features/status
94
+ # Get the current status of progressive disclosure for the user
95
+ def status
96
+ render json: {
97
+ enabled: RailsOnboarding.configuration.progressive_disclosure_enabled,
98
+ total_features: RailsOnboarding.configuration.progressive_features&.size || 0,
99
+ revealed_count: current_user.revealed_features_count,
100
+ pending_count: current_user.pending_features_count,
101
+ ready_to_reveal: current_user.features_ready_to_reveal.map { |f| f[:key] }
102
+ }
103
+ end
104
+
105
+ private
106
+
107
+ def set_feature
108
+ feature_key = params[:feature_key] || params[:id]
109
+ @feature = (RailsOnboarding.configuration.progressive_features || [])
110
+ .find { |f| f[:key].to_s == feature_key.to_s }
111
+
112
+ unless @feature
113
+ respond_to do |format|
114
+ format.html do
115
+ flash[:error] = "Feature not found"
116
+ redirect_to root_path
117
+ end
118
+ format.json { render json: { error: "Feature not found" }, status: :not_found }
119
+ end
120
+ end
121
+ end
122
+
123
+ def authenticate_user!
124
+ # This should be implemented by the host application
125
+ # or overridden in the host's ApplicationController
126
+ return if defined?(current_user) && current_user
127
+
128
+ respond_to do |format|
129
+ format.html { redirect_to main_app.root_path, alert: "Please sign in" }
130
+ format.json { render json: { error: "Unauthorized" }, status: :unauthorized }
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOnboarding
4
+ # Controller for managing onboarding templates
5
+ # Provides pre-built flows for common use cases
6
+ class TemplatesController < ApplicationController
7
+ include RailsOnboarding::AdminAuthorization
8
+
9
+ # apply/create_custom mutate the process-wide RailsOnboarding
10
+ # configuration (the onboarding flow every user sees), so this whole
11
+ # controller is admin-only - the same gate as the Admin dashboard.
12
+ before_action :authenticate_admin!
13
+ before_action :verify_admin_authorization!
14
+
15
+ rescue_from UnauthorizedError, with: :handle_admin_unauthorized
16
+ rescue_from NotImplementedError, with: :handle_admin_not_implemented
17
+
18
+ # GET /templates
19
+ # List all available onboarding templates
20
+ def index
21
+ @templates = RailsOnboarding.configuration.onboarding_templates
22
+
23
+ respond_to do |format|
24
+ format.html
25
+ format.json { render json: @templates }
26
+ end
27
+ end
28
+
29
+ # GET /templates/:template_key
30
+ # Show details for a specific template
31
+ def show
32
+ @template_key = params[:template_key] || params[:id]
33
+ @template = RailsOnboarding.configuration.template(@template_key)
34
+
35
+ unless @template
36
+ respond_to do |format|
37
+ format.html do
38
+ flash[:error] = "Template not found: #{@template_key}"
39
+ redirect_to templates_path
40
+ end
41
+ format.json { render json: { error: "Template not found" }, status: :not_found }
42
+ end
43
+ return
44
+ end
45
+
46
+ respond_to do |format|
47
+ format.html
48
+ format.json { render json: { template_key: @template_key, template: @template } }
49
+ end
50
+ end
51
+
52
+ # POST /templates/:template_key/apply
53
+ # Apply a template to the current configuration
54
+ def apply
55
+ template_key = params[:template_key] || params[:id]
56
+ template = RailsOnboarding.configuration.template(template_key)
57
+
58
+ unless template
59
+ respond_to do |format|
60
+ format.html do
61
+ flash[:error] = "Template not found: #{template_key}"
62
+ redirect_to templates_path
63
+ end
64
+ format.json { render json: { error: "Template not found" }, status: :not_found }
65
+ end
66
+ return
67
+ end
68
+
69
+ if RailsOnboarding.configuration.apply_template(template_key)
70
+ respond_to do |format|
71
+ format.html do
72
+ flash[:success] = "Template '#{template[:name]}' has been applied successfully!"
73
+ redirect_to onboarding_path
74
+ end
75
+ format.json do
76
+ render json: {
77
+ success: true,
78
+ template_key: template_key,
79
+ steps: RailsOnboarding.configuration.steps
80
+ }
81
+ end
82
+ end
83
+ else
84
+ respond_to do |format|
85
+ format.html do
86
+ flash[:error] = "Failed to apply template"
87
+ redirect_to templates_path
88
+ end
89
+ format.json { render json: { success: false }, status: :unprocessable_entity }
90
+ end
91
+ end
92
+ end
93
+
94
+ # POST /templates/:template_key/preview
95
+ # Preview what a template would look like without applying it
96
+ def preview
97
+ template_key = params[:template_key] || params[:id]
98
+ template = RailsOnboarding.configuration.template(template_key)
99
+
100
+ unless template
101
+ respond_to do |format|
102
+ format.json { render json: { error: "Template not found" }, status: :not_found }
103
+ end
104
+ return
105
+ end
106
+
107
+ # Return template details without actually applying it
108
+ render json: {
109
+ template_key: template_key,
110
+ name: template[:name],
111
+ steps: template[:steps],
112
+ total_steps: template[:steps]&.size || 0,
113
+ description: template[:description],
114
+ suitable_for: template[:suitable_for]
115
+ }
116
+ end
117
+
118
+ # GET /templates/compare
119
+ # Compare multiple templates side by side
120
+ def compare
121
+ template_keys = params[:templates]&.split(",") || []
122
+ @templates_to_compare = {}
123
+
124
+ template_keys.each do |key|
125
+ template = RailsOnboarding.configuration.template(key.to_sym)
126
+ @templates_to_compare[key.to_sym] = template if template
127
+ end
128
+
129
+ respond_to do |format|
130
+ format.html
131
+ format.json { render json: @templates_to_compare }
132
+ end
133
+ end
134
+
135
+ # POST /templates/custom
136
+ # Create a custom template based on current configuration
137
+ def create_custom
138
+ template_name = params[:template_name] || "Custom Template"
139
+ template_key = params[:template_key]&.to_sym || :custom
140
+
141
+ custom_template = {
142
+ name: template_name,
143
+ steps: RailsOnboarding.configuration.steps,
144
+ description: params[:description] || "Custom onboarding flow",
145
+ created_at: Time.current
146
+ }
147
+
148
+ # In a real application, you'd save this to the database
149
+ # For now, we'll just return it as JSON
150
+
151
+ respond_to do |format|
152
+ format.json do
153
+ render json: {
154
+ success: true,
155
+ template_key: template_key,
156
+ template: custom_template
157
+ }
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end