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,32 @@
1
+ module RailsOnboarding
2
+ class Configuration
3
+ # Feature tooltip configuration.
4
+ module Tooltips
5
+ def enable_tooltips
6
+ tenant_override(:enable_tooltips, @enable_tooltips)
7
+ end
8
+
9
+ attr_writer :enable_tooltips
10
+
11
+ def feature_tooltips
12
+ tenant_override(:feature_tooltips, @feature_tooltips)
13
+ end
14
+
15
+ attr_writer :feature_tooltips
16
+
17
+ private
18
+
19
+ def initialize_tooltips
20
+ @enable_tooltips = true
21
+
22
+ @feature_tooltips = {
23
+ "getting_started" => {
24
+ text: "Click here to get started!",
25
+ delay: 1000,
26
+ position: "bottom"
27
+ }
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,133 @@
1
+ require_relative "configuration_validator"
2
+ require_relative "configuration/steps"
3
+ require_relative "configuration/milestones"
4
+ require_relative "configuration/tooltips"
5
+ require_relative "configuration/analytics"
6
+ require_relative "configuration/ab_testing"
7
+ require_relative "configuration/personalization"
8
+ require_relative "configuration/progressive_disclosure"
9
+ require_relative "configuration/integrations"
10
+ require_relative "configuration/rate_limiting"
11
+ require_relative "configuration/templates"
12
+
13
+ module RailsOnboarding
14
+ # Every reader/writer here is part of this gem's public configuration DSL
15
+ # (host apps set these in a RailsOnboarding.configure block), so none of
16
+ # them can change shape without breaking every existing installation. The
17
+ # ~40 settings are grouped by feature into the modules under
18
+ # configuration/ - each one owns its own defaults and any logic specific
19
+ # to it, so a change to (say) A/B testing only touches
20
+ # configuration/ab_testing.rb instead of this whole file. What's left
21
+ # here is identity/branding settings with no logic of their own, plus the
22
+ # infrastructure the feature modules share (tenant overrides, the derived-
23
+ # lookup cache, and the validator).
24
+ class Configuration
25
+ include Steps
26
+ include Milestones
27
+ include Tooltips
28
+ include Analytics
29
+ include AbTesting
30
+ include Personalization
31
+ include ProgressiveDisclosure
32
+ include Integrations
33
+ include RateLimiting
34
+ include Templates
35
+
36
+ attr_accessor :user_class_name,
37
+ :include_host_styles,
38
+ :redirect_after_completion,
39
+ :redirect_after_skip,
40
+ :custom_css_path,
41
+ :custom_js_path,
42
+ :welcome_heading,
43
+ :welcome_subheading,
44
+ :welcome_features,
45
+ :admin_user_search
46
+
47
+ def initialize
48
+ @user_class_name = "User"
49
+ @include_host_styles = true # Default to including host app css
50
+ @redirect_after_completion = :root_path
51
+ @redirect_after_skip = :root_path
52
+ @welcome_heading = "We're excited to have you here!"
53
+ @welcome_subheading = "Now, let's take a few moments to get you set up and familiar with a few things you need to know to get started."
54
+ @welcome_features = [
55
+ { icon: "👤", text: "Set up your profile" },
56
+ { icon: "📝", text: "Create your first item" },
57
+ { icon: "🔍", text: "Explore key features" }
58
+ ]
59
+
60
+ # Optional override for the admin User Management search box. The
61
+ # built-in search is plain SQL, which can't see through an encrypted
62
+ # email column - a host app that encrypts email and still wants partial
63
+ # matching has to supply the strategy itself. Receives the already
64
+ # status/step-filtered scope and the raw term, and must return a
65
+ # relation (not an array) so sorting and pagination still apply.
66
+ @admin_user_search = nil
67
+
68
+ initialize_steps
69
+ initialize_milestones
70
+ initialize_tooltips
71
+ initialize_analytics
72
+ initialize_ab_testing
73
+ initialize_personalization
74
+ initialize_progressive_disclosure
75
+ initialize_integrations
76
+ initialize_rate_limiting
77
+ initialize_templates
78
+ end
79
+
80
+ # Validate the current configuration
81
+ # @raise [ConfigurationError] if any validation fails
82
+ def validate!
83
+ validator.validate!
84
+ end
85
+
86
+ # Check if the configuration is valid without raising
87
+ # @return [Boolean] true if valid, false otherwise
88
+ def valid?
89
+ validator.valid?
90
+ end
91
+
92
+ # Get the validator instance for this configuration
93
+ # @return [ConfigurationValidator]
94
+ def validator
95
+ @validator ||= ConfigurationValidator.new(self)
96
+ end
97
+
98
+ # Get validation errors without raising
99
+ # @return [Array<StandardError>] array of validation errors
100
+ def validation_errors
101
+ validator.errors
102
+ end
103
+
104
+ def user_class
105
+ @user_class ||= @user_class_name.constantize
106
+ end
107
+
108
+ # Clear all cached lookups - call this when configuration changes
109
+ def clear_cache!
110
+ @user_class = nil
111
+ @total_steps = nil
112
+ @step_by_name_cache = nil
113
+ @step_index_cache = nil
114
+ @milestone_by_key_cache = nil
115
+ @milestones_for_trigger_cache = nil
116
+ @validator = nil
117
+ end
118
+
119
+ private
120
+
121
+ # Reads a per-request tenant override for +key+, set by
122
+ # MultiTenant.with_tenant_configuration via RailsOnboarding::Current.
123
+ # Falls back to +default+ (the process-wide value) when no override is
124
+ # active for +key+, distinguishing an explicit `false` override from "no
125
+ # override at all".
126
+ def tenant_override(key, default)
127
+ overrides = RailsOnboarding::Current.tenant_overrides
128
+ return default unless overrides&.key?(key)
129
+
130
+ overrides[key]
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,19 @@
1
+ module RailsOnboarding
2
+ # Base error class for all configuration errors
3
+ class ConfigurationError < StandardError; end
4
+
5
+ # Raised when step configuration is invalid
6
+ class InvalidStepError < ConfigurationError; end
7
+
8
+ # Raised when milestone configuration is invalid
9
+ class InvalidMilestoneError < ConfigurationError; end
10
+
11
+ # Raised when redirect path configuration is invalid
12
+ class InvalidRedirectPathError < ConfigurationError; end
13
+
14
+ # Raised when a configuration value has the wrong type
15
+ class InvalidTypeError < ConfigurationError; end
16
+
17
+ # Raised when a required configuration option is missing
18
+ class MissingRequiredOptionError < ConfigurationError; end
19
+ end
@@ -0,0 +1,477 @@
1
+ require_relative "configuration_errors"
2
+
3
+ module RailsOnboarding
4
+ # Validates RailsOnboarding configuration to ensure all settings are valid
5
+ class ConfigurationValidator
6
+ VALID_TRIGGERS = [ :onboarding_step_completed, :onboarding_completed, :tooltip_shown,
7
+ :tooltip_clicked, :custom ].freeze
8
+ VALID_REDIRECT_TYPES = [ Symbol, String, Proc ].freeze
9
+ VALID_ONBOARDING_REQUIREMENTS = [ :new_users, :all_users, Proc ].freeze
10
+ VALID_API_AUTH_METHODS = [ :token, :session, :custom ].freeze
11
+ VALID_REVEAL_CONDITIONS = [ :time_based, :action_based, :step_based, :milestone_based, :engagement_based ].freeze
12
+ NAME_FORMAT = /\A[a-z_][a-z0-9_]*\z/i
13
+
14
+ attr_reader :config, :errors
15
+
16
+ def initialize(config)
17
+ @config = config
18
+ @errors = []
19
+ end
20
+
21
+ # Validate all configuration options
22
+ # @raise [ConfigurationError] if any validation fails
23
+ def validate!
24
+ validate_required_options
25
+ validate_types
26
+ validate_steps
27
+ validate_milestones
28
+ validate_redirect_paths
29
+ validate_feature_tooltips
30
+ validate_ab_tests
31
+ validate_personalized_flows
32
+ validate_progressive_features
33
+ validate_mailer_config
34
+
35
+ raise ConfigurationError, error_message unless errors.empty?
36
+ end
37
+
38
+ # Validate without raising exceptions
39
+ # @return [Boolean] true if valid, false otherwise
40
+ def valid?
41
+ validate!
42
+ true
43
+ rescue ConfigurationError
44
+ false
45
+ end
46
+
47
+ private
48
+
49
+ def validate_required_options
50
+ validate_present(:user_class_name, "User class name is required")
51
+ validate_present(:steps, "At least one step must be defined")
52
+ end
53
+
54
+ def validate_types
55
+ validate_type(:user_class_name, [ String ], "user_class_name must be a String")
56
+ validate_type(:include_host_styles, [ TrueClass, FalseClass ], "include_host_styles must be a Boolean")
57
+ validate_type(:enable_tooltips, [ TrueClass, FalseClass ], "enable_tooltips must be a Boolean")
58
+ validate_type(:enable_milestones, [ TrueClass, FalseClass ], "enable_milestones must be a Boolean")
59
+ validate_type(:enable_analytics, [ TrueClass, FalseClass ], "enable_analytics must be a Boolean")
60
+ validate_type(:steps, [ Array ], "steps must be an Array")
61
+ validate_type(:milestones, [ Array ], "milestones must be an Array")
62
+ validate_type(:feature_tooltips, [ Hash ], "feature_tooltips must be a Hash")
63
+
64
+ # Numeric validations
65
+ if config.analytics_data_retention_days
66
+ validate_type(:analytics_data_retention_days, [ Integer ], "analytics_data_retention_days must be an Integer")
67
+ validate_positive(:analytics_data_retention_days, "analytics_data_retention_days must be positive")
68
+ end
69
+
70
+ if config.analytics_session_timeout_minutes
71
+ validate_type(:analytics_session_timeout_minutes, [ Integer ], "analytics_session_timeout_minutes must be an Integer")
72
+ validate_positive(:analytics_session_timeout_minutes, "analytics_session_timeout_minutes must be positive")
73
+ end
74
+
75
+ # Validate onboarding_required_for
76
+ unless VALID_ONBOARDING_REQUIREMENTS.include?(config.onboarding_required_for) ||
77
+ config.onboarding_required_for.is_a?(Proc)
78
+ errors << InvalidTypeError.new(
79
+ "onboarding_required_for must be :new_users, :all_users, or a Proc"
80
+ )
81
+ end
82
+
83
+ # API mode validations
84
+ if config.api_mode_enabled
85
+ validate_type(:api_mode_enabled, [ TrueClass, FalseClass ], "api_mode_enabled must be a Boolean")
86
+ unless VALID_API_AUTH_METHODS.include?(config.api_authentication_method)
87
+ errors << InvalidTypeError.new(
88
+ "api_authentication_method must be one of: #{VALID_API_AUTH_METHODS.join(', ')}"
89
+ )
90
+ end
91
+ end
92
+ end
93
+
94
+ def validate_steps
95
+ return unless config.steps.is_a?(Array)
96
+
97
+ if config.steps.empty?
98
+ errors << InvalidStepError.new("At least one step must be defined")
99
+ return
100
+ end
101
+
102
+ validate_named_collection(
103
+ config.steps,
104
+ error_class: InvalidStepError,
105
+ label: "Step",
106
+ identifier_field: :name,
107
+ identifier_type: [ Symbol, String ],
108
+ duplicate_label: "step name",
109
+ format_description: "Use alphanumeric characters and underscores only, starting with a letter or underscore",
110
+ fields: [
111
+ { name: :title, type: String },
112
+ { name: :icon, type: String },
113
+ { name: :skippable, type: [ TrueClass, FalseClass ] },
114
+ { name: :path, type: [ Symbol, String, Proc ] },
115
+ { name: :complete_if, type: Proc }
116
+ ]
117
+ ) do |step, name|
118
+ # A :path step with no :complete_if and no skippable: true can only
119
+ # advance via an explicit advance_onboarding! call in the host
120
+ # controller - which this validator can't see. Warn rather than error,
121
+ # but make the trap visible: if that call is missing, the user gets
122
+ # redirected to the step's page forever.
123
+ if step.is_a?(Hash) && step[:path] && !step[:complete_if] && step[:skippable] != true
124
+ Rails.logger.warn(
125
+ "RailsOnboarding: step '#{name}' has a :path but no :complete_if and is not skippable. " \
126
+ "Make sure the host controller calls advance_onboarding!(:#{name}), " \
127
+ "or users cannot progress past this step."
128
+ )
129
+ end
130
+ end
131
+ end
132
+
133
+ def validate_milestones
134
+ return unless config.enable_milestones
135
+ return unless config.milestones.is_a?(Array)
136
+ return if config.milestones.empty? # Empty milestones array is valid
137
+
138
+ validate_named_collection(
139
+ config.milestones,
140
+ error_class: InvalidMilestoneError,
141
+ label: "Milestone",
142
+ identifier_field: :key,
143
+ duplicate_label: "milestone key",
144
+ format_description: "Use alphanumeric characters and underscores only",
145
+ fields: [
146
+ { name: :trigger, required: true },
147
+ { name: :points, type: Integer },
148
+ { name: :title, type: String },
149
+ { name: :description, type: String },
150
+ { name: :conditions, type: Hash }
151
+ ]
152
+ ) do |milestone, key|
153
+ # Cross-field and value-range checks that a generic field schema
154
+ # can't express - kept bespoke per collection.
155
+ if milestone[:trigger] && !VALID_TRIGGERS.include?(milestone[:trigger].to_sym)
156
+ errors << InvalidMilestoneError.new(
157
+ "Milestone '#{key}' has invalid trigger '#{milestone[:trigger]}'. " \
158
+ "Valid triggers: #{VALID_TRIGGERS.join(', ')}"
159
+ )
160
+ end
161
+
162
+ if milestone[:points].is_a?(Integer) && milestone[:points].negative?
163
+ errors << InvalidMilestoneError.new(
164
+ "Milestone '#{key}' has negative points. Points must be non-negative"
165
+ )
166
+ end
167
+
168
+ if milestone[:conditions].is_a?(Hash) && milestone[:conditions][:step]
169
+ step_name = milestone[:conditions][:step]
170
+ unless config.step_by_name(step_name)
171
+ errors << InvalidMilestoneError.new(
172
+ "Milestone '#{key}' references undefined step '#{step_name}' in conditions"
173
+ )
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ def validate_redirect_paths
180
+ validate_redirect_path(:redirect_after_completion, config.redirect_after_completion)
181
+ validate_redirect_path(:redirect_after_skip, config.redirect_after_skip)
182
+ end
183
+
184
+ def validate_redirect_path(field_name, value)
185
+ return if value.nil?
186
+
187
+ unless VALID_REDIRECT_TYPES.any? { |type| value.is_a?(type) }
188
+ errors << InvalidRedirectPathError.new(
189
+ "#{field_name} must be a Symbol, String, or Proc, got #{value.class}"
190
+ )
191
+ return
192
+ end
193
+
194
+ # If it's a symbol, validate it looks like a valid path helper
195
+ if value.is_a?(Symbol) && !value.to_s.end_with?("_path", "_url")
196
+ errors << InvalidRedirectPathError.new(
197
+ "#{field_name} symbol '#{value}' should end with '_path' or '_url' (e.g., :root_path, :dashboard_path)"
198
+ )
199
+ end
200
+
201
+ # If it's a string, validate it starts with /
202
+ if value.is_a?(String) && !value.start_with?("/")
203
+ errors << InvalidRedirectPathError.new(
204
+ "#{field_name} string '#{value}' should be an absolute path starting with '/'"
205
+ )
206
+ end
207
+ end
208
+
209
+ def validate_feature_tooltips
210
+ return unless config.enable_tooltips
211
+ return unless config.feature_tooltips.is_a?(Hash)
212
+
213
+ config.feature_tooltips.each do |key, tooltip|
214
+ unless tooltip.is_a?(Hash)
215
+ errors << InvalidTypeError.new(
216
+ "Tooltip '#{key}' must be a Hash, got #{tooltip.class}"
217
+ )
218
+ next
219
+ end
220
+
221
+ # :text has a combined required+type message ("must have a :text
222
+ # field of type String"), unlike every other field here, so it's
223
+ # kept as its own check rather than going through validate_item_fields.
224
+ unless tooltip[:text].is_a?(String)
225
+ errors << InvalidTypeError.new(
226
+ "Tooltip '#{key}' must have a :text field of type String"
227
+ )
228
+ end
229
+
230
+ validate_item_fields(tooltip, "Tooltip '#{key}'", InvalidTypeError, [
231
+ { name: :delay, type: Integer }
232
+ ])
233
+
234
+ if tooltip[:position]
235
+ valid_positions = %w[top bottom left right]
236
+ unless valid_positions.include?(tooltip[:position].to_s)
237
+ errors << InvalidTypeError.new(
238
+ "Tooltip '#{key}' has invalid :position '#{tooltip[:position]}'. " \
239
+ "Valid positions: #{valid_positions.join(', ')}"
240
+ )
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ def validate_ab_tests
247
+ return unless config.enable_ab_testing
248
+ return unless config.ab_tests.is_a?(Hash)
249
+
250
+ config.ab_tests.each do |test_name, test_config|
251
+ unless test_config.is_a?(Hash)
252
+ errors << InvalidTypeError.new(
253
+ "A/B test '#{test_name}' must be a Hash, got #{test_config.class}"
254
+ )
255
+ next
256
+ end
257
+
258
+ # Validate variants exist
259
+ unless test_config[:variants].is_a?(Array)
260
+ errors << InvalidTypeError.new(
261
+ "A/B test '#{test_name}' must have a :variants array"
262
+ )
263
+ next
264
+ end
265
+
266
+ if test_config[:variants].empty?
267
+ errors << InvalidTypeError.new(
268
+ "A/B test '#{test_name}' must have at least one variant"
269
+ )
270
+ end
271
+ end
272
+ end
273
+
274
+ def validate_personalized_flows
275
+ return unless config.personalization_enabled
276
+ return unless config.personalized_flows.is_a?(Hash)
277
+
278
+ config.personalized_flows.each do |user_type, steps|
279
+ unless steps.is_a?(Array)
280
+ errors << InvalidTypeError.new(
281
+ "Personalized flow for '#{user_type}' must be an Array, got #{steps.class}"
282
+ )
283
+ next
284
+ end
285
+
286
+ # Validate each step follows the same rules as regular steps
287
+ steps.each_with_index do |step, index|
288
+ unless step.is_a?(Hash)
289
+ errors << InvalidStepError.new(
290
+ "Personalized flow '#{user_type}' step at index #{index} must be a Hash"
291
+ )
292
+ end
293
+ end
294
+ end
295
+ end
296
+
297
+ def validate_progressive_features
298
+ return unless config.progressive_disclosure_enabled
299
+ return unless config.progressive_features.is_a?(Array)
300
+
301
+ validate_named_collection(
302
+ config.progressive_features,
303
+ error_class: InvalidTypeError,
304
+ label: "Progressive feature",
305
+ identifier_field: :key
306
+ ) do |feature, key|
307
+ next unless feature[:reveal_condition]
308
+
309
+ unless VALID_REVEAL_CONDITIONS.include?(feature[:reveal_condition])
310
+ errors << InvalidTypeError.new(
311
+ "Progressive feature '#{key}' has invalid :reveal_condition. " \
312
+ "Valid conditions: #{VALID_REVEAL_CONDITIONS.join(', ')}"
313
+ )
314
+ end
315
+
316
+ # Validate time_based features have delay
317
+ if feature[:reveal_condition] == :time_based && !feature[:delay].is_a?(Integer)
318
+ errors << InvalidTypeError.new(
319
+ "Progressive feature '#{key}' with :time_based condition must have :delay (Integer in seconds)"
320
+ )
321
+ end
322
+
323
+ # Validate step_based features reference valid steps
324
+ if feature[:reveal_condition] == :step_based && feature[:after_step]
325
+ unless config.step_by_name(feature[:after_step])
326
+ errors << InvalidTypeError.new(
327
+ "Progressive feature '#{key}' references undefined step '#{feature[:after_step]}'"
328
+ )
329
+ end
330
+ end
331
+ end
332
+ end
333
+
334
+ def validate_mailer_config
335
+ return unless config.background_jobs_enabled
336
+
337
+ if config.mailer_from && !config.mailer_from.is_a?(String)
338
+ errors << InvalidTypeError.new(
339
+ "mailer_from must be a String, got #{config.mailer_from.class}"
340
+ )
341
+ end
342
+
343
+ # Validate email format
344
+ if config.mailer_from.is_a?(String) && !valid_email_format?(config.mailer_from)
345
+ errors << InvalidTypeError.new(
346
+ "mailer_from '#{config.mailer_from}' is not a valid email address"
347
+ )
348
+ end
349
+ end
350
+
351
+ # Helper methods
352
+
353
+ def validate_present(field, message)
354
+ value = config.send(field)
355
+ if value.nil? || (value.respond_to?(:empty?) && value.empty?)
356
+ errors << MissingRequiredOptionError.new(message)
357
+ end
358
+ end
359
+
360
+ def validate_type(field, valid_types, message)
361
+ value = config.send(field)
362
+ return if value.nil? # nil is valid unless validate_present says otherwise
363
+
364
+ unless valid_types.any? { |type| value.is_a?(type) }
365
+ errors << InvalidTypeError.new(message)
366
+ end
367
+ end
368
+
369
+ def validate_positive(field, message)
370
+ value = config.send(field)
371
+ if value && value.is_a?(Integer) && value <= 0
372
+ errors << InvalidTypeError.new(message)
373
+ end
374
+ end
375
+
376
+ # Shared engine behind validate_steps/validate_milestones/
377
+ # validate_progressive_features: they all iterate an Array of Hashes,
378
+ # each identified by one field (:name/:key), and each with its own mix
379
+ # of required/optional fields - only the identifier's uniqueness/format
380
+ # rules and the field list differ, plus whatever bespoke cross-field
381
+ # checks the caller needs (yielded per item).
382
+ #
383
+ # @param items [Array] the configured collection (steps, milestones, ...)
384
+ # @param error_class [Class] error class to raise for problems found
385
+ # @param label [String] human label used in messages, e.g. "Step"
386
+ # @param identifier_field [Symbol] the field that identifies each item
387
+ # @param identifier_type [Array<Class>, nil] allowed types for the
388
+ # identifier's value, if it should be type-checked
389
+ # @param duplicate_label [String, nil] enables a uniqueness check on the
390
+ # identifier, e.g. "step name"
391
+ # @param format_description [String, nil] enables a name-format check on
392
+ # the identifier (alphanumeric/underscore), described in the message
393
+ # @param fields [Array<Hash>] field schema: {name:, type:, required:}
394
+ # @yield [item, identifier] for bespoke, collection-specific checks once
395
+ # the item has passed the structural checks above
396
+ def validate_named_collection(items, error_class:, label:, identifier_field:, identifier_type: nil,
397
+ duplicate_label: nil, format_description: nil, fields: [])
398
+ return unless items.is_a?(Array)
399
+
400
+ seen_identifiers = []
401
+
402
+ items.each_with_index do |item, index|
403
+ unless item.is_a?(Hash)
404
+ errors << error_class.new("#{label} at index #{index} must be a Hash")
405
+ next
406
+ end
407
+
408
+ identifier = item[identifier_field]
409
+ unless identifier
410
+ errors << error_class.new("#{label} at index #{index} is missing required :#{identifier_field} field")
411
+ next
412
+ end
413
+
414
+ if identifier_type && !identifier_type.any? { |type| identifier.is_a?(type) }
415
+ errors << error_class.new(
416
+ "#{label} at index #{index} has invalid #{identifier_field} type. " \
417
+ "Must be #{identifier_type.map(&:name).join(' or ')}, got #{identifier.class}"
418
+ )
419
+ next
420
+ end
421
+
422
+ item_label = "#{label} '#{identifier}'"
423
+
424
+ if duplicate_label
425
+ if seen_identifiers.include?(identifier.to_sym)
426
+ errors << error_class.new("Duplicate #{duplicate_label} found: '#{identifier}'")
427
+ end
428
+ seen_identifiers << identifier.to_sym
429
+ end
430
+
431
+ if format_description && !identifier.to_s.match?(NAME_FORMAT)
432
+ errors << error_class.new("#{item_label} has invalid format. #{format_description}")
433
+ end
434
+
435
+ validate_item_fields(item, item_label, error_class, fields)
436
+
437
+ yield item, identifier if block_given?
438
+ end
439
+ end
440
+
441
+ # Checks a schema of {name:, type:, required:} field rules against a
442
+ # single Hash item, reporting a missing-required-field or wrong-type
443
+ # error per field as appropriate.
444
+ def validate_item_fields(item, item_label, error_class, fields)
445
+ fields.each do |field|
446
+ value = item[field[:name]]
447
+
448
+ if field[:required] && value.nil?
449
+ errors << error_class.new("#{item_label} is missing required :#{field[:name]} field")
450
+ next
451
+ end
452
+
453
+ next if value.nil?
454
+
455
+ expected_types = Array(field[:type])
456
+ next if expected_types.empty?
457
+
458
+ unless expected_types.any? { |type| value.is_a?(type) }
459
+ errors << error_class.new(
460
+ "#{item_label} has invalid :#{field[:name]} type. " \
461
+ "Expected #{expected_types.map(&:name).join(' or ')}, got #{value.class}"
462
+ )
463
+ end
464
+ end
465
+ end
466
+
467
+ def valid_email_format?(email)
468
+ email.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
469
+ end
470
+
471
+ def error_message
472
+ header = "Configuration validation failed with #{errors.size} error(s):"
473
+ error_list = errors.map.with_index { |error, i| " #{i + 1}. #{error.message}" }.join("\n")
474
+ "#{header}\n#{error_list}"
475
+ end
476
+ end
477
+ end