rails_onboarding 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1351 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/rails_onboarding/admin/chart_controller.js +25 -0
- data/app/assets/javascripts/rails_onboarding/admin/filter_controller.js +49 -0
- data/app/assets/javascripts/rails_onboarding/admin/flash_controller.js +21 -0
- data/app/assets/javascripts/rails_onboarding/admin/flow_editor_controller.js +86 -0
- data/app/assets/javascripts/rails_onboarding/application.js +231 -0
- data/app/assets/javascripts/rails_onboarding/milestone_celebration_controller.js +177 -0
- data/app/assets/javascripts/rails_onboarding/milestone_dashboard_controller.js +132 -0
- data/app/assets/javascripts/rails_onboarding/milestone_detail_controller.js +34 -0
- data/app/assets/javascripts/rails_onboarding/navigation_controller.js +277 -0
- data/app/assets/javascripts/rails_onboarding/onboarding_controller.js +326 -0
- data/app/assets/javascripts/rails_onboarding/progress_controller.js +203 -0
- data/app/assets/javascripts/rails_onboarding/progressive_disclosure_controller.js +218 -0
- data/app/assets/javascripts/rails_onboarding/tooltip_controller.js +1237 -0
- data/app/assets/javascripts/rails_onboarding/tooltip_scheduler_controller.js +511 -0
- data/app/assets/javascripts/rails_onboarding/tour_controller.js +763 -0
- data/app/assets/stylesheets/rails_onboarding/accessibility.css +536 -0
- data/app/assets/stylesheets/rails_onboarding/admin.css +816 -0
- data/app/assets/stylesheets/rails_onboarding/application.css +1145 -0
- data/app/assets/stylesheets/rails_onboarding/flash_messages.css +237 -0
- data/app/assets/stylesheets/rails_onboarding/milestones.css +545 -0
- data/app/assets/stylesheets/rails_onboarding/mobile.css +856 -0
- data/app/assets/stylesheets/rails_onboarding/progressive_disclosure.css +299 -0
- data/app/assets/stylesheets/rails_onboarding/tooltips.css +468 -0
- data/app/assets/stylesheets/rails_onboarding/tour.css +495 -0
- data/app/assets/stylesheets/rails_onboarding/utilities.css +393 -0
- data/app/controllers/concerns/rails_onboarding/admin_authorization.rb +98 -0
- data/app/controllers/concerns/rails_onboarding/rate_limitable.rb +104 -0
- data/app/controllers/rails_onboarding/ab_tests_controller.rb +257 -0
- data/app/controllers/rails_onboarding/admin/ab_tests_controller.rb +121 -0
- data/app/controllers/rails_onboarding/admin/base_controller.rb +68 -0
- data/app/controllers/rails_onboarding/admin/dashboard_controller.rb +189 -0
- data/app/controllers/rails_onboarding/admin/flows_controller.rb +175 -0
- data/app/controllers/rails_onboarding/admin/users_controller.rb +366 -0
- data/app/controllers/rails_onboarding/application_controller.rb +9 -0
- data/app/controllers/rails_onboarding/milestones_controller.rb +126 -0
- data/app/controllers/rails_onboarding/onboarding_controller.rb +409 -0
- data/app/controllers/rails_onboarding/progressive_features_controller.rb +134 -0
- data/app/controllers/rails_onboarding/templates_controller.rb +162 -0
- data/app/controllers/rails_onboarding/test_sessions_controller.rb +16 -0
- data/app/controllers/rails_onboarding/tooltips_controller.rb +92 -0
- data/app/helpers/rails_onboarding/application_helper.rb +4 -0
- data/app/helpers/rails_onboarding/i18n_helper.rb +137 -0
- data/app/helpers/rails_onboarding/personalization_helper.rb +184 -0
- data/app/helpers/rails_onboarding/templates_helper.rb +253 -0
- data/app/jobs/rails_onboarding/application_job.rb +4 -0
- data/app/mailers/rails_onboarding/application_mailer.rb +6 -0
- data/app/mailers/rails_onboarding/onboarding_mailer.rb +72 -0
- data/app/models/concerns/rails_onboarding/ab_testable.rb +149 -0
- data/app/models/concerns/rails_onboarding/onboardable.rb +649 -0
- data/app/models/concerns/rails_onboarding/personalizable.rb +211 -0
- data/app/models/concerns/rails_onboarding/progressive_disclosure.rb +276 -0
- data/app/models/rails_onboarding/analytics.rb +255 -0
- data/app/models/rails_onboarding/analytics_event.rb +168 -0
- data/app/models/rails_onboarding/application_record.rb +5 -0
- data/app/models/rails_onboarding/flow.rb +41 -0
- data/app/services/rails_onboarding/milestone_service.rb +105 -0
- data/app/views/layouts/rails_onboarding/admin.html.erb +106 -0
- data/app/views/layouts/rails_onboarding/application.html.erb +65 -0
- data/app/views/rails_onboarding/ab_tests/index.html.erb +209 -0
- data/app/views/rails_onboarding/ab_tests/results.html.erb +253 -0
- data/app/views/rails_onboarding/admin/ab_tests/_ab_test_card.html.erb +24 -0
- data/app/views/rails_onboarding/admin/ab_tests/index.html.erb +57 -0
- data/app/views/rails_onboarding/admin/ab_tests/show.html.erb +41 -0
- data/app/views/rails_onboarding/admin/dashboard/index.html.erb +224 -0
- data/app/views/rails_onboarding/admin/flows/_form.html.erb +147 -0
- data/app/views/rails_onboarding/admin/flows/edit.html.erb +7 -0
- data/app/views/rails_onboarding/admin/flows/index.html.erb +125 -0
- data/app/views/rails_onboarding/admin/flows/new.html.erb +7 -0
- data/app/views/rails_onboarding/admin/flows/preview.html.erb +40 -0
- data/app/views/rails_onboarding/admin/flows/show.html.erb +114 -0
- data/app/views/rails_onboarding/admin/users/index.html.erb +180 -0
- data/app/views/rails_onboarding/admin/users/show.html.erb +187 -0
- data/app/views/rails_onboarding/milestones/index.html.erb +64 -0
- data/app/views/rails_onboarding/milestones/show.html.erb +57 -0
- data/app/views/rails_onboarding/onboarding/_custom_footer.html.erb +2 -0
- data/app/views/rails_onboarding/onboarding/_progress_indicator.html.erb +56 -0
- data/app/views/rails_onboarding/onboarding/_step_navigation.html.erb +44 -0
- data/app/views/rails_onboarding/onboarding/step.html.erb +38 -0
- data/app/views/rails_onboarding/onboarding/welcome.html.erb +37 -0
- data/app/views/rails_onboarding/onboarding_mailer/completion_email.html.erb +109 -0
- data/app/views/rails_onboarding/onboarding_mailer/completion_email.text.erb +29 -0
- data/app/views/rails_onboarding/onboarding_mailer/reminder_email.html.erb +99 -0
- data/app/views/rails_onboarding/onboarding_mailer/reminder_email.text.erb +20 -0
- data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.html.erb +105 -0
- data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.text.erb +19 -0
- data/app/views/rails_onboarding/onboarding_mailer/welcome_email.html.erb +74 -0
- data/app/views/rails_onboarding/onboarding_mailer/welcome_email.text.erb +17 -0
- data/app/views/rails_onboarding/shared/_flash.html.erb +25 -0
- data/app/views/rails_onboarding/shared/_milestone_badge.html.erb +22 -0
- data/app/views/rails_onboarding/shared/_milestone_celebration.html.erb +44 -0
- data/app/views/rails_onboarding/shared/_onboarding_banner.html.erb +48 -0
- data/app/views/rails_onboarding/templates/index.html.erb +255 -0
- data/config/importmap.rb +33 -0
- data/config/locales/en.yml +62 -0
- data/config/locales/es.yml +62 -0
- data/config/locales/fr.yml +62 -0
- data/config/routes.rb +79 -0
- data/lib/generators/rails_onboarding/install_generator.rb +124 -0
- data/lib/generators/rails_onboarding/templates/README +120 -0
- data/lib/generators/rails_onboarding/templates/add_analytics_to_rails_onboarding.rb +33 -0
- data/lib/generators/rails_onboarding/templates/add_milestone_tracking_to_users.rb +38 -0
- data/lib/generators/rails_onboarding/templates/add_onboarding_indexes.rb +93 -0
- data/lib/generators/rails_onboarding/templates/add_onboarding_to_users.rb +51 -0
- data/lib/generators/rails_onboarding/templates/add_robustness_fields_to_users.rb.tt +20 -0
- data/lib/generators/rails_onboarding/templates/create_rails_onboarding_flows.rb +18 -0
- data/lib/generators/rails_onboarding/templates/onboarding.css +18 -0
- data/lib/generators/rails_onboarding/templates/rails_onboarding.rb +75 -0
- data/lib/generators/rails_onboarding/update_generator.rb +73 -0
- data/lib/rails_onboarding/api_mode.rb +367 -0
- data/lib/rails_onboarding/backfill.rb +130 -0
- data/lib/rails_onboarding/background_jobs.rb +412 -0
- data/lib/rails_onboarding/caching.rb +180 -0
- data/lib/rails_onboarding/cdn_support.rb +202 -0
- data/lib/rails_onboarding/configuration/ab_testing.rb +24 -0
- data/lib/rails_onboarding/configuration/analytics.rb +29 -0
- data/lib/rails_onboarding/configuration/integrations.rb +31 -0
- data/lib/rails_onboarding/configuration/milestones.rb +92 -0
- data/lib/rails_onboarding/configuration/personalization.rb +25 -0
- data/lib/rails_onboarding/configuration/progressive_disclosure.rb +41 -0
- data/lib/rails_onboarding/configuration/rate_limiting.rb +18 -0
- data/lib/rails_onboarding/configuration/steps.rb +121 -0
- data/lib/rails_onboarding/configuration/templates.rb +85 -0
- data/lib/rails_onboarding/configuration/tooltips.rb +32 -0
- data/lib/rails_onboarding/configuration.rb +133 -0
- data/lib/rails_onboarding/configuration_errors.rb +19 -0
- data/lib/rails_onboarding/configuration_validator.rb +477 -0
- data/lib/rails_onboarding/controller_helpers.rb +153 -0
- data/lib/rails_onboarding/current.rb +13 -0
- data/lib/rails_onboarding/deprecation.rb +64 -0
- data/lib/rails_onboarding/devise_integration.rb +122 -0
- data/lib/rails_onboarding/engine.rb +245 -0
- data/lib/rails_onboarding/error_recovery.rb +173 -0
- data/lib/rails_onboarding/lazy_loading.rb +155 -0
- data/lib/rails_onboarding/multi_tenant.rb +306 -0
- data/lib/rails_onboarding/requirements_validator.rb +315 -0
- data/lib/rails_onboarding/responsive_helper.rb +218 -0
- data/lib/rails_onboarding/session_manager.rb +187 -0
- data/lib/rails_onboarding/skip_logic.rb +159 -0
- data/lib/rails_onboarding/turbo_compatibility.rb +229 -0
- data/lib/rails_onboarding/version.rb +3 -0
- data/lib/rails_onboarding.rb +44 -0
- data/lib/tasks/rails_onboarding_analytics.rake +136 -0
- data/lib/tasks/rails_onboarding_tasks.rake +162 -0
- metadata +303 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["submitButton", "radioButton"]
|
|
5
|
+
static values = { currentStep: String }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.updateSubmitButtonState()
|
|
9
|
+
this.setupKeyboardNavigation()
|
|
10
|
+
this.announceStepToScreenReader()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Enable submit button when radio button is selected
|
|
14
|
+
enableSubmit(event) {
|
|
15
|
+
const submitButton = this.element.querySelector('.primary-action[disabled]')
|
|
16
|
+
if (submitButton && event.target.checked) {
|
|
17
|
+
submitButton.disabled = false
|
|
18
|
+
submitButton.textContent = submitButton.textContent.replace('disabled', '')
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Handle radio button changes
|
|
23
|
+
radioChanged(event) {
|
|
24
|
+
if (event.target.type === 'radio' && event.target.checked) {
|
|
25
|
+
this.enableSubmitButton()
|
|
26
|
+
this.updateSelectedChoice(event.target)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Show feature tooltip
|
|
31
|
+
showFeatureTooltip(event) {
|
|
32
|
+
const feature = event.target.dataset.featureTarget
|
|
33
|
+
const tooltip = this.createTooltip(feature)
|
|
34
|
+
this.showTooltip(tooltip, event.target)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Update submit button state based on form validity
|
|
38
|
+
updateSubmitButtonState() {
|
|
39
|
+
const submitButton = this.element.querySelector('.primary-action')
|
|
40
|
+
const radioButtons = this.element.querySelectorAll('input[type="radio"]')
|
|
41
|
+
const requiredFields = this.element.querySelectorAll('input[required], select[required]')
|
|
42
|
+
|
|
43
|
+
if (submitButton && (radioButtons.length > 0 || requiredFields.length > 0)) {
|
|
44
|
+
const hasSelection = Array.from(radioButtons).some(radio => radio.checked)
|
|
45
|
+
const allRequiredFilled = Array.from(requiredFields).every(field =>
|
|
46
|
+
field.value.trim() !== ''
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if (radioButtons.length > 0) {
|
|
50
|
+
submitButton.disabled = !hasSelection
|
|
51
|
+
} else if (requiredFields.length > 0) {
|
|
52
|
+
submitButton.disabled = !allRequiredFilled
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Enable the submit button
|
|
58
|
+
enableSubmitButton() {
|
|
59
|
+
const submitButton = this.element.querySelector('.primary-action')
|
|
60
|
+
if (submitButton) {
|
|
61
|
+
submitButton.disabled = false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Update visual state of selected choice
|
|
66
|
+
updateSelectedChoice(selectedRadio) {
|
|
67
|
+
// Remove selection from all choices
|
|
68
|
+
this.element.querySelectorAll('.action-choice').forEach(choice => {
|
|
69
|
+
choice.classList.remove('selected')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Add selection to current choice
|
|
73
|
+
const selectedChoice = selectedRadio.closest('.action-choice')
|
|
74
|
+
if (selectedChoice) {
|
|
75
|
+
selectedChoice.classList.add('selected')
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Create tooltip for feature explanation
|
|
80
|
+
createTooltip(feature) {
|
|
81
|
+
const tooltips = {
|
|
82
|
+
organizing: {
|
|
83
|
+
title: "Smart Organization",
|
|
84
|
+
content: "Create hierarchical lists, set priorities, add due dates, and organize items exactly how you think. Use tags, categories, and custom sorting to keep everything in its place."
|
|
85
|
+
},
|
|
86
|
+
collaboration: {
|
|
87
|
+
title: "Team Collaboration",
|
|
88
|
+
content: "Invite team members, assign tasks, leave comments, and track progress together. Real-time updates keep everyone synchronized and productive."
|
|
89
|
+
},
|
|
90
|
+
automation: {
|
|
91
|
+
title: "Smart Automation",
|
|
92
|
+
content: "Set up recurring tasks, automated reminders, smart templates, and rule-based actions. Let the app handle routine work so you can focus on what matters."
|
|
93
|
+
},
|
|
94
|
+
insights: {
|
|
95
|
+
title: "Progress Insights",
|
|
96
|
+
content: "Track completion rates, identify patterns, see productivity trends, and get personalized suggestions to improve your workflow and achieve your goals."
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return tooltips[feature] || { title: "Feature", content: "Learn more about this feature." }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Show tooltip near target element
|
|
104
|
+
showTooltip(tooltipData, targetElement) {
|
|
105
|
+
// Remove any existing tooltips
|
|
106
|
+
this.hideTooltips()
|
|
107
|
+
|
|
108
|
+
const tooltip = document.createElement('div')
|
|
109
|
+
tooltip.className = 'feature-tooltip'
|
|
110
|
+
// The class names here are the ones tooltips.css already styles.
|
|
111
|
+
// Previously this emitted a bare <h4>/<p>, which picked up
|
|
112
|
+
// .tooltip-content h4/p from application.css - rules coloured for a light
|
|
113
|
+
// background (var(--onboarding-text)) - leaving dark text on the feature
|
|
114
|
+
// tooltip's saturated gradient. .tooltip-title/.tooltip-body carry the
|
|
115
|
+
// white-on-gradient treatment that was written for this component.
|
|
116
|
+
// "Got it" is a text button, so it uses .tooltip-action rather than
|
|
117
|
+
// .tooltip-close, which is sized 1.5rem square for an icon and clipped it.
|
|
118
|
+
tooltip.innerHTML = `
|
|
119
|
+
<div class="tooltip-content">
|
|
120
|
+
<h4 class="tooltip-title">${tooltipData.title}</h4>
|
|
121
|
+
<p class="tooltip-body">${tooltipData.content}</p>
|
|
122
|
+
<div class="tooltip-actions">
|
|
123
|
+
<button type="button" class="tooltip-action onboarding-secondary" data-action="click->onboarding#hideTooltips">
|
|
124
|
+
Got it
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
`
|
|
129
|
+
|
|
130
|
+
// Position tooltip
|
|
131
|
+
document.body.appendChild(tooltip)
|
|
132
|
+
this.positionTooltip(tooltip, targetElement)
|
|
133
|
+
|
|
134
|
+
// Auto-hide after 10 seconds
|
|
135
|
+
setTimeout(() => this.hideTooltips(), 10000)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Position tooltip relative to target
|
|
139
|
+
positionTooltip(tooltip, target) {
|
|
140
|
+
const rect = target.getBoundingClientRect()
|
|
141
|
+
const tooltipRect = tooltip.getBoundingClientRect()
|
|
142
|
+
|
|
143
|
+
// Sits below the target by default, flipping above when it would run off
|
|
144
|
+
// the bottom of the viewport.
|
|
145
|
+
let top = rect.bottom + 10
|
|
146
|
+
let placedAbove = false
|
|
147
|
+
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2)
|
|
148
|
+
|
|
149
|
+
// Adjust if tooltip would be off-screen
|
|
150
|
+
if (left < 10) left = 10
|
|
151
|
+
if (left + tooltipRect.width > window.innerWidth - 10) {
|
|
152
|
+
left = window.innerWidth - tooltipRect.width - 10
|
|
153
|
+
}
|
|
154
|
+
if (top + tooltipRect.height > window.innerHeight - 10) {
|
|
155
|
+
top = rect.top - tooltipRect.height - 10
|
|
156
|
+
placedAbove = true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Tell the stylesheet which way the arrow should point. The naming
|
|
160
|
+
// matches tooltip_controller.js: onboarding-top means the tooltip sits
|
|
161
|
+
// above its target, so the arrow hangs off the bottom edge.
|
|
162
|
+
tooltip.classList.remove("onboarding-top", "onboarding-bottom")
|
|
163
|
+
tooltip.classList.add(placedAbove ? "onboarding-top" : "onboarding-bottom")
|
|
164
|
+
|
|
165
|
+
tooltip.style.position = 'fixed'
|
|
166
|
+
tooltip.style.top = `${top}px`
|
|
167
|
+
tooltip.style.left = `${left}px`
|
|
168
|
+
tooltip.style.zIndex = '1000'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Hide all tooltips
|
|
172
|
+
hideTooltips() {
|
|
173
|
+
document.querySelectorAll('.feature-tooltip').forEach(tooltip => {
|
|
174
|
+
tooltip.remove()
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Setup keyboard navigation
|
|
179
|
+
setupKeyboardNavigation() {
|
|
180
|
+
this.element.addEventListener('keydown', (event) => {
|
|
181
|
+
if (event.key === 'Escape') {
|
|
182
|
+
this.hideTooltips()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Arrow key navigation for radio buttons
|
|
186
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
187
|
+
const radioButtons = Array.from(this.element.querySelectorAll('input[type="radio"]'))
|
|
188
|
+
const currentIndex = radioButtons.findIndex(radio => radio.checked)
|
|
189
|
+
|
|
190
|
+
if (radioButtons.length > 0) {
|
|
191
|
+
event.preventDefault()
|
|
192
|
+
let nextIndex
|
|
193
|
+
|
|
194
|
+
if (event.key === 'ArrowDown') {
|
|
195
|
+
nextIndex = (currentIndex + 1) % radioButtons.length
|
|
196
|
+
} else {
|
|
197
|
+
nextIndex = currentIndex <= 0 ? radioButtons.length - 1 : currentIndex - 1
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
radioButtons[nextIndex].checked = true
|
|
201
|
+
radioButtons[nextIndex].focus()
|
|
202
|
+
this.updateSelectedChoice(radioButtons[nextIndex])
|
|
203
|
+
this.enableSubmitButton()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Announce step change to screen readers
|
|
210
|
+
announceStepToScreenReader() {
|
|
211
|
+
const announcement = document.createElement('div')
|
|
212
|
+
announcement.setAttribute('aria-live', 'polite')
|
|
213
|
+
announcement.setAttribute('aria-atomic', 'true')
|
|
214
|
+
announcement.className = 'onboarding-sr-only'
|
|
215
|
+
announcement.style.cssText = 'position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden;'
|
|
216
|
+
|
|
217
|
+
const stepTitle = this.element.querySelector('.step-title')
|
|
218
|
+
if (stepTitle) {
|
|
219
|
+
announcement.textContent = `Onboarding step: ${stepTitle.textContent}`
|
|
220
|
+
document.body.appendChild(announcement)
|
|
221
|
+
|
|
222
|
+
// Remove after announcement
|
|
223
|
+
setTimeout(() => {
|
|
224
|
+
if (announcement.parentNode) {
|
|
225
|
+
announcement.parentNode.removeChild(announcement)
|
|
226
|
+
}
|
|
227
|
+
}, 1000)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Handle form submission with validation
|
|
232
|
+
submitForm(event) {
|
|
233
|
+
const form = event.target.closest('form')
|
|
234
|
+
if (form) {
|
|
235
|
+
// Validate required fields
|
|
236
|
+
const requiredFields = form.querySelectorAll('input[required], select[required]')
|
|
237
|
+
const radioGroups = {}
|
|
238
|
+
|
|
239
|
+
// Check radio button groups
|
|
240
|
+
form.querySelectorAll('input[type="radio"]').forEach(radio => {
|
|
241
|
+
if (!radioGroups[radio.name]) {
|
|
242
|
+
radioGroups[radio.name] = []
|
|
243
|
+
}
|
|
244
|
+
radioGroups[radio.name].push(radio)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// Validate radio groups
|
|
248
|
+
for (const groupName in radioGroups) {
|
|
249
|
+
const group = radioGroups[groupName]
|
|
250
|
+
const hasSelection = group.some(radio => radio.checked)
|
|
251
|
+
|
|
252
|
+
if (!hasSelection && group.length > 0) {
|
|
253
|
+
event.preventDefault()
|
|
254
|
+
this.showValidationError('Please make a selection to continue.')
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Validate other required fields
|
|
260
|
+
for (const field of requiredFields) {
|
|
261
|
+
if (field.value.trim() === '') {
|
|
262
|
+
event.preventDefault()
|
|
263
|
+
field.focus()
|
|
264
|
+
this.showValidationError('Please fill in all required fields.')
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return true
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Show validation error message
|
|
274
|
+
showValidationError(message) {
|
|
275
|
+
// Remove existing error messages
|
|
276
|
+
this.element.querySelectorAll('.validation-error').forEach(error => {
|
|
277
|
+
error.remove()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Create error message
|
|
281
|
+
const errorDiv = document.createElement('div')
|
|
282
|
+
errorDiv.className = 'validation-error'
|
|
283
|
+
errorDiv.style.cssText = `
|
|
284
|
+
background: #fef2f2;
|
|
285
|
+
border: 1px solid #fecaca;
|
|
286
|
+
color: #dc2626;
|
|
287
|
+
padding: 0.75rem 1rem;
|
|
288
|
+
border-radius: 0.5rem;
|
|
289
|
+
margin-top: 1rem;
|
|
290
|
+
font-size: 0.875rem;
|
|
291
|
+
text-align: center;
|
|
292
|
+
`
|
|
293
|
+
errorDiv.textContent = message
|
|
294
|
+
|
|
295
|
+
// Insert before action buttons
|
|
296
|
+
const actionButtons = this.element.querySelector('.action-buttons')
|
|
297
|
+
if (actionButtons) {
|
|
298
|
+
actionButtons.parentNode.insertBefore(errorDiv, actionButtons)
|
|
299
|
+
} else {
|
|
300
|
+
this.element.appendChild(errorDiv)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Auto-remove after 5 seconds
|
|
304
|
+
setTimeout(() => {
|
|
305
|
+
if (errorDiv.parentNode) {
|
|
306
|
+
errorDiv.remove()
|
|
307
|
+
}
|
|
308
|
+
}, 5000)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Handle input changes for real-time validation
|
|
312
|
+
inputChanged(event) {
|
|
313
|
+
this.updateSubmitButtonState()
|
|
314
|
+
|
|
315
|
+
// Clear validation errors when user starts typing
|
|
316
|
+
const existingErrors = this.element.querySelectorAll('.validation-error')
|
|
317
|
+
if (existingErrors.length > 0) {
|
|
318
|
+
existingErrors.forEach(error => error.remove())
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Disconnect cleanup
|
|
323
|
+
disconnect() {
|
|
324
|
+
this.hideTooltips()
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = {
|
|
5
|
+
progress: Number,
|
|
6
|
+
totalSteps: Number,
|
|
7
|
+
currentStep: Number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
connect() {
|
|
11
|
+
this.updateProgressDisplay()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Update progress when values change
|
|
15
|
+
progressValueChanged() {
|
|
16
|
+
this.updateProgressDisplay()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
currentStepValueChanged() {
|
|
20
|
+
this.updateStepStates()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Update the visual progress display. The fill's width comes from
|
|
24
|
+
// --onboarding-progress-width (consumed by .progress-fill in CSS), so
|
|
25
|
+
// changing it here still animates via the stylesheet's `transition:
|
|
26
|
+
// width` - no inline width/transition needed.
|
|
27
|
+
updateProgressDisplay() {
|
|
28
|
+
const progressFill = this.element.querySelector('.onboarding-progress-fill')
|
|
29
|
+
const progressText = this.element.querySelector('.current-progress')
|
|
30
|
+
|
|
31
|
+
if (progressFill) {
|
|
32
|
+
progressFill.style.setProperty('--onboarding-progress-width', `${this.progressValue}%`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (progressText) {
|
|
36
|
+
progressText.textContent = `Step ${this.currentStepValue || 1}`
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Update step marker states
|
|
41
|
+
updateStepStates() {
|
|
42
|
+
const steps = this.element.querySelectorAll('.progress-step')
|
|
43
|
+
const currentStep = this.currentStepValue || 1
|
|
44
|
+
|
|
45
|
+
steps.forEach((step, index) => {
|
|
46
|
+
const stepNumber = index + 1
|
|
47
|
+
const marker = step.querySelector('.step-marker')
|
|
48
|
+
const checkElement = step.querySelector('.step-check')
|
|
49
|
+
const numberElement = step.querySelector('.step-number')
|
|
50
|
+
|
|
51
|
+
// Remove all state classes
|
|
52
|
+
step.classList.remove('onboarding-completed', 'current', 'upcoming')
|
|
53
|
+
|
|
54
|
+
// Add appropriate class based on progress
|
|
55
|
+
if (stepNumber < currentStep) {
|
|
56
|
+
step.classList.add('onboarding-completed')
|
|
57
|
+
|
|
58
|
+
// Show checkmark for completed steps
|
|
59
|
+
if (numberElement && !checkElement) {
|
|
60
|
+
numberElement.textContent = '✓'
|
|
61
|
+
numberElement.className = 'step-check'
|
|
62
|
+
}
|
|
63
|
+
} else if (stepNumber === currentStep) {
|
|
64
|
+
step.classList.add('onboarding-current')
|
|
65
|
+
|
|
66
|
+
// Show number for current step
|
|
67
|
+
if (numberElement) {
|
|
68
|
+
numberElement.textContent = stepNumber
|
|
69
|
+
numberElement.className = 'step-number'
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
step.classList.add('onboarding-upcoming')
|
|
73
|
+
|
|
74
|
+
// Show number for upcoming steps
|
|
75
|
+
if (numberElement) {
|
|
76
|
+
numberElement.textContent = stepNumber
|
|
77
|
+
numberElement.className = 'step-number'
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Calculate progress percentage
|
|
84
|
+
calculateProgress(currentStep, totalSteps) {
|
|
85
|
+
if (!totalSteps || totalSteps <= 0) return 0
|
|
86
|
+
return Math.min(100, Math.max(0, (currentStep / totalSteps) * 100))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Pulse animation for current step
|
|
90
|
+
pulseCurrentStep() {
|
|
91
|
+
const currentStepElement = this.element.querySelector('.progress-step.onboarding-current .step-marker')
|
|
92
|
+
if (currentStepElement) {
|
|
93
|
+
currentStepElement.classList.add('step-marker-pulse')
|
|
94
|
+
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
currentStepElement.classList.remove('step-marker-pulse')
|
|
97
|
+
}, 1000)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Show step tooltip on hover
|
|
102
|
+
showStepTooltip(event) {
|
|
103
|
+
const step = event.currentTarget
|
|
104
|
+
const stepIndex = Array.from(step.parentNode.children).indexOf(step)
|
|
105
|
+
const stepData = this.getStepData(stepIndex)
|
|
106
|
+
|
|
107
|
+
if (stepData) {
|
|
108
|
+
this.createStepTooltip(stepData, step)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Hide step tooltip
|
|
113
|
+
hideStepTooltip(event) {
|
|
114
|
+
const tooltip = document.querySelector('.step-tooltip')
|
|
115
|
+
if (tooltip) {
|
|
116
|
+
tooltip.remove()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Get step data for tooltip
|
|
121
|
+
getStepData(stepIndex) {
|
|
122
|
+
const stepNames = ['welcome', 'profile', 'first_action', 'explore']
|
|
123
|
+
const stepTitles = ['Welcome', 'Setup Profile', 'First Action', 'Explore Features']
|
|
124
|
+
const stepDescriptions = [
|
|
125
|
+
'Get introduced to the platform',
|
|
126
|
+
'Personalize your experience',
|
|
127
|
+
'Take your first meaningful action',
|
|
128
|
+
'Discover key features'
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
if (stepIndex < stepNames.length) {
|
|
132
|
+
return {
|
|
133
|
+
name: stepNames[stepIndex],
|
|
134
|
+
title: stepTitles[stepIndex],
|
|
135
|
+
description: stepDescriptions[stepIndex]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Create tooltip for step
|
|
142
|
+
createStepTooltip(stepData, targetElement) {
|
|
143
|
+
const tooltip = document.createElement('div')
|
|
144
|
+
tooltip.className = 'step-tooltip'
|
|
145
|
+
tooltip.innerHTML = `
|
|
146
|
+
<div class="tooltip-content">
|
|
147
|
+
<h4>${stepData.title}</h4>
|
|
148
|
+
<p>${stepData.description}</p>
|
|
149
|
+
</div>
|
|
150
|
+
`
|
|
151
|
+
|
|
152
|
+
// Position tooltip (top/left are viewport-relative and computed per
|
|
153
|
+
// call, so they stay inline; everything else lives in .step-tooltip)
|
|
154
|
+
document.body.appendChild(tooltip)
|
|
155
|
+
this.positionTooltip(tooltip, targetElement)
|
|
156
|
+
|
|
157
|
+
// Auto-remove after 3 seconds
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
if (tooltip.parentNode) {
|
|
160
|
+
tooltip.remove()
|
|
161
|
+
}
|
|
162
|
+
}, 3000)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Position tooltip relative to target
|
|
166
|
+
positionTooltip(tooltip, target) {
|
|
167
|
+
const rect = target.getBoundingClientRect()
|
|
168
|
+
const tooltipRect = tooltip.getBoundingClientRect()
|
|
169
|
+
|
|
170
|
+
let top = rect.bottom + 8
|
|
171
|
+
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2)
|
|
172
|
+
|
|
173
|
+
// Adjust if tooltip would be off-screen
|
|
174
|
+
if (left < 8) left = 8
|
|
175
|
+
if (left + tooltipRect.width > window.innerWidth - 8) {
|
|
176
|
+
left = window.innerWidth - tooltipRect.width - 8
|
|
177
|
+
}
|
|
178
|
+
if (top + tooltipRect.height > window.innerHeight - 8) {
|
|
179
|
+
top = rect.top - tooltipRect.height - 8
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
tooltip.style.top = `${top}px`
|
|
183
|
+
tooltip.style.left = `${left}px`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Advance to next step (for testing/demo purposes)
|
|
187
|
+
advanceStep() {
|
|
188
|
+
if (this.currentStepValue < this.totalStepsValue) {
|
|
189
|
+
this.currentStepValue += 1
|
|
190
|
+
this.progressValue = this.calculateProgress(this.currentStepValue, this.totalStepsValue)
|
|
191
|
+
this.pulseCurrentStep()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Go back to previous step
|
|
196
|
+
previousStep() {
|
|
197
|
+
if (this.currentStepValue > 1) {
|
|
198
|
+
this.currentStepValue -= 1
|
|
199
|
+
this.progressValue = this.calculateProgress(this.currentStepValue, this.totalStepsValue)
|
|
200
|
+
this.pulseCurrentStep()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|