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
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,25 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Chart controller
4
+ // Handles chart animations and interactions
5
+ export default class extends Controller {
6
+ static targets = ["bar"]
7
+
8
+ connect() {
9
+ this.animateBars()
10
+ }
11
+
12
+ animateBars() {
13
+ if (!this.hasBarTarget) return
14
+
15
+ this.barTargets.forEach((bar, index) => {
16
+ const finalHeight = bar.style.height
17
+ bar.style.height = '0%'
18
+
19
+ setTimeout(() => {
20
+ bar.style.transition = 'height 0.5s ease-out'
21
+ bar.style.height = finalHeight
22
+ }, index * 100)
23
+ })
24
+ }
25
+ }
@@ -0,0 +1,49 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Filter controller
4
+ // Handles filtering and search functionality
5
+ export default class extends Controller {
6
+ static targets = ["searchInput", "statusSelect", "table", "row"]
7
+
8
+ connect() {
9
+ this.filterRows()
10
+ }
11
+
12
+ filter() {
13
+ this.filterRows()
14
+ }
15
+
16
+ filterRows() {
17
+ if (!this.hasRowTarget) return
18
+
19
+ const searchTerm = this.hasSearchInputTarget ?
20
+ this.searchInputTarget.value.toLowerCase() : ''
21
+
22
+ const statusFilter = this.hasStatusSelectTarget ?
23
+ this.statusSelectTarget.value : ''
24
+
25
+ this.rowTargets.forEach(row => {
26
+ const rowText = row.textContent.toLowerCase()
27
+ const rowStatus = row.dataset.status || ''
28
+
29
+ const matchesSearch = searchTerm === '' || rowText.includes(searchTerm)
30
+ const matchesStatus = statusFilter === '' || rowStatus === statusFilter
31
+
32
+ if (matchesSearch && matchesStatus) {
33
+ row.style.display = ''
34
+ } else {
35
+ row.style.display = 'none'
36
+ }
37
+ })
38
+ }
39
+
40
+ reset() {
41
+ if (this.hasSearchInputTarget) {
42
+ this.searchInputTarget.value = ''
43
+ }
44
+ if (this.hasStatusSelectTarget) {
45
+ this.statusSelectTarget.selectedIndex = 0
46
+ }
47
+ this.filterRows()
48
+ }
49
+ }
@@ -0,0 +1,21 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Flash message controller
4
+ // Handles auto-dismissing flash messages
5
+ export default class extends Controller {
6
+ connect() {
7
+ // Auto-dismiss after 5 seconds
8
+ setTimeout(() => {
9
+ this.dismiss()
10
+ }, 5000)
11
+ }
12
+
13
+ dismiss() {
14
+ this.element.style.transition = 'opacity 0.3s ease-out'
15
+ this.element.style.opacity = '0'
16
+
17
+ setTimeout(() => {
18
+ this.element.remove()
19
+ }, 300)
20
+ }
21
+ }
@@ -0,0 +1,86 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Flow editor controller
4
+ // Handles dynamic step management in flow editor
5
+ export default class extends Controller {
6
+ static targets = ["stepsContainer"]
7
+
8
+ connect() {
9
+ this.stepIndex = this.getMaxStepIndex()
10
+ }
11
+
12
+ addStep(event) {
13
+ event.preventDefault()
14
+
15
+ this.stepIndex++
16
+ const stepNumber = this.stepIndex + 1
17
+
18
+ const template = document.getElementById('step-template')
19
+ if (!template) return
20
+
21
+ const stepHtml = template.innerHTML
22
+ .replace(/__INDEX__/g, this.stepIndex)
23
+ .replace(/__NUMBER__/g, stepNumber)
24
+
25
+ const stepsList = document.getElementById('steps-list')
26
+ if (!stepsList) return
27
+
28
+ // Remove empty state message if it exists
29
+ const emptyMessage = stepsList.querySelector('.admin-text-muted')
30
+ if (emptyMessage) {
31
+ emptyMessage.remove()
32
+ }
33
+
34
+ stepsList.insertAdjacentHTML('beforeend', stepHtml)
35
+ this.updateStepNumbers()
36
+ }
37
+
38
+ removeStep(event) {
39
+ event.preventDefault()
40
+
41
+ if (!confirm('Are you sure you want to remove this step?')) {
42
+ return
43
+ }
44
+
45
+ const stepItem = event.target.closest('.admin-step-item')
46
+ if (!stepItem) return
47
+
48
+ stepItem.remove()
49
+ this.updateStepNumbers()
50
+
51
+ // Show empty message if no steps remain
52
+ const stepsList = document.getElementById('steps-list')
53
+ if (stepsList && stepsList.children.length === 0) {
54
+ stepsList.innerHTML = '<p class="admin-text-muted">No steps added yet. Click "Add Step" to get started.</p>'
55
+ }
56
+ }
57
+
58
+ updateStepNumbers() {
59
+ const stepItems = document.querySelectorAll('.admin-step-item')
60
+ stepItems.forEach((item, index) => {
61
+ const stepNumber = item.querySelector('.admin-step-number')
62
+ if (stepNumber) {
63
+ stepNumber.textContent = index + 1
64
+ }
65
+
66
+ const orderInput = item.querySelector('input[type="hidden"][name*="[order]"]')
67
+ if (orderInput) {
68
+ orderInput.value = index
69
+ }
70
+ })
71
+ }
72
+
73
+ getMaxStepIndex() {
74
+ const stepItems = document.querySelectorAll('.admin-step-item')
75
+ let maxIndex = -1
76
+
77
+ stepItems.forEach(item => {
78
+ const index = parseInt(item.dataset.stepIndex || '0')
79
+ if (index > maxIndex) {
80
+ maxIndex = index
81
+ }
82
+ })
83
+
84
+ return maxIndex
85
+ }
86
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Rails Onboarding JavaScript Application
3
+ *
4
+ * This file provides flexible integration with different JavaScript bundling strategies:
5
+ * - Stimulus (via importmap or bundler)
6
+ * - Standalone (without Stimulus)
7
+ * - ESBuild/Webpack/Vite
8
+ *
9
+ * The code automatically detects the environment and adapts accordingly.
10
+ *
11
+ * Compatibility:
12
+ * - Works with Asset Pipeline (Sprockets)
13
+ * - Works with Propshaft
14
+ * - Works with importmap-rails
15
+ * - Works with esbuild, webpack, vite, etc.
16
+ */
17
+
18
+ (function() {
19
+ 'use strict';
20
+
21
+ // Initialize Stimulus application if available
22
+ function initializeStimulusIfAvailable() {
23
+ // Check if Stimulus is already loaded
24
+ if (window.Stimulus) {
25
+ console.log('RailsOnboarding: Using existing Stimulus application');
26
+ return window.Stimulus;
27
+ }
28
+
29
+ // Check if we can create a new Stimulus application
30
+ if (typeof Application !== 'undefined') {
31
+ const app = Application.start();
32
+ app.debug = false;
33
+ window.Stimulus = app;
34
+ console.log('RailsOnboarding: Created new Stimulus application');
35
+ return app;
36
+ }
37
+
38
+ console.debug('RailsOnboarding: Stimulus not available, using standalone mode');
39
+ return null;
40
+ }
41
+
42
+ // Auto-initialize when DOM is ready
43
+ if (document.readyState === 'loading') {
44
+ document.addEventListener('DOMContentLoaded', function() {
45
+ initializeStimulusIfAvailable();
46
+ });
47
+ } else {
48
+ initializeStimulusIfAvailable();
49
+ }
50
+ })();
51
+
52
+ // Global utilities for onboarding
53
+ window.RailsOnboarding = {
54
+ // Show a global tooltip
55
+ showTooltip: function(element, content, options = {}) {
56
+ const tooltip = new TooltipController()
57
+ tooltip.element = element
58
+ tooltip.featureValue = options.feature || ''
59
+ tooltip.positionValue = options.position || 'top'
60
+ tooltip.delayValue = options.delay || 0
61
+ tooltip.dismissibleValue = options.dismissible !== false
62
+
63
+ // Override content method
64
+ tooltip.getTooltipContent = () => content
65
+
66
+ tooltip.connect()
67
+ tooltip.forceShow()
68
+
69
+ return tooltip
70
+ },
71
+
72
+ // Hide all tooltips
73
+ hideAllTooltips: function() {
74
+ document.querySelectorAll('.onboarding-tooltip').forEach(tooltip => {
75
+ if (tooltip.parentNode) {
76
+ tooltip.parentNode.removeChild(tooltip)
77
+ }
78
+ })
79
+ },
80
+
81
+ // Restart onboarding (if supported by backend)
82
+ restartOnboarding: function() {
83
+ if (confirm('Are you sure you want to restart the onboarding process?')) {
84
+ if (typeof Rails !== 'undefined' && Rails.ajax) {
85
+ Rails.ajax({
86
+ url: '/rails_onboarding/onboarding/restart',
87
+ type: 'POST',
88
+ headers: {
89
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
90
+ },
91
+ success: function() {
92
+ window.location.reload()
93
+ },
94
+ error: function() {
95
+ alert('Failed to restart onboarding. Please try again.')
96
+ }
97
+ })
98
+ }
99
+ }
100
+ },
101
+
102
+ // Skip to end of onboarding
103
+ skipToEnd: function() {
104
+ if (confirm('Are you sure you want to skip the remaining onboarding steps?')) {
105
+ const skipForm = document.createElement('form')
106
+ skipForm.method = 'POST'
107
+ skipForm.action = '/rails_onboarding/onboarding/complete'
108
+
109
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
110
+ if (csrfToken) {
111
+ const csrfInput = document.createElement('input')
112
+ csrfInput.type = 'hidden'
113
+ csrfInput.name = 'authenticity_token'
114
+ csrfInput.value = csrfToken
115
+ skipForm.appendChild(csrfInput)
116
+ }
117
+
118
+ document.body.appendChild(skipForm)
119
+ skipForm.submit()
120
+ }
121
+ },
122
+
123
+ // Get current onboarding progress
124
+ getProgress: function() {
125
+ const progressController = document.querySelector('[data-controller*="progress"]')
126
+ if (progressController) {
127
+ return {
128
+ current: progressController.dataset.progressCurrentStepValue || 1,
129
+ total: progressController.dataset.progressTotalStepsValue || 4,
130
+ percentage: progressController.dataset.progressValue || 0
131
+ }
132
+ }
133
+ return null
134
+ },
135
+
136
+ // Add custom step validation
137
+ addStepValidation: function(stepName, validationFunction) {
138
+ const onboardingController = document.querySelector('[data-controller*="onboarding"]')
139
+ if (onboardingController && onboardingController.stimulus) {
140
+ const controller = onboardingController.stimulus
141
+ if (!controller.customValidations) {
142
+ controller.customValidations = {}
143
+ }
144
+ controller.customValidations[stepName] = validationFunction
145
+ }
146
+ }
147
+ }
148
+
149
+ // Auto-initialize tooltips for elements with data-tooltip attribute
150
+ document.addEventListener('DOMContentLoaded', function() {
151
+ document.querySelectorAll('[data-tooltip]').forEach(element => {
152
+ const tooltipText = element.dataset.tooltip
153
+ const feature = element.dataset.tooltipFeature
154
+ const position = element.dataset.tooltipPosition || 'top'
155
+ const delay = parseInt(element.dataset.tooltipDelay) || 0
156
+
157
+ if (tooltipText && !element.dataset.controllerAdded) {
158
+ element.dataset.controller = (element.dataset.controller || '') + ' tooltip'
159
+ element.dataset.tooltipFeatureValue = feature || ''
160
+ element.dataset.tooltipPositionValue = position
161
+ element.dataset.tooltipDelayValue = delay
162
+ element.dataset.tooltipText = tooltipText
163
+ element.dataset.controllerAdded = 'true'
164
+
165
+ // Re-initialize Stimulus for this element
166
+ application.register("tooltip", TooltipController)
167
+ }
168
+ })
169
+ })
170
+
171
+ // Handle onboarding completion celebration
172
+ document.addEventListener('onboarding:completed', function(event) {
173
+ // Show celebration animation or message
174
+ const celebration = document.createElement('div')
175
+ celebration.innerHTML = `
176
+ <div style="
177
+ position: fixed;
178
+ top: 50%;
179
+ left: 50%;
180
+ transform: translate(-50%, -50%);
181
+ background: white;
182
+ padding: 2rem;
183
+ border-radius: 1rem;
184
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
185
+ text-align: center;
186
+ z-index: 9999;
187
+ ">
188
+ <div style="font-size: 3rem; margin-bottom: 1rem;">🎉</div>
189
+ <h2 style="margin-bottom: 1rem; color: #1f2937;">Congratulations!</h2>
190
+ <p style="color: #6b7280; margin-bottom: 1.5rem;">You've completed the onboarding process.</p>
191
+ <button onclick="this.parentElement.parentElement.remove()" style="
192
+ background: #6366f1;
193
+ color: white;
194
+ border: none;
195
+ padding: 0.75rem 1.5rem;
196
+ border-radius: 0.5rem;
197
+ cursor: pointer;
198
+ font-weight: 600;
199
+ ">Get Started</button>
200
+ </div>
201
+ `
202
+
203
+ document.body.appendChild(celebration)
204
+
205
+ // Auto-remove after 5 seconds
206
+ setTimeout(() => {
207
+ if (celebration.parentNode) {
208
+ celebration.remove()
209
+ }
210
+ }, 5000)
211
+ })
212
+
213
+ // Handle page transitions during onboarding
214
+ document.addEventListener('turbo:before-visit', function(event) {
215
+ // Hide tooltips before navigation
216
+ RailsOnboarding.hideAllTooltips()
217
+ })
218
+
219
+ // Re-initialize onboarding after Turbo navigation
220
+ document.addEventListener('turbo:load', function() {
221
+ // Refresh any dynamic onboarding elements
222
+ const onboardingContainer = document.querySelector('.onboarding-container')
223
+ if (onboardingContainer) {
224
+ onboardingContainer.setAttribute('data-step-changed', 'true')
225
+
226
+ // Remove the attribute after animation
227
+ setTimeout(() => {
228
+ onboardingContainer.removeAttribute('data-step-changed')
229
+ }, 300)
230
+ }
231
+ })
@@ -0,0 +1,177 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["confetti"]
5
+ // milestonesPath is supplied by the view. The engine is mounted at a
6
+ // host-chosen prefix, so a literal "/milestones" is wrong in most apps.
7
+ static values = { milestone: Object, milestonesPath: String }
8
+
9
+ connect() {
10
+ this.renderContentIfEmpty()
11
+ this.showCelebration()
12
+ }
13
+
14
+ // The celebration is normally rendered server-side by
15
+ // rails_onboarding/shared/_milestone_celebration. It can also be injected
16
+ // at runtime by milestone_dashboard_controller, which knows only the
17
+ // milestone's JSON - that injected element is an empty shell, and without
18
+ // this it showed as a full-screen backdrop containing nothing, with no
19
+ // dismiss control and no confetti container. Build the same structure the
20
+ // partial produces so the existing CSS applies either way.
21
+ renderContentIfEmpty() {
22
+ if (this.element.querySelector(".celebration-modal")) return
23
+
24
+ const milestone = this.hasMilestoneValue ? this.milestoneValue : {}
25
+ const el = (tag, className, text) => {
26
+ const node = document.createElement(tag)
27
+ if (className) node.className = className
28
+ if (text !== undefined && text !== null) node.textContent = text
29
+ return node
30
+ }
31
+
32
+ const backdrop = el("div", "celebration-backdrop")
33
+ backdrop.dataset.action = "click->milestone-celebration#dismiss"
34
+
35
+ const iconContainer = el("div", "achievement-icon-container")
36
+ iconContainer.append(el("div", "achievement-icon-glow"),
37
+ el("div", "achievement-icon", milestone.icon))
38
+
39
+ const points = el("div", "points-earned")
40
+ points.append(el("span", "points-label", "You earned"),
41
+ el("span", "points-value", `+${milestone.points ?? 0}`),
42
+ el("span", "points-label", "points!"))
43
+
44
+ const announcement = el("div", "achievement-announcement")
45
+ announcement.append(iconContainer,
46
+ el("h2", "achievement-title", "Milestone Achieved!"),
47
+ el("h3", "milestone-name", milestone.title),
48
+ el("p", "milestone-description", milestone.description),
49
+ points)
50
+
51
+ const confetti = el("div", "confetti-container")
52
+ confetti.dataset.milestoneCelebrationTarget = "confetti"
53
+
54
+ const continueBtn = el("button", "onboarding-btn onboarding-btn-primary", "Continue")
55
+ continueBtn.type = "button"
56
+ continueBtn.dataset.action = "click->milestone-celebration#dismiss"
57
+
58
+ const actions = el("div", "celebration-actions")
59
+ actions.append(continueBtn)
60
+
61
+ // Only offer "View All" when the view told us where that lives.
62
+ if (this.hasMilestonesPathValue && this.milestonesPathValue) {
63
+ const allBtn = el("button", "onboarding-btn onboarding-btn-secondary", "View All Achievements")
64
+ allBtn.type = "button"
65
+ allBtn.dataset.action = "click->milestone-celebration#viewAllMilestones"
66
+ actions.append(allBtn)
67
+ }
68
+
69
+ const content = el("div", "celebration-content")
70
+ content.append(confetti, announcement, actions)
71
+
72
+ const modal = el("div", "celebration-modal")
73
+ modal.append(content)
74
+
75
+ this.element.append(backdrop, modal)
76
+ }
77
+
78
+ showCelebration() {
79
+ // Show the modal with animation. .is-hidden/.is-fading (and the
80
+ // opacity transition) live in milestones.css, so this only toggles
81
+ // classes rather than writing display/opacity/transition inline.
82
+ this.element.classList.remove("is-hidden")
83
+ this.element.classList.add("is-fading")
84
+
85
+ // Trigger animations
86
+ requestAnimationFrame(() => {
87
+ this.element.classList.remove("is-fading")
88
+
89
+ // Start confetti animation
90
+ this.startConfetti()
91
+
92
+ // Auto-dismiss after 5 seconds if user doesn't interact
93
+ this.autoDismissTimer = setTimeout(() => {
94
+ this.dismiss()
95
+ }, 8000)
96
+ })
97
+ }
98
+
99
+ startConfetti() {
100
+ // Fall back to a direct lookup: when renderContentIfEmpty() has just
101
+ // inserted the container, Stimulus may not have registered the target yet.
102
+ const confettiContainer = this.hasConfettiTarget
103
+ ? this.confettiTarget
104
+ : this.element.querySelector(".confetti-container")
105
+ if (!confettiContainer) return
106
+ const colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7']
107
+
108
+ // Create confetti pieces
109
+ for (let i = 0; i < 50; i++) {
110
+ setTimeout(() => this.createConfettiPiece(confettiContainer, colors), i * 50)
111
+ }
112
+ }
113
+
114
+ createConfettiPiece(container, colors) {
115
+ const confetti = document.createElement('div')
116
+ confetti.className = 'confetti-piece'
117
+
118
+ // Random properties
119
+ const color = colors[Math.floor(Math.random() * colors.length)]
120
+ const size = Math.random() * 8 + 4
121
+ const left = Math.random() * 100
122
+ const animationDuration = Math.random() * 2 + 2
123
+ const rotation = Math.random() * 360
124
+
125
+ confetti.style.cssText = `
126
+ position: absolute;
127
+ width: ${size}px;
128
+ height: ${size}px;
129
+ background-color: ${color};
130
+ left: ${left}%;
131
+ top: -10px;
132
+ border-radius: 50%;
133
+ animation: onboarding-confetti-fall ${animationDuration}s linear forwards;
134
+ transform: rotate(${rotation}deg);
135
+ pointer-events: none;
136
+ `
137
+
138
+ container.appendChild(confetti)
139
+
140
+ // Remove confetti piece after animation
141
+ setTimeout(() => {
142
+ if (confetti.parentNode) {
143
+ confetti.parentNode.removeChild(confetti)
144
+ }
145
+ }, animationDuration * 1000)
146
+ }
147
+
148
+ dismiss() {
149
+ if (this.autoDismissTimer) {
150
+ clearTimeout(this.autoDismissTimer)
151
+ }
152
+
153
+ this.element.classList.add("is-fading")
154
+
155
+ setTimeout(() => {
156
+ this.element.classList.add("is-hidden")
157
+ // Remove the element from DOM if it was dynamically added
158
+ if (this.element.dataset.temporary === "true") {
159
+ this.element.remove()
160
+ }
161
+ }, 300)
162
+ }
163
+
164
+ viewAllMilestones() {
165
+ this.dismiss()
166
+ // Supplied by the view via the engine's route helper; a literal
167
+ // "/milestones" only resolves when the engine is mounted at root.
168
+ if (this.hasMilestonesPathValue && this.milestonesPathValue) {
169
+ window.location.href = this.milestonesPathValue
170
+ }
171
+ }
172
+
173
+ // Prevent dismissal when clicking inside the modal
174
+ preventDismiss(event) {
175
+ event.stopPropagation()
176
+ }
177
+ }