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,763 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Tour Controller - Interactive Guided Tours with Modal Overlays
5
+ *
6
+ * Provides step-by-step guided tours with spotlight/highlight effects
7
+ * on specific elements, modal overlays, and navigation controls.
8
+ *
9
+ * Features:
10
+ * - Modal overlay with customizable opacity
11
+ * - Multiple highlight styles (spotlight, border, glow)
12
+ * - Step-by-step navigation
13
+ * - Progress indicators
14
+ * - Keyboard navigation (ESC, arrows, Enter)
15
+ * - Auto-scrolling to highlighted elements
16
+ * - Responsive positioning
17
+ * - Analytics tracking
18
+ */
19
+ export default class extends Controller {
20
+ static targets = ["overlay", "spotlight", "popup", "progress"]
21
+ static values = {
22
+ steps: String, // JSON array of tour steps
23
+ autoStart: { type: Boolean, default: false },
24
+ showProgress: { type: Boolean, default: true },
25
+ allowSkip: { type: Boolean, default: true },
26
+ overlayOpacity: { type: Number, default: 0.7 },
27
+ highlightStyle: { type: String, default: "spotlight" }, // spotlight, border, glow, none
28
+ scrollBehavior: { type: String, default: "smooth" }, // smooth, auto, none
29
+ scrollOffset: { type: Number, default: 80 }, // px offset from top when scrolling
30
+ persistProgress: { type: Boolean, default: true },
31
+ tourId: String
32
+ }
33
+
34
+ connect() {
35
+ this.currentStepIndex = 0
36
+ this.isActive = false
37
+ this.tourSteps = []
38
+ this.completedTours = this.loadCompletedTours()
39
+
40
+ this.parseSteps()
41
+ this.setupKeyboardHandlers()
42
+
43
+ // Auto-start if configured and not completed
44
+ if (this.autoStartValue && !this.isTourCompleted()) {
45
+ setTimeout(() => this.start(), 1000)
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Parse and validate tour steps from configuration
51
+ */
52
+ parseSteps() {
53
+ try {
54
+ this.tourSteps = JSON.parse(this.stepsValue || '[]')
55
+ } catch (error) {
56
+ console.error('Invalid tour steps configuration:', error)
57
+ this.tourSteps = []
58
+ return
59
+ }
60
+
61
+ // Validate and enhance each step
62
+ this.tourSteps = this.tourSteps.map((step, index) => ({
63
+ id: step.id || `step_${index}`,
64
+ selector: step.selector, // Element to highlight
65
+ title: step.title || '',
66
+ content: step.content || '',
67
+ position: step.position || 'auto', // auto, top, bottom, left, right, center
68
+ highlightStyle: step.highlightStyle || this.highlightStyleValue,
69
+ highlightPadding: step.highlightPadding || 10, // px padding around highlighted element
70
+ showNext: step.showNext !== false,
71
+ showPrev: step.showPrev !== false,
72
+ showSkip: step.showSkip !== false && this.allowSkipValue,
73
+ nextLabel: step.nextLabel || 'Next',
74
+ prevLabel: step.prevLabel || 'Previous',
75
+ skipLabel: step.skipLabel || 'Skip Tour',
76
+ completeLabel: step.completeLabel || 'Complete',
77
+ beforeShow: step.beforeShow, // Callback function name
78
+ afterShow: step.afterShow,
79
+ beforeHide: step.beforeHide,
80
+ onComplete: step.onComplete,
81
+ width: step.width || 400, // Popup width in px
82
+ ...step
83
+ }))
84
+ }
85
+
86
+ /**
87
+ * Start the tour from the beginning
88
+ */
89
+ start() {
90
+ if (this.isActive || this.tourSteps.length === 0) return
91
+
92
+ this.isActive = true
93
+ this.currentStepIndex = 0
94
+
95
+ this.createOverlay()
96
+ this.showStep(this.currentStepIndex)
97
+ this.trackEvent('tour_started')
98
+
99
+ this.dispatch('start', { detail: { tourId: this.tourIdValue } })
100
+ }
101
+
102
+ /**
103
+ * Resume tour from saved progress
104
+ */
105
+ resume() {
106
+ const progress = this.loadProgress()
107
+ if (progress && progress.stepIndex < this.tourSteps.length) {
108
+ this.currentStepIndex = progress.stepIndex
109
+ this.start()
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Stop and clean up the tour
115
+ */
116
+ stop() {
117
+ if (!this.isActive) return
118
+
119
+ this.hideCurrentStep()
120
+ this.removeOverlay()
121
+ this.isActive = false
122
+
123
+ this.trackEvent('tour_stopped')
124
+ this.dispatch('stop')
125
+ }
126
+
127
+ /**
128
+ * Complete the tour
129
+ */
130
+ complete() {
131
+ if (!this.isActive) return
132
+
133
+ this.markTourCompleted()
134
+ this.clearProgress()
135
+
136
+ this.trackEvent('tour_completed')
137
+ this.dispatch('complete', {
138
+ detail: {
139
+ tourId: this.tourIdValue,
140
+ stepsCompleted: this.currentStepIndex + 1,
141
+ totalSteps: this.tourSteps.length
142
+ }
143
+ })
144
+
145
+ // Execute onComplete callback of final step
146
+ const currentStep = this.tourSteps[this.currentStepIndex]
147
+ if (currentStep && currentStep.onComplete) {
148
+ this.executeCallback(currentStep.onComplete, currentStep)
149
+ }
150
+
151
+ this.stop()
152
+ }
153
+
154
+ /**
155
+ * Skip the tour
156
+ */
157
+ skip() {
158
+ if (!this.isActive) return
159
+
160
+ this.trackEvent('tour_skipped', {
161
+ stepIndex: this.currentStepIndex,
162
+ stepId: this.tourSteps[this.currentStepIndex]?.id
163
+ })
164
+
165
+ this.dispatch('skip')
166
+ this.stop()
167
+ }
168
+
169
+ /**
170
+ * Show a specific step
171
+ */
172
+ showStep(index) {
173
+ if (index < 0 || index >= this.tourSteps.length) return
174
+
175
+ // Hide current step first
176
+ if (this.popup) {
177
+ this.hideCurrentStep()
178
+ }
179
+
180
+ this.currentStepIndex = index
181
+ const step = this.tourSteps[index]
182
+
183
+ // Execute beforeShow callback
184
+ if (step.beforeShow) {
185
+ this.executeCallback(step.beforeShow, step)
186
+ }
187
+
188
+ // Find target element
189
+ const targetElement = step.selector ? document.querySelector(step.selector) : null
190
+
191
+ if (targetElement) {
192
+ this.scrollToElement(targetElement, step)
193
+ this.createHighlight(targetElement, step)
194
+ }
195
+
196
+ this.createPopup(step, targetElement)
197
+ this.updateProgress()
198
+ this.saveProgress()
199
+
200
+ // Execute afterShow callback
201
+ if (step.afterShow) {
202
+ setTimeout(() => this.executeCallback(step.afterShow, step), 100)
203
+ }
204
+
205
+ this.trackEvent('step_shown', {
206
+ stepIndex: index,
207
+ stepId: step.id
208
+ })
209
+
210
+ this.dispatch('step-shown', { detail: { step, index } })
211
+ }
212
+
213
+ /**
214
+ * Hide current step
215
+ */
216
+ hideCurrentStep() {
217
+ const step = this.tourSteps[this.currentStepIndex]
218
+
219
+ if (step && step.beforeHide) {
220
+ this.executeCallback(step.beforeHide, step)
221
+ }
222
+
223
+ this.removeHighlight()
224
+ this.removePopup()
225
+ }
226
+
227
+ /**
228
+ * Navigate to next step
229
+ */
230
+ next() {
231
+ if (this.currentStepIndex < this.tourSteps.length - 1) {
232
+ this.showStep(this.currentStepIndex + 1)
233
+ this.trackEvent('next_step')
234
+ } else {
235
+ this.complete()
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Navigate to previous step
241
+ */
242
+ previous() {
243
+ if (this.currentStepIndex > 0) {
244
+ this.showStep(this.currentStepIndex - 1)
245
+ this.trackEvent('previous_step')
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Go to specific step by index
251
+ */
252
+ goToStep(index) {
253
+ if (index >= 0 && index < this.tourSteps.length) {
254
+ this.showStep(index)
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Create modal overlay
260
+ */
261
+ createOverlay() {
262
+ if (this.overlay) return
263
+
264
+ this.overlay = document.createElement('div')
265
+ this.overlay.className = 'tour-overlay'
266
+ this.overlay.style.cssText = `
267
+ position: fixed;
268
+ top: 0;
269
+ left: 0;
270
+ width: 100%;
271
+ height: 100%;
272
+ background: rgba(0, 0, 0, ${this.overlayOpacityValue});
273
+ z-index: 9998;
274
+ opacity: 0;
275
+ transition: opacity 0.3s ease;
276
+ `
277
+
278
+ document.body.appendChild(this.overlay)
279
+
280
+ // Prevent body scroll
281
+ document.body.style.overflow = 'hidden'
282
+
283
+ // Trigger fade in
284
+ requestAnimationFrame(() => {
285
+ this.overlay.style.opacity = '1'
286
+ })
287
+ }
288
+
289
+ /**
290
+ * Remove modal overlay
291
+ */
292
+ removeOverlay() {
293
+ if (!this.overlay) return
294
+
295
+ this.overlay.style.opacity = '0'
296
+
297
+ setTimeout(() => {
298
+ if (this.overlay && this.overlay.parentNode) {
299
+ this.overlay.parentNode.removeChild(this.overlay)
300
+ }
301
+ this.overlay = null
302
+
303
+ // Restore body scroll
304
+ document.body.style.overflow = ''
305
+ }, 300)
306
+ }
307
+
308
+ /**
309
+ * Create highlight/spotlight on target element
310
+ */
311
+ createHighlight(element, step) {
312
+ this.removeHighlight()
313
+
314
+ const rect = element.getBoundingClientRect()
315
+ const padding = step.highlightPadding
316
+ const style = step.highlightStyle
317
+
318
+ if (style === 'none') return
319
+
320
+ this.spotlight = document.createElement('div')
321
+ this.spotlight.className = `tour-spotlight tour-spotlight-${style}`
322
+
323
+ const baseStyles = `
324
+ position: fixed;
325
+ pointer-events: none;
326
+ z-index: 9999;
327
+ transition: all 0.3s ease;
328
+ `
329
+
330
+ switch (style) {
331
+ case 'spotlight':
332
+ // Create a cutout effect in the overlay
333
+ this.spotlight.style.cssText = `
334
+ ${baseStyles}
335
+ top: ${rect.top - padding}px;
336
+ left: ${rect.left - padding}px;
337
+ width: ${rect.width + (padding * 2)}px;
338
+ height: ${rect.height + (padding * 2)}px;
339
+ box-shadow: 0 0 0 9999px rgba(0, 0, 0, ${this.overlayOpacityValue});
340
+ border-radius: 8px;
341
+ `
342
+ break
343
+
344
+ case 'border':
345
+ this.spotlight.style.cssText = `
346
+ ${baseStyles}
347
+ top: ${rect.top - padding}px;
348
+ left: ${rect.left - padding}px;
349
+ width: ${rect.width + (padding * 2)}px;
350
+ height: ${rect.height + (padding * 2)}px;
351
+ border: 3px solid #3b82f6;
352
+ border-radius: 8px;
353
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2);
354
+ `
355
+ break
356
+
357
+ case 'glow':
358
+ this.spotlight.style.cssText = `
359
+ ${baseStyles}
360
+ top: ${rect.top - padding}px;
361
+ left: ${rect.left - padding}px;
362
+ width: ${rect.width + (padding * 2)}px;
363
+ height: ${rect.height + (padding * 2)}px;
364
+ border-radius: 8px;
365
+ box-shadow:
366
+ 0 0 0 4px rgba(59, 130, 246, 0.3),
367
+ 0 0 20px 8px rgba(59, 130, 246, 0.4),
368
+ inset 0 0 20px rgba(59, 130, 246, 0.2);
369
+ `
370
+ break
371
+ }
372
+
373
+ document.body.appendChild(this.spotlight)
374
+
375
+ // Make highlighted element interactive
376
+ element.style.position = 'relative'
377
+ element.style.zIndex = '10000'
378
+ }
379
+
380
+ /**
381
+ * Remove highlight/spotlight
382
+ */
383
+ removeHighlight() {
384
+ if (this.spotlight) {
385
+ if (this.spotlight.parentNode) {
386
+ this.spotlight.parentNode.removeChild(this.spotlight)
387
+ }
388
+ this.spotlight = null
389
+ }
390
+
391
+ // Reset z-index of previously highlighted elements
392
+ document.querySelectorAll('[style*="z-index: 10000"]').forEach(el => {
393
+ el.style.zIndex = ''
394
+ })
395
+ }
396
+
397
+ /**
398
+ * Create tour popup with content and navigation
399
+ */
400
+ createPopup(step, targetElement) {
401
+ this.removePopup()
402
+
403
+ this.popup = document.createElement('div')
404
+ this.popup.className = 'tour-popup'
405
+
406
+ // Build popup HTML
407
+ const isFirstStep = this.currentStepIndex === 0
408
+ const isLastStep = this.currentStepIndex === this.tourSteps.length - 1
409
+
410
+ this.popup.innerHTML = `
411
+ <div class="tour-popup-content">
412
+ ${step.title ? `<h3 class="tour-popup-title">${step.title}</h3>` : ''}
413
+ <div class="tour-popup-body">${step.content}</div>
414
+
415
+ ${this.showProgressValue ? `
416
+ <div class="tour-popup-progress">
417
+ <div class="tour-progress-bar">
418
+ <div class="tour-progress-fill" style="width: ${((this.currentStepIndex + 1) / this.tourSteps.length) * 100}%"></div>
419
+ </div>
420
+ <div class="tour-progress-text">
421
+ Step ${this.currentStepIndex + 1} of ${this.tourSteps.length}
422
+ </div>
423
+ </div>
424
+ ` : ''}
425
+
426
+ <div class="tour-popup-actions">
427
+ ${step.showSkip ? `
428
+ <button type="button" class="tour-btn tour-btn-skip" data-action="click->tour#skip">
429
+ ${step.skipLabel}
430
+ </button>
431
+ ` : '<div></div>'}
432
+
433
+ <div class="tour-popup-nav">
434
+ ${step.showPrev && !isFirstStep ? `
435
+ <button type="button" class="tour-btn tour-btn-prev" data-action="click->tour#previous">
436
+ ← ${step.prevLabel}
437
+ </button>
438
+ ` : ''}
439
+
440
+ ${step.showNext ? `
441
+ <button type="button" class="tour-btn tour-btn-next" data-action="click->tour#next">
442
+ ${isLastStep ? step.completeLabel : step.nextLabel} →
443
+ </button>
444
+ ` : ''}
445
+ </div>
446
+ </div>
447
+ </div>
448
+ `
449
+
450
+ // Style popup
451
+ this.stylePopup(step)
452
+
453
+ document.body.appendChild(this.popup)
454
+
455
+ // Position popup relative to target
456
+ this.positionPopup(step, targetElement)
457
+
458
+ // Animate in
459
+ requestAnimationFrame(() => {
460
+ this.popup.style.opacity = '1'
461
+ this.popup.style.transform = 'scale(1)'
462
+ })
463
+ }
464
+
465
+ /**
466
+ * Style the popup element
467
+ */
468
+ stylePopup(step) {
469
+ this.popup.style.cssText = `
470
+ position: fixed;
471
+ z-index: 10001;
472
+ background: white;
473
+ border-radius: 12px;
474
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
475
+ max-width: ${step.width}px;
476
+ width: calc(100% - 2rem);
477
+ opacity: 0;
478
+ transform: scale(0.95);
479
+ transition: all 0.3s ease;
480
+ `
481
+ }
482
+
483
+ /**
484
+ * Position popup relative to target element
485
+ */
486
+ positionPopup(step, targetElement) {
487
+ if (!this.popup) return
488
+
489
+ const popupRect = this.popup.getBoundingClientRect()
490
+ const margin = 20
491
+ let top, left
492
+
493
+ if (targetElement) {
494
+ const targetRect = targetElement.getBoundingClientRect()
495
+ const position = this.calculateBestPosition(step, targetRect, popupRect, margin)
496
+
497
+ top = position.top
498
+ left = position.left
499
+ } else {
500
+ // Center on screen if no target
501
+ top = (window.innerHeight - popupRect.height) / 2
502
+ left = (window.innerWidth - popupRect.width) / 2
503
+ }
504
+
505
+ // Ensure popup stays in viewport
506
+ top = Math.max(margin, Math.min(top, window.innerHeight - popupRect.height - margin))
507
+ left = Math.max(margin, Math.min(left, window.innerWidth - popupRect.width - margin))
508
+
509
+ this.popup.style.top = `${top}px`
510
+ this.popup.style.left = `${left}px`
511
+ }
512
+
513
+ /**
514
+ * Calculate best position for popup
515
+ */
516
+ calculateBestPosition(step, targetRect, popupRect, margin) {
517
+ const positions = {
518
+ top: {
519
+ top: targetRect.top - popupRect.height - margin,
520
+ left: targetRect.left + (targetRect.width - popupRect.width) / 2
521
+ },
522
+ bottom: {
523
+ top: targetRect.bottom + margin,
524
+ left: targetRect.left + (targetRect.width - popupRect.width) / 2
525
+ },
526
+ left: {
527
+ top: targetRect.top + (targetRect.height - popupRect.height) / 2,
528
+ left: targetRect.left - popupRect.width - margin
529
+ },
530
+ right: {
531
+ top: targetRect.top + (targetRect.height - popupRect.height) / 2,
532
+ left: targetRect.right + margin
533
+ },
534
+ center: {
535
+ top: (window.innerHeight - popupRect.height) / 2,
536
+ left: (window.innerWidth - popupRect.width) / 2
537
+ }
538
+ }
539
+
540
+ // If position is auto, find best fit
541
+ if (step.position === 'auto') {
542
+ const preferences = ['bottom', 'top', 'right', 'left']
543
+
544
+ for (const pos of preferences) {
545
+ const coords = positions[pos]
546
+ if (this.isPositionValid(coords, popupRect, margin)) {
547
+ return coords
548
+ }
549
+ }
550
+
551
+ // Fallback to center
552
+ return positions.center
553
+ }
554
+
555
+ return positions[step.position] || positions.bottom
556
+ }
557
+
558
+ /**
559
+ * Check if position is valid (within viewport)
560
+ */
561
+ isPositionValid(coords, popupRect, margin) {
562
+ return coords.top >= margin &&
563
+ coords.left >= margin &&
564
+ coords.top + popupRect.height <= window.innerHeight - margin &&
565
+ coords.left + popupRect.width <= window.innerWidth - margin
566
+ }
567
+
568
+ /**
569
+ * Remove popup
570
+ */
571
+ removePopup() {
572
+ if (this.popup) {
573
+ this.popup.style.opacity = '0'
574
+ this.popup.style.transform = 'scale(0.95)'
575
+
576
+ setTimeout(() => {
577
+ if (this.popup && this.popup.parentNode) {
578
+ this.popup.parentNode.removeChild(this.popup)
579
+ }
580
+ this.popup = null
581
+ }, 300)
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Update progress display
587
+ */
588
+ updateProgress() {
589
+ if (!this.showProgressValue) return
590
+
591
+ const progressBar = this.popup?.querySelector('.tour-progress-fill')
592
+ const progressText = this.popup?.querySelector('.tour-progress-text')
593
+
594
+ if (progressBar) {
595
+ const percentage = ((this.currentStepIndex + 1) / this.tourSteps.length) * 100
596
+ progressBar.style.width = `${percentage}%`
597
+ }
598
+
599
+ if (progressText) {
600
+ progressText.textContent = `Step ${this.currentStepIndex + 1} of ${this.tourSteps.length}`
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Scroll element into view
606
+ */
607
+ scrollToElement(element, step) {
608
+ if (this.scrollBehaviorValue === 'none') return
609
+
610
+ const rect = element.getBoundingClientRect()
611
+ const offset = this.scrollOffsetValue
612
+
613
+ // Check if element is already in view
614
+ const isInView = rect.top >= offset && rect.bottom <= window.innerHeight - offset
615
+
616
+ if (!isInView) {
617
+ const scrollTop = window.pageYOffset + rect.top - offset
618
+
619
+ window.scrollTo({
620
+ top: scrollTop,
621
+ behavior: this.scrollBehaviorValue
622
+ })
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Setup keyboard event handlers
628
+ */
629
+ setupKeyboardHandlers() {
630
+ this.keyboardHandler = (event) => {
631
+ if (!this.isActive) return
632
+
633
+ switch (event.key) {
634
+ case 'Escape':
635
+ event.preventDefault()
636
+ this.skip()
637
+ break
638
+
639
+ case 'ArrowRight':
640
+ case 'Enter':
641
+ event.preventDefault()
642
+ this.next()
643
+ break
644
+
645
+ case 'ArrowLeft':
646
+ event.preventDefault()
647
+ this.previous()
648
+ break
649
+ }
650
+ }
651
+
652
+ document.addEventListener('keydown', this.keyboardHandler)
653
+ }
654
+
655
+ /**
656
+ * Execute callback function by name
657
+ */
658
+ executeCallback(functionName, step) {
659
+ if (typeof window[functionName] === 'function') {
660
+ try {
661
+ window[functionName](step, this)
662
+ } catch (error) {
663
+ console.error(`Error executing callback ${functionName}:`, error)
664
+ }
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Save tour progress
670
+ */
671
+ saveProgress() {
672
+ if (!this.persistProgressValue || !this.tourIdValue) return
673
+
674
+ const progress = {
675
+ tourId: this.tourIdValue,
676
+ stepIndex: this.currentStepIndex,
677
+ timestamp: Date.now()
678
+ }
679
+
680
+ localStorage.setItem(`tour_progress_${this.tourIdValue}`, JSON.stringify(progress))
681
+ }
682
+
683
+ /**
684
+ * Load tour progress
685
+ */
686
+ loadProgress() {
687
+ if (!this.tourIdValue) return null
688
+
689
+ try {
690
+ const data = localStorage.getItem(`tour_progress_${this.tourIdValue}`)
691
+ return data ? JSON.parse(data) : null
692
+ } catch (error) {
693
+ return null
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Clear tour progress
699
+ */
700
+ clearProgress() {
701
+ if (!this.tourIdValue) return
702
+ localStorage.removeItem(`tour_progress_${this.tourIdValue}`)
703
+ }
704
+
705
+ /**
706
+ * Mark tour as completed
707
+ */
708
+ markTourCompleted() {
709
+ if (!this.tourIdValue) return
710
+
711
+ this.completedTours[this.tourIdValue] = Date.now()
712
+ localStorage.setItem('completed_tours', JSON.stringify(this.completedTours))
713
+ }
714
+
715
+ /**
716
+ * Check if tour is completed
717
+ */
718
+ isTourCompleted() {
719
+ return this.tourIdValue && !!this.completedTours[this.tourIdValue]
720
+ }
721
+
722
+ /**
723
+ * Load completed tours
724
+ */
725
+ loadCompletedTours() {
726
+ try {
727
+ const data = localStorage.getItem('completed_tours')
728
+ return data ? JSON.parse(data) : {}
729
+ } catch (error) {
730
+ return {}
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Track analytics event
736
+ */
737
+ trackEvent(action, metadata = {}) {
738
+ // Track with Google Analytics if available
739
+ if (typeof gtag !== 'undefined') {
740
+ gtag('event', action, {
741
+ event_category: 'tour',
742
+ tour_id: this.tourIdValue,
743
+ ...metadata
744
+ })
745
+ }
746
+
747
+ // Dispatch custom event for other analytics systems
748
+ this.dispatch('analytics', {
749
+ detail: { action, tourId: this.tourIdValue, ...metadata }
750
+ })
751
+ }
752
+
753
+ /**
754
+ * Cleanup on disconnect
755
+ */
756
+ disconnect() {
757
+ this.stop()
758
+
759
+ if (this.keyboardHandler) {
760
+ document.removeEventListener('keydown', this.keyboardHandler)
761
+ }
762
+ }
763
+ }