PluginProbe
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress / 9.1.3
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress v9.1.3
9.1.3 9.1.2 9.1.1 9.1.0 9.0.3 9.0.2 9.0.1 9.0.0 8.5.79 8.5.78 8.5.77 8.5.76 8.5.75 8.5.74 8.5.73 8.5.72 8.5.71 8.5.70 8.5.69 8.5.68 8.5.35 8.5.36 8.5.37 8.5.38 8.5.39 All 222 releases
wpvr / legacy / admin / js / setup-wizard.js

setup-wizard.js in WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress 9.1.3, at legacy/admin/js/setup-wizard.js

1,421 lines 62.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 jQuery(document).ready(function ($) {
2 if (typeof window.LinnoOnboarding === 'undefined') {
3 console.error('Linno Onboarding library not loaded');
4 return;
5 }
6
7 const { registerOnboarding, engine, tracker } = window.LinnoOnboarding;
8
9 const content = {
10 'real-estate': {
11 title: "Show your property without another site visit",
12 sub: "Most agents publish their first property tour in minutes.",
13 cta: "USE PROPERTY TEMPLATE",
14 publishText: "Publish property tour",
15 prev: "This is how buyers will experience your listing",
16 succ: "Your tour is published!",
17 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
18 icon: "🏠"
19 },
20 'hotel': {
21 title: "Let guests experience your hotel before they book",
22 sub: "Virtual tours help increase trust and direct bookings.",
23 cta: "USE HOTEL TEMPLATE",
24 publishText: "Publish hotel tour",
25 prev: "This is how guests will see your room",
26 succ: "Your tour is published!",
27 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
28 icon: "🏨"
29 },
30 'school': {
31 title: "Showcase your campus to prospective students",
32 sub: "Virtual campus tours help increase enrollment and engagement.",
33 cta: "USE CAMPUS TEMPLATE",
34 publishText: "Publish campus tour",
35 prev: "This is how students will explore your campus",
36 succ: "Your tour is published!",
37 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
38 icon: "🏫"
39 },
40 'automotive': {
41 title: "Showcase your vehicles in stunning detail",
42 sub: "Virtual tours help customers explore every angle before purchase.",
43 cta: "USE AUTOMOTIVE TEMPLATE",
44 publishText: "Publish automotive tour",
45 prev: "This is how customers will explore your vehicles",
46 succ: "Your tour is published!",
47 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
48 icon: "🚗"
49 },
50 'ecommerce': {
51 title: "Create immersive shopping experiences",
52 sub: "Virtual tours help customers visualize products in real spaces.",
53 cta: "USE E-COMMERCE TEMPLATE",
54 publishText: "Publish ecommerce tour",
55 prev: "This is how customers will shop your products",
56 succ: "Your tour is published!",
57 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
58 icon: "🛍"
59 },
60 'exhibitions': {
61 title: "Bring your art and exhibitions to life",
62 sub: "Virtual tours help visitors explore galleries from anywhere.",
63 cta: "USE EXHIBITION TEMPLATE",
64 publishText: "Publish exhibition tour",
65 prev: "This is how visitors will experience your exhibition",
66 succ: "Your tour is published!",
67 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
68 icon: "🎨"
69 },
70 'offices': {
71 title: "Showcase your workspace to potential clients",
72 sub: "Virtual office tours help build trust and attract talent.",
73 cta: "USE OFFICE TEMPLATE",
74 publishText: "Publish office tour",
75 prev: "This is how visitors will explore your office",
76 succ: "Your tour is published!",
77 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
78 icon: "🏢"
79 },
80 'training': {
81 title: "Create engaging onboarding experiences",
82 sub: "Virtual training tours help new employees learn faster.",
83 cta: "USE TRAINING TEMPLATE",
84 publishText: "Publish training tour",
85 prev: "This is how trainees will experience your program",
86 succ: "Your tour is published!",
87 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
88 icon: "📘"
89 },
90 'showrooms': {
91 title: "Transform your showroom into a virtual experience",
92 sub: "Virtual showrooms help customers explore products anytime.",
93 cta: "USE SHOWROOM TEMPLATE",
94 publishText: "Publish showroom tour",
95 prev: "This is how customers will browse your showroom",
96 succ: "Your tour is published!",
97 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
98 icon: "🏬"
99 },
100 'tourism': {
101 title: "Let travelers explore destinations before they arrive",
102 sub: "Virtual tours help increase bookings and visitor engagement.",
103 cta: "USE TOURISM TEMPLATE",
104 publishText: "Publish tourism tour",
105 prev: "This is how travelers will discover your destination",
106 succ: "🎉 Your tour is published!",
107 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
108 icon: "🏖"
109 },
110 'fitness': {
111 title: "Showcase your fitness facilities",
112 sub: "Virtual tours help attract new members and showcase amenities.",
113 cta: "USE FITNESS TEMPLATE",
114 publishText: "Publish fitness tour",
115 prev: "This is how members will explore your facility",
116 succ: "Your tour is published!",
117 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
118 icon: "🏋️"
119 },
120 'museums': {
121 title: "Make cultural sites accessible to everyone",
122 sub: "Virtual museum tours help reach global audiences.",
123 cta: "USE MUSEUM TEMPLATE",
124 publishText: "Publish museum tour",
125 prev: "This is how visitors will explore your museum",
126 succ: "Your tour is published!",
127 succSub: "Your virtual tour has been created and is ready to be displayed on your website.",
128 icon: "🏛"
129 }
130 };
131
132 // Pro / hotspot-limit config (populated via wp_localize_script)
133 const wpvrWizardCfg = (typeof wpvrSetupWizardData !== 'undefined') ? wpvrSetupWizardData : {};
134 const wizardNonce = (typeof wpvrNonce !== 'undefined' && wpvrNonce) ? wpvrNonce : (wpvrWizardCfg.ajax_nonce || '');
135 const isPro = !!wpvrWizardCfg.is_pro;
136 const HOTSPOT_LIMIT = isPro ? Infinity : (parseInt(wpvrWizardCfg.hotspot_limit, 10) || 5);
137
138 const wizardStrings = wpvrWizardCfg.wizard_strings || {};
139
140 let industrySkipped = false; // true when user chose to upload own image from step 1
141 let bypassTemplateOnBack = false; // true when returning backward through the bypassed template step
142 let selectedIndustry = null;
143 let uploadedImageUrl = null;
144 let uploadedImageFile = null;
145 let uploadedImageId = null; // WordPress attachment ID
146 let panoramaViewer = null;
147 let hotspots = [];
148 let pendingHotspotCoords = null;
149 let panoramaMouseDown = null;
150 let panoramaIsDragging = false;
151 let templateData = null; // Store template tour object
152 let mediaUploader = null; // WordPress media uploader instance
153
154 // Skip wizard and redirect to tour listing
155 function skipWizard() {
156 const listingUrl = ajaxurl.replace('admin-ajax.php', 'edit.php?post_type=wpvr_item');
157 window.location.href = listingUrl;
158 }
159
160 // Bind "Remind me later" button (exits the wizard, redirects to listing)
161 $('#wizard-exit').on('click', function (e) {
162 e.preventDefault();
163 skipWizard();
164 });
165
166 // Generate random string for scene IDs (matching WPVR format)
167 function generateRandomString(length) {
168 var result = '';
169 var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
170 var charactersLength = characters.length;
171 for (var i = 0; i < length; i++) {
172 var randomIndex = Math.floor(Math.random() * charactersLength);
173 result += characters.charAt(randomIndex);
174 }
175 return result;
176 }
177
178 // Universal open WP Media method for our workflow
179 function openWPMediaForDirectUpload(callback) {
180 // Create WordPress media uploader with standard frame
181 const uploader = wp.media({
182 title: 'Select or Upload 360° Image',
183 button: {
184 text: 'Select Image'
185 },
186 multiple: false,
187 library: {
188 type: 'image'
189 },
190 state: 'library'
191 });
192
193 // When image is selected
194 uploader.on('select', function () {
195 const attachment = uploader.state().get('selection').first().toJSON();
196 callback(attachment);
197 });
198
199 uploader.open();
200 }
201
202 function clearWizardImageWarning() {
203 if (!window.WPVRImageResizeWarning) {
204 return;
205 }
206
207 window.WPVRImageResizeWarning.remove();
208 }
209
210 function handleWizardPanoramaSelection(attachment, applyImage) {
211 clearWizardImageWarning();
212
213 if (window.WPVRImageResizeWarning) {
214 try {
215 window.WPVRImageResizeWarning.handleSelection(
216 attachment,
217 function (imageUrl) {
218 applyImage(imageUrl, attachment.id);
219 }
220 );
221 } catch (error) {
222 applyImage(attachment.url, attachment.id);
223 }
224 return;
225 }
226
227 applyImage(attachment.url, attachment.id);
228 }
229
230 // ── Skip-mode UI helper ─────────────────────────────────────────
231 // Toggles the sidebar Template nav item and the progress bars in
232 // step-preview and step-success between the 4-step default and the
233 // 3-step "no template" variant.
234 function updateSkipModeUI(isSkipped) {
235 const filled = '<div class="step-seg filled"></div>';
236 const empty = '<div class="step-seg"></div>';
237
238 if (isSkipped) {
239 // Hide template nav, renumber remaining steps as 1→2→3
240 $('.nav-item[data-step="step-template"]').hide();
241 $('.nav-item[data-step="step-preview"]').attr('data-number', '2');
242 $('.nav-item[data-step="step-success"]').attr('data-number', '3');
243
244 // Progress bars — 3-step mode
245 $('#step-vertical .step-progress').html(
246 filled + empty + empty +
247 '<span class="step-count">1/3</span>'
248 );
249 $('#step-preview .step-progress').html(
250 filled + filled + empty +
251 '<span class="step-count">2/3</span>'
252 );
253 $('#step-success .step-progress').html(
254 filled + filled + filled +
255 '<span class="step-count">3/3</span>'
256 );
257 } else {
258 // Show template nav, restore original numbers 1→2→3→4
259 $('.nav-item[data-step="step-template"]').show();
260 $('.nav-item[data-step="step-preview"]').attr('data-number', '3');
261 $('.nav-item[data-step="step-success"]').attr('data-number', '4');
262
263 // Progress bars — 4-step mode
264 $('#step-vertical .step-progress').html(
265 filled + empty + empty + empty +
266 '<span class="step-count">1/4</span>'
267 );
268 $('#step-preview .step-progress').html(
269 filled + filled + filled + empty +
270 '<span class="step-count">3/4</span>'
271 );
272 $('#step-success .step-progress').html(
273 filled + filled + filled + filled +
274 '<span class="step-count">4/4</span>'
275 );
276 }
277 }
278
279 // Helper function to update dynamic content based on selected industry
280 function updateDynamicContent() {
281 const data = content[selectedIndustry];
282 if (!data) return;
283
284 $('.dynamic-title').text(data.title);
285 $('.dynamic-sub').text(data.sub);
286 $('#use-template-btn').text(data.cta);
287 $('.dynamic-prev-title').text(data.prev);
288 $('.dynamic-success-title').text(data.succ);
289 $('.dynamic-success-sub').text(data.succSub);
290 $('#publish-btn').text(data.publishText);
291 }
292
293 function updateIconPlaceholder() {
294 const data = content[selectedIndustry];
295 if (!data) return;
296
297 $('#template-icon-placeholder').text(data.icon);
298 }
299
300 // Navigation helper function
301 function navigateTo(stepId) {
302 // Show/Hide Steps Container (contains sidebar + content)
303 if (stepId === 'step-welcome') {
304 $('#steps-container').hide();
305 $('#welcome-content').show();
306 $('#onboarding-app').removeClass('sidebar-visible');
307 } else {
308 $('#steps-container').css('display', 'flex');
309 $('#welcome-content').hide();
310 $('#onboarding-app').addClass('sidebar-visible');
311 }
312
313 // Toggle Active Step
314 $('.step').removeClass('active');
315 $('#' + stepId).addClass('active');
316
317 // Update Sidebar Navigation UI
318 $('.nav-item').removeClass('active');
319 $(`.nav-item[data-step="${stepId}"]`).addClass('active');
320
321 // Mark previous steps as completed
322 updateNavProgress(stepId);
323
324 // Trigger celebration effect on success step (only once)
325 if (stepId === 'step-success' && !celebrationTriggered) {
326 // Small delay to ensure step is fully rendered
327 setTimeout(() => {
328 triggerCelebration();
329 }, 300);
330 } else if (stepId !== 'step-success') {
331 // Remove confetti if navigating away from success
332 $('.confetti-container').remove();
333 // Reset celebration trigger if going back (optional - comment out if you want it to only play once ever)
334 // celebrationTriggered = false;
335 }
336 }
337
338 function updateNavProgress(currentId) {
339 // Dynamically build steps array depending on 'industrySkipped' or direct upload path
340 let steps = ['step-vertical', 'step-template', 'step-preview', 'step-success'];
341
342 if (industrySkipped || (uploadedImageUrl && !templateData)) {
343 // Hide the template nav item in the DOM
344 $('.nav-item[data-step="step-template"]').hide();
345 steps = ['step-vertical', 'step-preview', 'step-success'];
346 } else {
347 $('.nav-item[data-step="step-template"]').show();
348 }
349
350 const currentIndex = steps.indexOf(currentId);
351
352 $('.nav-item').each(function () {
353 const stepId = $(this).data('step');
354
355 // Skip checking hidden steps
356 if (steps.indexOf(stepId) === -1) {
357 $(this).removeClass('completed active');
358 return;
359 }
360
361 const stepIndex = steps.indexOf(stepId);
362
363 if (stepId === currentId) {
364 $(this).removeClass('completed').addClass('active');
365 } else {
366 $(this).removeClass('active');
367 if (stepIndex < currentIndex && stepIndex !== -1) {
368 $(this).addClass('completed');
369 } else {
370 $(this).removeClass('completed');
371 }
372 }
373 });
374 }
375
376 // Track if celebration has been triggered to prevent replay
377 let celebrationTriggered = false;
378
379 function triggerCelebration() {
380 // Check if already triggered
381 if (celebrationTriggered) {
382 return;
383 }
384
385 // Check for prefers-reduced-motion
386 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
387 if (prefersReducedMotion) {
388 return;
389 }
390
391 // Mark as triggered
392 celebrationTriggered = true;
393
394 // Remove any existing confetti
395 $('.confetti-container').remove();
396
397 // Create confetti container
398 const confettiContainer = $('<div class="confetti-container"></div>');
399 $('body').append(confettiContainer);
400
401 // Create lightweight confetti particles (fewer particles for performance)
402 const colors = ['#3F04FE', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'];
403 const particleCount = 30; // Reduced from 50 for better performance
404
405 // Get check circle position for burst origin
406 const checkCircle = $('.check-circle');
407 let centerX = window.innerWidth / 2;
408 let centerY = window.innerHeight / 2;
409
410 if (checkCircle.length) {
411 const offset = checkCircle.offset();
412 if (offset) {
413 centerX = offset.left + checkCircle.outerWidth() / 2;
414 centerY = offset.top + checkCircle.outerHeight() / 2;
415 }
416 }
417
418 for (let i = 0; i < particleCount; i++) {
419 const confetti = $('<div class="confetti"></div>');
420 const color = colors[Math.floor(Math.random() * colors.length)];
421
422 // Calculate random direction and distance
423 const angle = (Math.PI * 2 * i) / particleCount + (Math.random() * 0.5);
424 const distance = 100 + Math.random() * 150;
425 const tx = Math.cos(angle) * distance;
426 const ty = Math.sin(angle) * distance;
427 const rotation = 360 + Math.random() * 360;
428
429 confetti.css({
430 'background': color,
431 'left': centerX + 'px',
432 'top': centerY + 'px',
433 '--tx': tx + 'px',
434 '--ty': ty + 'px',
435 '--rot': rotation + 'deg',
436 'width': (6 + Math.random() * 6) + 'px',
437 'height': (6 + Math.random() * 6) + 'px',
438 'border-radius': Math.random() > 0.5 ? '50%' : '0'
439 });
440
441 confettiContainer.append(confetti);
442 }
443
444 // Remove confetti after animation completes (1.2s duration)
445 setTimeout(function () {
446 confettiContainer.remove();
447 }, 1500);
448 }
449
450 // Register onboarding with Linno Onboarding library
451 registerOnboarding({
452 plugin: 'wpvr-onboarding',
453 version: '8.5.54',
454 theme: {
455 color: '#3F04FE'
456 },
457 // WordPress Telemetry Integration
458 telemetry: {
459 onSetupStarted: (data) => {
460 console.log('📊 Telemetry: setup_started', data);
461 },
462 onSetupCompleted: (data) => {
463 console.log('Setup completed event emitted by wizard flow');
464 },
465 onFirstStrikeCompleted: (data) => {
466 console.log('First strike is tracked on first tour publish');
467 }
468 },
469 steps: [
470 {
471 id: 'vertical',
472 title: 'Industry',
473 description: 'What are you creating a virtual tour for?',
474 canGoBack: false,
475 canSkip: false,
476 mount: (container, context) => {
477 navigateTo('step-vertical');
478
479 // Reset global state each time we visit the first step from scratch
480 clearWizardImageWarning();
481 industrySkipped = false;
482 bypassTemplateOnBack = false;
483 updateSkipModeUI(false);
484
485 setTimeout(() => {
486 // Initially disable next button until industry is selected
487 const nextBtn = $('.next-btn[data-next="step-template"]');
488 if (!selectedIndustry) {
489 nextBtn.prop('disabled', true).addClass('disabled');
490 }
491
492 // Select Industry Flow
493 $('.v-card').off('click').on('click', function () {
494 const newIndustry = $(this).data('vertical');
495
496 // Reset all template/upload/hotspot state when the industry changes
497 if (selectedIndustry && selectedIndustry !== newIndustry) {
498 clearWizardImageWarning();
499 uploadedImageUrl = null;
500 uploadedImageFile = null;
501 uploadedImageId = null;
502 templateData = null;
503 hotspots = [];
504 mediaUploader = null;
505 if (panoramaViewer) {
506 try { panoramaViewer.destroy(); } catch (e) { }
507 panoramaViewer = null;
508 }
509 }
510
511 $('.v-card').removeClass('active');
512 $(this).addClass('active');
513 selectedIndustry = newIndustry;
514 // Pre-fetch template in the background here (optional) to make next step faster
515
516 // Auto-proceed to step 2 (Template)
517 context.goNext();
518 });
519
520 // Skip / Upload My Own — triggers wp.media immediately
521 $('#skip-industry-link').off('click').on('click', function (e) {
522 e.preventDefault();
523
524 openWPMediaForDirectUpload(function (attachment) {
525 handleWizardPanoramaSelection(attachment, function (url, id) {
526 if (url) {
527 industrySkipped = true;
528 selectedIndustry = null;
529 uploadedImageUrl = url;
530 uploadedImageId = id;
531 uploadedImageFile = null;
532 templateData = null;
533 hotspots = [];
534 $('.v-card').removeClass('active');
535
536 // Apply 3-step UI before navigating
537 updateSkipModeUI(true);
538
539 // goNext() lands on template mount which will auto-skip forward
540 context.goNext();
541 }
542 });
543 });
544 });
545
546 // Fallback continue btn
547 $('.next-btn[data-next="step-template"]').off('click').on('click', () => {
548 context.goNext();
549 });
550
551 }, 0);
552 }
553 },
554 {
555 id: 'template',
556 title: 'Template',
557 description: 'Choose your template',
558 canGoBack: true,
559 canSkip: false,
560 onNext: (context) => {
561 if (!uploadedImageUrl) {
562 alert('Please upload a 360° image or use a template before proceeding.');
563 return false;
564 }
565 return true;
566 },
567 mount: (container, context) => {
568 // ── Bypass logic ────────────────────────────────────────────
569 // If industry was skipped (forward pass) → jump straight to preview
570 if (industrySkipped) {
571 updateSkipModeUI(true);
572 context.goNext();
573 return;
574 }
575 // If returning backward through a previously-skipped template → go to industry
576 if (bypassTemplateOnBack) {
577 bypassTemplateOnBack = false;
578 updateSkipModeUI(false);
579 context.goBack();
580 return;
581 }
582 // ── Normal template step ─────────────────────────────────
583 navigateTo('step-template');
584 updateDynamicContent();
585 updateIconPlaceholder();
586
587 setTimeout(() => {
588 const $useTemplateBtn = $('#use-template-btn');
589 $useTemplateBtn.removeData('default-text');
590 const defaultUseTemplateText = $useTemplateBtn.text();
591 $useTemplateBtn.data('default-text', defaultUseTemplateText);
592 $useTemplateBtn.prop('disabled', false).removeClass('is-loading');
593
594 // Always show template box side-by-side with upload button
595 $('#template-preview-box').show();
596 $('#upload-section').show();
597
598 // Bind navigation buttons
599 $('.btn-back').off('click').on('click', () => {
600 context.goBack();
601 });
602
603 $('.next-btn[data-next="step-preview"]').not('#use-template-btn').off('click').on('click', () => {
604 context.goNext();
605 });
606
607 // Bind WP media direct trigger — both the zone and the browse link
608 $('#template-upload-zone, #btn-upload-own-image').off('click.tmplUpload').on('click.tmplUpload', function (e) {
609 e.preventDefault();
610 openWPMediaForDirectUpload(function (attachment) {
611 handleWizardPanoramaSelection(attachment, function (url, id) {
612 if (url) {
613 uploadedImageUrl = url;
614 uploadedImageId = id;
615 uploadedImageFile = null;
616 templateData = null;
617 hotspots = [];
618
619 // Jump immediately to step-preview
620 context.goNext();
621 }
622 });
623 });
624 });
625
626 // Use template button handler - call remote API
627 $('#use-template-btn').off('click.template').on('click.template', function (e) {
628 e.preventDefault();
629 clearWizardImageWarning();
630
631 // Show loading state
632 const $btn = $(this);
633 const originalText = $btn.text();
634 $btn.prop('disabled', true).addClass('is-loading').text('Importing template & media...');
635
636 // Call AJAX to fetch template
637 $.ajax({
638 url: ajaxurl,
639 method: 'POST',
640 data: {
641 action: 'wpvr_fetch_template',
642 industry: selectedIndustry,
643 security: wizardNonce
644 },
645 success: function (response) {
646 if (response.success && response.data.template) {
647 templateData = response.data.template;
648
649 if (templateData.post_id) {
650 window.wpvrCreatedTourId = templateData.post_id;
651 window.wpvrCreatedTourUrl = templateData.view_url || '';
652 window.wpvrCreatedTourEditUrl = templateData.edit_url || '';
653 }
654
655 // If template has an image URL, use it
656 hotspots = [];
657 if (templateData.image_url) {
658 uploadedImageUrl = templateData.image_url;
659 } else {
660 // Fallback placeholder
661 uploadedImageUrl = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAwIiBoZWlnaHQ9IjQwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iODAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iI2YzZjRmNiIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMjQiIGZpbGw9IiM2YjcyODAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5UZW1wbGF0ZSBQcmV2aWV3PC90ZXh0Pjwvc3ZnPg==';
662 }
663
664 // Auto proceed to next step
665 setTimeout(() => {
666 context.goNext();
667 }, 500);
668 } else {
669 alert('Failed to load template. Please try again.');
670 $btn.prop('disabled', false).removeClass('is-loading').text(originalText);
671 }
672 },
673 error: function (xhr, status, error) {
674 console.error('Template fetch error:', error);
675 alert('Failed to load template. Please try again.');
676 $btn.prop('disabled', false).removeClass('is-loading').text(originalText);
677 }
678 });
679 });
680 }, 0);
681 }
682 },
683 {
684 id: 'preview',
685 title: 'Preview',
686 description: 'This is how buyers will experience your listing',
687 canGoBack: true,
688 canSkip: false,
689 onNext: (context) => {
690 // Validate that user has either uploaded an image or used a template
691 if (!uploadedImageUrl) {
692 alert('Please upload a 360° image or use a template before proceeding.');
693 return false;
694 }
695 return true;
696 },
697 mount: (container, context) => {
698 navigateTo('step-preview');
699 updateDynamicContent();
700 // Defensive: re-apply skip mode UI in case of any timing issues
701 updateSkipModeUI(industrySkipped);
702
703 setTimeout(() => {
704 // Back button: if user came through skip-mode, go back to industry rather than template
705 $('.btn-back').off('click').on('click', () => {
706 if (industrySkipped) {
707 // Signal template mount to pass straight through backward
708 bypassTemplateOnBack = true;
709 industrySkipped = false;
710 context.goBack(); // lands on template → template auto-calls goBack() to industry
711 } else {
712 context.goBack();
713 }
714 });
715
716 $('#publish-btn').off('click').on('click', function () {
717 const $btn = $(this);
718 const originalText = $btn.text();
719 $btn.prop('disabled', true).text('Publishing...');
720
721 // Build panodata object matching WPVR format
722 // Generate scene ID (8 character random string)
723 const sceneId = generateRandomString(8);
724
725 // Create scene object matching WPVR structure
726 const scene = {
727 'scene-type': 'equirectangular',
728 'scene-id': sceneId,
729 'hotspot-list': [],
730 'dscene': 'off',
731 'scene-attachment-url': uploadedImageUrl
732 };
733
734 // Add hotspots if any (matching WPVR format)
735 if (hotspots.length > 0) {
736 hotspots.forEach(function (hotspot, index) {
737 const hotspotId = generateRandomString(8);
738 scene['hotspot-list'].push({
739 'hotspot-title': hotspotId,
740 'hotspot-pitch': hotspot.pitch,
741 'hotspot-yaw': hotspot.yaw,
742 'hotspot-customclass': '',
743 'hotspot-scene': '',
744 'hotspot-url': '',
745 'hotspot-content': '',
746 'hotspot-hover': '<p>' + (hotspot.text || '') + '</p>',
747 'hotspot-type': hotspot.type || 'info',
748 'hotspot-scene-list': 'none',
749 'wpvr_url_open': {}
750 });
751 });
752 }
753
754 // Build panodata structure matching WPVR format
755 let panodata = {
756 'autoLoad': true,
757 'showControls': true,
758 'customcontrol': '',
759 'genericform': 'off',
760 'genericformshortcode': '',
761 'preview': '',
762 'defaultscene': sceneId, // Set default scene to generated scene ID
763 'scenefadeduration': '',
764 'panodata': {
765 'scene-list': {
766 '1': scene // WPVR uses numeric keys starting from 1
767 }
768 },
769 'previewtext': 'Click To Load Panorama'
770 };
771
772 // If template data exists, merge it with panodata
773 if (templateData && templateData.panodata) {
774 // Merge template settings
775 if (templateData.autoLoad !== undefined) panodata.autoLoad = templateData.autoLoad;
776 if (templateData.showControls !== undefined) panodata.showControls = templateData.showControls;
777 if (templateData.defaultscene) panodata.defaultscene = templateData.defaultscene;
778
779 // Merge template panodata scene-list if exists
780 if (templateData.panodata && templateData.panodata['scene-list']) {
781 // Merge template scenes with our scene
782 Object.assign(panodata.panodata['scene-list'], templateData.panodata['scene-list']);
783 // Ensure our scene is the first one (key 1)
784 panodata.panodata['scene-list']['1'] = scene;
785 panodata.defaultscene = sceneId;
786 }
787 }
788
789 // Create tour via AJAX
790 $.ajax({
791 url: ajaxurl,
792 type: 'POST',
793 data: {
794 action: 'wpvr_create_tour_from_wizard',
795 panodata: JSON.stringify(panodata),
796 templateMeta: templateData && templateData.meta ? JSON.stringify(templateData.meta) : '',
797 existing_post_id: templateData && templateData.post_id ? templateData.post_id : '',
798 title: 'My ' + (
799 selectedIndustry === 'real-estate' ? 'Property' :
800 selectedIndustry === 'hotel' ? 'Hotel' :
801 selectedIndustry === 'school' ? 'Campus' :
802 selectedIndustry === 'automotive' ? 'Automotive' :
803 selectedIndustry === 'ecommerce' ? 'E-commerce' :
804 selectedIndustry === 'exhibitions' ? 'Exhibition' :
805 selectedIndustry === 'offices' ? 'Office' :
806 selectedIndustry === 'training' ? 'Training' :
807 selectedIndustry === 'showrooms' ? 'Showroom' :
808 selectedIndustry === 'tourism' ? 'Tourism' :
809 selectedIndustry === 'fitness' ? 'Fitness' :
810 selectedIndustry === 'museums' ? 'Museum' : 'Virtual'
811 ) + ' Tour',
812 industry: selectedIndustry,
813 security: wizardNonce
814 },
815 success: function (response) {
816 if (response.success) {
817 // Store tour ID and URLs for later use
818 window.wpvrCreatedTourId = response.data.post_id;
819 window.wpvrCreatedTourUrl = response.data.view_url;
820 window.wpvrCreatedTourEditUrl = response.data.edit_url;
821
822 // Proceed to next step
823 context.goNext();
824 } else {
825 alert('Failed to create tour: ' + (response.data.message || 'Unknown error'));
826 $btn.prop('disabled', false).text(originalText);
827 }
828 },
829 error: function (xhr, status, error) {
830 console.error('Tour creation error:', error);
831 alert('Failed to create tour. Please try again.');
832 $btn.prop('disabled', false).text(originalText);
833 }
834 });
835 });
836
837 // Bind skip button
838 $('#skip-preview').off('click').on('click', function (e) {
839 e.preventDefault();
840 skipWizard();
841 });
842
843 // Initialize panorama viewer if image is uploaded
844 if (uploadedImageUrl) {
845 initPanoramaViewer();
846 } else {
847 $('#panorama-container').hide();
848 $('#preview-image-placeholder').show();
849 }
850 renderHotspotList();
851 }, 0);
852 }
853 },
854 {
855 id: 'success',
856 title: 'Publish',
857 description: 'Your property tour is live!',
858 canGoBack: true,
859 canSkip: false,
860 mount: (container, context) => {
861 navigateTo('step-success');
862 updateDynamicContent();
863
864 // Change pro badge color to #01B5FF on last step
865 setTimeout(() => {
866 $('.pro-badge').css({
867 'background': '#01B5FF',
868 'background-color': '#01B5FF'
869 });
870 }, 0);
871
872 // Automatically complete onboarding when user reaches the last step
873 setTimeout(() => {
874 // Complete the final step to trigger onboarding completion and firstStrike
875 context.completeStep();
876 }, 100);
877
878 setTimeout(() => {
879 // Bind Consent Checkbox
880 $('#consent-checkbox').off('change').on('change', function () {
881 const isChecked = $(this).is(':checked');
882 $.ajax({
883 url: ajaxurl,
884 type: 'POST',
885 data: {
886 action: 'wpvr_save_opt_in_toggle',
887 opt_in: isChecked ? 1 : 0,
888 security: wizardNonce
889 },
890 success: function (response) {
891 console.log('Opt-in preference updated:', response);
892 }
893 });
894 });
895 // Fire it initially if checked (as it's default checked in template)
896 if ($('#consent-checkbox').is(':checked')) {
897 $('#consent-checkbox').trigger('change');
898 }
899
900 // Clear localStorage
901 if (typeof Storage !== 'undefined') {
902 localStorage.clear();
903 }
904
905 // Bind back button (restart)
906 $('.btn-back').off('click').on('click', () => {
907 location.reload();
908 });
909
910 // Update button text and show shortcode if tour is created
911 if (window.wpvrCreatedTourId) {
912 // Show shortcode instructions - insert before primary CTA
913 const shortcode = '[wpvr id="' + window.wpvrCreatedTourId + '"]';
914 const shortcodeHtml = '<div class="shortcode-instructions">' +
915 '<h4 class="shortcode-title">Use This Shortcode on Your Site</h4>' +
916 '<p class="shortcode-description">Copy this shortcode and paste it into any page, post, or widget area to display your virtual tour.</p>' +
917 '<div class="shortcode-box">' +
918 '<code id="tour-shortcode" class="shortcode-code">' + shortcode + '</code>' +
919 '<button id="copy-shortcode-btn" class="btn-copy-shortcode">Copy Shortcode</button>' +
920 '</div>' +
921 '</div>';
922
923 // Insert shortcode instructions before primary CTA container
924 if ($('.shortcode-instructions').length === 0) {
925 $('#step-success .primary-cta-container').before(shortcodeHtml);
926 }
927
928 // Copy shortcode functionality
929 $('#copy-shortcode-btn').off('click').on('click', function () {
930 const shortcodeText = $('#tour-shortcode').text();
931
932 // Modern clipboard API
933 if (navigator.clipboard && navigator.clipboard.writeText) {
934 navigator.clipboard.writeText(shortcodeText).then(function () {
935 const $btn = $(this);
936 const originalText = $btn.text();
937 $btn.text('Copied!').css('background', '#10B981');
938 setTimeout(function () {
939 $btn.text(originalText).css('background', '#E8E1FF');
940 }, 2000);
941 }.bind(this));
942 } else {
943 // Fallback for older browsers
944 const tempInput = $('<input>');
945 $('body').append(tempInput);
946 tempInput.val(shortcodeText).select();
947 document.execCommand('copy');
948 tempInput.remove();
949
950 const $btn = $(this);
951 const originalText = $btn.text();
952 $btn.text('Copied!').css('background', '#3F04FE');
953 setTimeout(function () {
954 $btn.text(originalText).css('background', '#E8E1FF');
955 }, 2000);
956 }
957 });
958 }
959
960 // Bind "Edit Your Tour" button - opens WP admin edit page
961 $('#edit-tour-btn').off('click.editTour').on('click.editTour', function (e) {
962 e.preventDefault();
963
964 // Complete onboarding first
965 if (context && typeof context.completeStep === 'function') {
966 context.completeStep();
967 }
968
969 // Navigate to edit tour page
970 if (window.wpvrCreatedTourId) {
971 const editUrl = ajaxurl.replace('admin-ajax.php', 'post.php?post=' + window.wpvrCreatedTourId + '&action=edit');
972 window.location.href = editUrl;
973 } else if (window.wpvrCreatedTourEditUrl) {
974 window.location.href = window.wpvrCreatedTourEditUrl;
975 }
976 });
977
978 // Bind skip button
979 $('#skip-success').off('click').on('click', function (e) {
980 e.preventDefault();
981 skipWizard();
982 });
983
984 // Bind footer "FINISH" button - go to tour listing
985 $('#step-success .btn-primary-outline').off('click.finish').on('click.finish', function (e) {
986 e.preventDefault();
987
988 // Complete onboarding
989 if (context && typeof context.completeStep === 'function') {
990 context.completeStep();
991 }
992
993 // Navigate to tour listing page
994 const listingUrl = ajaxurl.replace('admin-ajax.php', 'edit.php?post_type=wpvr_item');
995 window.location.href = listingUrl;
996 });
997 }, 0);
998 }
999 }
1000 ],
1001 firstStrike: {
1002 label: 'WP VR Onboarding Completed',
1003 verify: () => {
1004 console.log('First Strike verified! Onboarding completed.');
1005 return true;
1006 }
1007 }
1008 });
1009
1010 // Start onboarding
1011 engine.start();
1012
1013 // Listen to step changes and update UI
1014 tracker.on('step_changed', (data) => {
1015 const currentStep = engine.getCurrentStep();
1016 if (currentStep && currentStep.mount) {
1017 const container = document.getElementById('onboarding-app');
1018 if (container) {
1019 currentStep.mount(container, engine.getStepContext());
1020 }
1021 }
1022 });
1023
1024 tracker.on('step_completed', (data) => {
1025 console.log('Step completed:', data);
1026 });
1027
1028 tracker.on('onboarding_completed', (data) => {
1029 console.log('Onboarding completed!', data);
1030 // Celebration is already handled in the success step mount
1031 // Note: Setup completed is now handled in telemetry.onSetupCompleted callback
1032 });
1033
1034 tracker.on('first_strike_verified', (data) => {
1035 console.log('First Strike verified!', data);
1036 // firstStrike verification is complete
1037 });
1038
1039 // Initial render - show welcome step
1040 const currentStep = engine.getCurrentStep();
1041 if (currentStep && currentStep.mount) {
1042 const container = document.getElementById('onboarding-app');
1043 if (container) {
1044 currentStep.mount(container, engine.getStepContext());
1045 }
1046 }
1047
1048 // Initialize Panorama Viewer
1049 function initPanoramaViewer() {
1050 // Hide placeholder, show panorama container
1051 $('#preview-image-placeholder').hide();
1052 $('#panorama-container').show();
1053
1054 // Destroy existing viewer if any
1055 if (panoramaViewer) {
1056 try {
1057 panoramaViewer.destroy();
1058 } catch (e) {
1059 console.log('Viewer already destroyed');
1060 }
1061 }
1062
1063 // Initialize pannellum viewer
1064 const config = {
1065 type: "equirectangular",
1066 panorama: uploadedImageUrl,
1067 autoLoad: true,
1068 showControls: true,
1069 hotSpotDebug: false,
1070 hotSpots: hotspots
1071 };
1072
1073 panoramaViewer = pannellum.viewer('panorama', config);
1074
1075 // Remove existing 'load' listeners to avoid duplicates
1076 panoramaViewer.off('load');
1077
1078 // Use Pannellum's load event instead of setTimeout for reliable handler attachment
1079 panoramaViewer.on('load', function () {
1080 // Get both the container and canvas elements
1081 const panoramaElement = document.getElementById('panorama');
1082 const canvas = document.querySelector('#panorama canvas');
1083
1084 if (!panoramaElement) {
1085 return;
1086 }
1087
1088 // Remove existing listeners from both elements
1089 if (canvas) {
1090 canvas.removeEventListener('mousedown', handlePanoramaMouseDown, true);
1091 canvas.removeEventListener('mousemove', handlePanoramaMouseMove, true);
1092 canvas.removeEventListener('mouseup', handlePanoramaMouseUp, true);
1093 }
1094
1095 panoramaElement.removeEventListener('mousedown', handlePanoramaMouseDown, true);
1096 panoramaElement.removeEventListener('mousemove', handlePanoramaMouseMove, true);
1097 panoramaElement.removeEventListener('mouseup', handlePanoramaMouseUp, true);
1098
1099 // Attach to the panorama container with capture phase
1100 // This will catch events before Pannellum's handlers
1101 panoramaElement.addEventListener('mousedown', handlePanoramaMouseDown, true);
1102 panoramaElement.addEventListener('mousemove', handlePanoramaMouseMove, true);
1103 panoramaElement.addEventListener('mouseup', handlePanoramaMouseUp, true);
1104 });
1105 }
1106
1107 // Handle panorama mousedown - track if it's a click or drag
1108 function handlePanoramaMouseDown(e) {
1109 // Only handle left clicks
1110 if (e.button !== 0) {
1111 return;
1112 }
1113
1114 // Ignore clicks on Pannellum UI control buttons (zoom, fullscreen, etc.)
1115 // But NOT pnlm-dragfix which is the main dragging overlay
1116 if (e.target.closest('.pnlm-zoom-in, .pnlm-zoom-out, .pnlm-fullscreen, .pnlm-fullscreen-toggle-button, .pnlm-load-button, .pnlm-about-msg, .pnlm-compass, .pnlm-orientation')) {
1117 return;
1118 }
1119
1120 // Check if clicking on an existing hotspot
1121 const hotspotElement = e.target.closest('.pnlm-hotspot');
1122 if (hotspotElement) {
1123 return;
1124 }
1125
1126 // Verify panoramaViewer exists and has required methods
1127 if (!panoramaViewer) {
1128 return;
1129 }
1130
1131 // Use Pannellum's built-in projection math to get accurate pitch/yaw
1132 // This handles all edge cases, HFOV variations, and aspect ratios correctly
1133 let clickPitch, clickYaw;
1134
1135 if (typeof panoramaViewer.mouseEventToCoords === 'function') {
1136 const coords = panoramaViewer.mouseEventToCoords(e);
1137 if (coords && coords.length === 2) {
1138 clickPitch = coords[0];
1139 clickYaw = coords[1];
1140 } else {
1141 return;
1142 }
1143 } else {
1144 // Fallback to manual calculation if mouseEventToCoords is not available
1145 if (typeof panoramaViewer.getPitch !== 'function') {
1146 return;
1147 }
1148
1149 const currentPitch = panoramaViewer.getPitch();
1150 const currentYaw = panoramaViewer.getYaw();
1151 const currentHfov = panoramaViewer.getHfov();
1152
1153 const canvas = document.querySelector('#panorama canvas');
1154 if (!canvas) {
1155 return;
1156 }
1157
1158 const rect = canvas.getBoundingClientRect();
1159 const canvasWidth = rect.width;
1160 const canvasHeight = rect.height;
1161
1162 const mouseX = e.clientX - rect.left;
1163 const mouseY = e.clientY - rect.top;
1164 const normalizedX = (mouseX / canvasWidth - 0.5) * 2;
1165 const normalizedY = (mouseY / canvasHeight - 0.5) * 2;
1166
1167 const vfov = currentHfov * (canvasHeight / canvasWidth);
1168 const yawOffset = normalizedX * (currentHfov / 2);
1169 const pitchOffset = -normalizedY * (vfov / 2);
1170
1171 clickPitch = currentPitch + pitchOffset;
1172 clickYaw = currentYaw + yawOffset;
1173 }
1174
1175 // Store mouse down position, time, and calculated coordinates
1176 panoramaMouseDown = {
1177 x: e.clientX,
1178 y: e.clientY,
1179 time: Date.now(),
1180 pitch: clickPitch,
1181 yaw: clickYaw
1182 };
1183 panoramaIsDragging = false;
1184
1185 // Don't prevent default or stop propagation - let Pannellum handle dragging
1186 }
1187
1188 // Handle panorama mousemove - detect if dragging
1189 function handlePanoramaMouseMove(e) {
1190 if (panoramaMouseDown) {
1191 const deltaX = Math.abs(e.clientX - panoramaMouseDown.x);
1192 const deltaY = Math.abs(e.clientY - panoramaMouseDown.y);
1193 const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
1194
1195 // If mouse moved more than 5 pixels, it's a drag
1196 if (distance > 5 && !panoramaIsDragging) {
1197 panoramaIsDragging = true;
1198 }
1199 }
1200 }
1201
1202 // Handle panorama mouseup - only show hotspot if it was a click, not a drag
1203 function handlePanoramaMouseUp(e) {
1204 if (!panoramaMouseDown) return;
1205
1206 // Only handle left clicks
1207 if (e.button !== 0) {
1208 panoramaMouseDown = null;
1209 return;
1210 }
1211
1212 // Ignore clicks on Pannellum UI control buttons (zoom, fullscreen, etc.)
1213 // But NOT pnlm-dragfix which is the main dragging overlay
1214 if (e.target.closest('.pnlm-zoom-in, .pnlm-zoom-out, .pnlm-fullscreen, .pnlm-fullscreen-toggle-button, .pnlm-load-button, .pnlm-about-msg, .pnlm-compass, .pnlm-orientation')) {
1215 panoramaMouseDown = null;
1216 panoramaIsDragging = false;
1217 return;
1218 }
1219
1220 // Check if clicking on an existing hotspot
1221 if (e.target.closest('.pnlm-hotspot')) {
1222 panoramaMouseDown = null;
1223 panoramaIsDragging = false;
1224 return;
1225 }
1226
1227 // Only show hotspot modal if it was a click (not a drag)
1228 if (!panoramaIsDragging) {
1229 const timeDiff = Date.now() - panoramaMouseDown.time;
1230
1231 // Also check time - if mouse was held for less than 300ms, it's likely a click
1232 if (timeDiff < 300) {
1233 // Use the pitch and yaw captured at mousedown (before any potential drag)
1234 const pitch = panoramaMouseDown.pitch;
1235 const yaw = panoramaMouseDown.yaw;
1236
1237 // Store coordinates for hotspot creation
1238 pendingHotspotCoords = { pitch, yaw };
1239
1240 // Enforce free-version hotspot limit before opening modal
1241 if (!isPro && hotspots.length >= HOTSPOT_LIMIT) {
1242 showHotspotLimitNotice();
1243 pendingHotspotCoords = null;
1244 return;
1245 }
1246
1247 // Show hotspot modal
1248 showHotspotModal();
1249 }
1250 }
1251
1252 // Reset tracking
1253 panoramaMouseDown = null;
1254 panoramaIsDragging = false;
1255 }
1256
1257 // Show hotspot modal
1258 function showHotspotModal() {
1259 $('#hotspot-modal').fadeIn(200);
1260 $('#hotspot-text').val('').focus();
1261 }
1262
1263 // Hide hotspot modal
1264 function hideHotspotModal() {
1265 $('#hotspot-modal').fadeOut(200);
1266 $('#hotspot-text').val('');
1267 pendingHotspotCoords = null;
1268 }
1269
1270 // Show the premium upgrade popup when hotspot limit is reached
1271 function showHotspotLimitNotice() {
1272 var $popup = $('#wpvr_premium_feature_popup');
1273 if (!$popup.length) {
1274 return;
1275 }
1276
1277 // Swap in hotspot-limit-specific text
1278 var $heading = $popup.find('.wpvr-premium-feature__heading');
1279 var $subheading = $popup.find('.wpvr-premium-feature__subheading');
1280 $heading.data('original-text', $heading.text());
1281 $subheading.data('original-html', $subheading.html());
1282 $heading.text(wizardStrings.limit_heading || 'Limit Reached');
1283 $subheading.html(
1284 '<p>' + (wizardStrings.limit_line1 || 'You can add up to ' + HOTSPOT_LIMIT + ' hotspots on each scene in the Free version.') + '</p>' +
1285 '<p>' + (wizardStrings.limit_line2 || 'Upgrade to Pro to add an unlimited number of hotspots.') + '</p>'
1286 );
1287
1288 // Apply the discount label CSS custom property
1289 var $label = $popup.find('.wpvr-premium-feature__discount-price-label');
1290 if ($label.length) {
1291 $label[0].style.setProperty('--discount-content-value', '"' + ($label.data('discount') || '') + '"');
1292 }
1293 $popup.show();
1294 }
1295
1296 // Hide the premium upgrade popup and restore original heading text
1297 function hideHotspotLimitNotice() {
1298 var $popup = $('#wpvr_premium_feature_popup');
1299 var $heading = $popup.find('.wpvr-premium-feature__heading');
1300 var $subheading = $popup.find('.wpvr-premium-feature__subheading');
1301 if ($heading.data('original-text')) {
1302 $heading.text($heading.data('original-text'));
1303 }
1304 if ($subheading.data('original-html')) {
1305 $subheading.html($subheading.data('original-html'));
1306 }
1307 $popup.hide();
1308 }
1309
1310 // Save hotspot
1311 function saveHotspot() {
1312 const text = $('#hotspot-text').val().trim();
1313
1314 if (!isPro && hotspots.length >= HOTSPOT_LIMIT) {
1315 hideHotspotModal();
1316 showHotspotLimitNotice();
1317 return;
1318 }
1319
1320 if (!text) {
1321 alert('Please enter hotspot text');
1322 return;
1323 }
1324
1325 if (!pendingHotspotCoords) {
1326 alert('Error: No coordinates available');
1327 return;
1328 }
1329
1330 // Create hotspot object
1331 const hotspot = {
1332 pitch: pendingHotspotCoords.pitch,
1333 yaw: pendingHotspotCoords.yaw,
1334 type: "info",
1335 text: text
1336 };
1337
1338 // Add to hotspots array
1339 hotspots.push(hotspot);
1340
1341 // Reinitialize viewer with updated hotspots
1342 initPanoramaViewer();
1343
1344 // Update hotspot list
1345 renderHotspotList();
1346
1347 // Hide modal
1348 hideHotspotModal();
1349 }
1350
1351 // Render hotspot list below panorama with remove buttons
1352 function renderHotspotList() {
1353 const $list = $('#hotspot-list');
1354 if (!$list.length) return;
1355
1356 $list.empty();
1357
1358 if (hotspots.length === 0) {
1359 return;
1360 }
1361
1362 const $label = $('<p class="hotspot-list-label">Hotspot</p>');
1363 const $items = $('<ul class="hotspot-items"></ul>');
1364 hotspots.forEach(function (hotspot, index) {
1365 const escapedText = $('<span>').text(hotspot.text).html();
1366 const $item = $('<li class="hotspot-item">' +
1367 '<span class="hotspot-item-text">' + escapedText + '</span>' +
1368 '<button class="hotspot-item-remove" data-index="' + index + '" title="Remove hotspot" aria-label="Remove hotspot">&times;</button>' +
1369 '</li>');
1370 $items.append($item);
1371 });
1372 $list.append($label);
1373 $list.append($items);
1374
1375 $list.find('.hotspot-item-remove').off('click').on('click', function () {
1376 const index = parseInt($(this).data('index'), 10);
1377 hotspots.splice(index, 1);
1378 initPanoramaViewer();
1379 renderHotspotList();
1380 });
1381 }
1382
1383 // Bind hotspot modal handlers
1384 $(document).ready(function () {
1385 $('#hotspot-save').off('click').on('click', saveHotspot);
1386 $('#hotspot-cancel, #hotspot-modal-close').off('click').on('click', hideHotspotModal);
1387
1388 // Close the premium popup via the built-in close button
1389 $(document).off('click.limitDismiss').on('click.limitDismiss', '#wpvr_premium_feature_close', function () {
1390 hideHotspotLimitNotice();
1391 });
1392
1393 // Close on clicking outside modal
1394 $('#hotspot-modal').off('click').on('click', function (e) {
1395 if ($(e.target).is('#hotspot-modal')) {
1396 hideHotspotModal();
1397 }
1398 });
1399
1400 // Prevent modal content clicks from closing modal
1401 $('.hotspot-modal-content').off('click').on('click', function (e) {
1402 e.stopPropagation();
1403 });
1404
1405 // Close on Escape key
1406 $(document).off('keydown.hotspot').on('keydown.hotspot', function (e) {
1407 if (e.key === 'Escape' && $('#hotspot-modal').is(':visible')) {
1408 hideHotspotModal();
1409 }
1410 });
1411
1412 // Submit on Enter key
1413 $('#hotspot-text').off('keypress').on('keypress', function (e) {
1414 if (e.which === 13) {
1415 e.preventDefault();
1416 saveHotspot();
1417 }
1418 });
1419 });
1420 });
1421