PluginProbe
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress / 9.0.2
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress v9.0.2
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.0.2, at legacy/admin/js/setup-wizard.js

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