https://donovancarrillo.com/wp-content/plugins/live-event-seating/public/js/seating-plan-selector.js
1(function($) { 2 $(function() { 3 if (typeof seatingPlanData === 'undefined' || $('#seating-plan-app').length === 0) { return; } 4 5 const config = seatingPlanData; 6 const i18n = config.i18n || {}; 7 const svgNS = "http://www.w3.org/2000/svg"; 8 let softLockedSeats = new Set(); 9 let bookedSeatsByParent = new Map(); 10 let gaBookings = {}; 11 let selectedItems = []; 12 let isBooking = false; 13 let currentPriceFilter = null; 14 let currentAccessibilityMap = null; // set when â¿ filter is active 15 16 let view = { 17 x: 0, y: 0, w: 1000, h: 800, 18 isPanning: false, 19 panStart: { x: 0, y: 0 }, 20 isPinching: false, 21 startPinchDist: 0 22 }; 23 24 let fullMapBounds = { x: 0, y: 0, w: 1000, h: 800 }; 25 let activeDetailSectionId = null; 26 let isAnimating = false; 27 let addonModalOpen = false; 28 let currentAddons = []; 29 let addonSelections = {}; 30 let pollingInterval; 31 let renderedSections = new Set(); 32 let tooltipHideTimeout; 33 34 function rebuildBookedSeatIndex() { 35 bookedSeatsByParent = new Map(); 36 Object.keys(config.layout.bookings || {}).forEach(seatId => { 37 const parentId = seatId.split('-')[0]; 38 if (!bookedSeatsByParent.has(parentId)) bookedSeatsByParent.set(parentId, new Set()); 39 bookedSeatsByParent.get(parentId).add(seatId); 40 }); 41 } 42 43 const $svg = $('#seating-map-svg'); 44 const $summaryList = $('#selected-seats-list'); 45 const $totalPrice = $('#total-price'); 46 const $addToCartBtn = $('#add-to-cart-btn'); 47 const $errorMessage = $('#booking-error-message'); 48 const $tooltip = $('#les-seat-tooltip'); 49 const $mapWrapper = $('.seating-map-wrapper'); 50 51 $('head').append('<style>.les-dimmed { opacity: 0.15; filter: grayscale(100%); transition: opacity 0.3s ease; } .les-highlighted { opacity: 1; filter: none; }</style>'); 52 53 function numberToAlpha(num) { 54 let s = '', t; 55 while (num > 0) { t = (num - 1) % 26; s = String.fromCharCode(65 + t) + s; num = (num - 1) / 26 | 0; } 56 return s || undefined; 57 } 58 59 function debounce(func, wait) { 60 let timeout; 61 return function() { 62 const context = this, args = arguments; 63 clearTimeout(timeout); 64 timeout = setTimeout(() => func.apply(context, args), wait); 65 }; 66 } 67 68 function init() { 69 $svg.html(`<text x="50%" y="50%" text-anchor="middle">${i18n.loading || 'Loading...'}</text>`); 70 attachEventListeners(); 71 72 $.ajax({ 73 url: config.ajax_url, type: 'POST', 74 data: { action: 'les_get_locked_seats', nonce: config.nonce, event_id: config.event_id }, 75 success: function(response) { 76 if ( response.success === false && response.data.license_inactive === true ) return; 77 if (response.success) { 78 softLockedSeats = new Set(response.data.locked_seats || []); 79 gaBookings = response.data.ga_bookings || {}; 80 } 81 }, 82 complete: function(xhr) { 83 if (xhr.responseJSON && xhr.responseJSON.success === false && xhr.responseJSON.data.license_inactive === true) return; 84 85 rebuildBookedSeatIndex(); 86 const dynamicView = findDynamicViewBox(config.layout.elements); 87 view.x = dynamicView.x; view.y = dynamicView.y; view.w = dynamicView.width; view.h = dynamicView.height; 88 fullMapBounds = { ...view }; 89 90 renderBaseLayer(); 91 updateVisibleSectors(); 92 initPriceFilter(); 93 initAccessibilityFilter(); 94 updateSummaryAndValidation(); 95 96 if (pollingInterval) clearInterval(pollingInterval); 97 pollingInterval = setInterval(pollForUpdates, 20000); 98 99 // --- GROUP BOOKING INTEGRATION: Announce Map is Ready --- 100 $(document).trigger('les_map_ready', [ config.event_id ]); 101 } 102 }); 103 setupMobileUI(); 104 } 105 106 function setupMobileUI() { 107 const $wrapper = $('.seating-summary-wrapper'); 108 if (!$wrapper.length) return; 109 110 if ($('.les-mobile-cart-toggle').length === 0) { 111 $('body').append('<button type="button" class="les-mobile-cart-toggle" aria-expanded="false" aria-label=""><span class="dashicons dashicons-tickets-alt" aria-hidden="true"></span><span class="les-mobile-cart-count">0</span><span class="les-mobile-cart-total"></span></button>'); 112 } 113 const $toggle = $('.les-mobile-cart-toggle');
114 $toggle.attr('aria-label', i18n.showDetails || 'Show Details'); 115 116 $toggle.off('click.lesMobileSummary').on('click.lesMobileSummary', function() { 117 const isExpanded = !$wrapper.hasClass('expanded'); 118 $wrapper.toggleClass('expanded', isExpanded); 119 $toggle.attr('aria-expanded', isExpanded ? 'true' : 'false'); 120 $toggle.attr('aria-label', isExpanded ? (i18n.hideDetails || 'Hide Details') : (i18n.showDetails || 'Show Details')); 121 }); 122 123 updateMobileCartToggle(); 124 } 125 126 function updateMobileCartToggle() { 127 const $toggle = $('.les-mobile-cart-toggle'); 128 if (!$toggle.length) return; 129 130 const totalSelected = getTotalSelectedSeats(); 131 $toggle.find('.les-mobile-cart-count').text(totalSelected); 132 $toggle.find('.les-mobile-cart-total').text(formatPrice(selectedItems.reduce((total, item) => total + getSelectedItemPrice(item), 0))); 133 $toggle.toggleClass('has-items', totalSelected > 0); 134 } 135 136 function getPinchDistance(touches) { 137 return Math.hypot( 138 touches[0].clientX - touches[1].clientX, 139 touches[0].clientY - touches[1].clientY 140 ); 141 } 142 143 function isElementMatchingFilter(element, filterValue) { 144 if (!filterValue || !element || !element.variation_id) return false; 145 const parts = filterValue.split('_'); 146 const fType = parts[0]; 147 const fVid = parts[1]; 148 149 if (fType === 'table') { 150 const fSeats = parseInt(parts[2], 10); 151 return element.type === 'table' && element.whole_table && String(element.variation_id) === String(fVid) && parseInt(element.seats, 10) === fSeats; 152 } else { 153 return String(element.variation_id) === String(fVid) && !(element.type === 'table' && element.whole_table); 154 } 155 } 156 157 function initPriceFilter() { 158 const filterOptionsMap = new Map(); 159 160 config.layout.elements.forEach(el => { 161 if (!el.variation_id) return; 162 163 const vid = el.variation_id; 164 const variationData = config.variation_prices || {}; 165 const rawData = variationData[vid]; 166 let priceVal = 0; 167 let baseLabel = `Tier ${vid}`; 168 169 if (rawData !== undefined && rawData !== null) { 170 if (typeof rawData === 'object' && rawData.price !== undefined) { 171 priceVal = parseFloat(rawData.price) || 0; 172 if (rawData.name) baseLabel = rawData.name; 173 } else { 174 priceVal = parseFloat(rawData) || 0; 175 } 176 } 177 178 if (el.type === 'table' && el.whole_table) { 179 const seats = parseInt(el.seats, 10) || 0; 180 const totalTablePrice = priceVal * seats; 181 const filterKey = `table_${vid}_${seats}`; 182 if (!filterOptionsMap.has(filterKey)) { 183 filterOptionsMap.set(filterKey, { 184 key: filterKey, 185 price: totalTablePrice, 186 label: `${baseLabel} (Whole Table - ${seats} Seats) - ${formatPrice(totalTablePrice)}` 187 }); 188 } 189 } else { 190 const filterKey = `std_${vid}`; 191 if (!filterOptionsMap.has(filterKey)) { 192 filterOptionsMap.set(filterKey, { 193 key: filterKey, 194 price: priceVal, 195 label: `${baseLabel} - ${formatPrice(priceVal)}` 196 }); 197 } 198 } 199 }); 200 201 if (filterOptionsMap.size === 0) return; 202 203 const optionsArray = Array.from(filterOptionsMap.values()).sort((a, b) => a.price - b.price); 204 205 let filterHtml = `<div class="les-filter-wrapper">`; 206 filterHtml += `<select id="les-price-filter-select" class="les-form-control">`; 207 filterHtml += `<option value="">${i18n.filterByPrice || 'Filter by Price Tier'}</option>`; 208 filterHtml += `<option value="">${i18n.showAll || 'Show All'}</option>`; 209 210 optionsArray.forEach(opt => { 211 filterHtml += `<option value="${opt.key}">${opt.label}</option>`; 212 }); 213 214 filterHtml += `</select></div>`; 215 $('.les-filter-wrapper').remove(); 216 $('.les-view-controls').prepend(filterHtml); 217 218 $('#les-price-filter-select').on('change', function() { 219 const val = $(this).val(); 220 currentPriceFilter = val === "" ? null : val; 221 applyPriceFilter(); 222 }); 223 } 224 225 function applyPriceFilter() { 226 const $elements = $svg.find('g[data-element-id]'); 227 if (currentPriceFilter === null) { 228 $elements.removeClass('les-dimmed les-highlighted');
229 $svg.find('.section-preview-layer g').removeClass('les-dimmed les-highlighted'); 230 } else { 231 $elements.each(function() { 232 const $el = $(this); 233 const elId = $el.attr('data-element-id'); 234 const elementData = config.layout.elements.find(e => e.id === elId); 235 if (!elementData) return; 236 237 if (elementData.type === 'section') { 238 const hasMatchingChild = config.layout.elements.some(child => 239 child.parent_id === elId && isElementMatchingFilter(child, currentPriceFilter) 240 ); 241 if (hasMatchingChild) { 242 $el.removeClass('les-dimmed').addClass('les-highlighted'); 243 const $previewItems = $el.find('.section-preview-layer g'); 244 $previewItems.each(function() { 245 const $pItem = $(this); 246 const pId = $pItem.attr('data-element-id'); 247 const pData = config.layout.elements.find(e => e.id === pId); 248 if (pData && isElementMatchingFilter(pData, currentPriceFilter)) { 249 $pItem.removeClass('les-dimmed').addClass('les-highlighted'); 250 } else { 251 $pItem.removeClass('les-highlighted').addClass('les-dimmed'); 252 } 253 }); 254 } else { 255 $el.removeClass('les-highlighted').addClass('les-dimmed'); 256 } 257 } else { 258 if (isElementMatchingFilter(elementData, currentPriceFilter)) { 259 $el.removeClass('les-dimmed').addClass('les-highlighted'); 260 } else { 261 $el.removeClass('les-highlighted').addClass('les-dimmed'); 262 } 263 } 264 }); 265 } 266 } 267 268 // ââ Accessibility filter ââââââââââââââââââââââââââââââââââââââââââââââ 269 function initAccessibilityFilter() { 270 const acc = (config.accessibility) || {}; 271 if (!acc.enabled) return; 272 273 const hasAny = (config.layout.elements || []).some(el => 274 el.accessible_seats && el.accessible_seats.length > 0 275 ); 276 if (!hasAny) return; 277 278 // Build a fast lookup: seatId â 'wheelchair' | 'companion' 279 const accessibleSeatMap = {}; 280 (config.layout.elements || []).forEach(el => { 281 (el.accessible_seats || []).forEach(a => { 282 accessibleSeatMap[a.id] = a.type || 'wheelchair'; 283 }); 284 }); 285 286 const filterLabel = acc.filterLabel || 'â¿ Show Accessible Seats Only'; 287 const $toggle = $(` 288 <label class="les-accessible-filter-wrap" id="les-accessible-filter-label"> 289 <input type="checkbox" id="les-accessible-filter-cb" /> 290 ${filterLabel} 291 </label> 292 `); 293 $('.les-filter-wrapper').after($toggle); 294 if ($('.les-filter-wrapper').length === 0) { 295 $('.les-view-controls').prepend($toggle); 296 } 297 298 $('#les-accessible-filter-cb').on('change', function () { 299 if (this.checked) { 300 currentAccessibilityMap = accessibleSeatMap; 301 applyAccessibilityFilter(accessibleSeatMap); 302 $('#les-accessible-filter-label').addClass('active'); 303 } else { 304 currentAccessibilityMap = null; 305 clearAccessibilityFilter(); 306 $('#les-accessible-filter-label').removeClass('active'); 307 } 308 }); 309 } 310 311 function applyAccessibilityFilter(accessibleSeatMap) { 312 const svgEl = document.getElementById('seating-map-svg'); 313 if (!svgEl) return; 314 315 const svgNS = 'http://www.w3.org/2000/svg'; 316 317 // ââ Build section â accessible seat info map ââââââââââââââââââ 318 // For each section, count wheelchair + companion seats among its children 319 const sectionAccInfo = {}; // sectionId â { wheelchair: n, companion: n }
320 config.layout.elements.forEach(function(el) { 321 if (!el.parent_id) return; // only child elements 322 (el.accessible_seats || []).forEach(function(a) { 323 const type = a.type || 'wheelchair'; 324 // Walk up: el.parent_id is a section id 325 if (!sectionAccInfo[el.parent_id]) { 326 sectionAccInfo[el.parent_id] = { wheelchair: 0, companion: 0 }; 327 } 328 sectionAccInfo[el.parent_id][type] = (sectionAccInfo[el.parent_id][type] || 0) + 1; 329 }); 330 }); 331 332 // Also handle top-level elements (no section parent) that have accessible seats 333 config.layout.elements.forEach(function(el) { 334 if (el.parent_id) return; 335 (el.accessible_seats || []).forEach(function(a) { 336 const type = a.type || 'wheelchair'; 337 if (!sectionAccInfo['__toplevel__']) { 338 sectionAccInfo['__toplevel__'] = { wheelchair: 0, companion: 0 }; 339 } 340 sectionAccInfo['__toplevel__'][type] = (sectionAccInfo['__toplevel__'][type] || 0) + 1; 341 }); 342 }); 343 344 const isOverview = svgEl.classList.contains('zoom-level-overview'); 345 346 if (isOverview) { 347 // ââ OVERVIEW MODE: operate on section groups ââââââââââââââ 348 const allSectionGroups = svgEl.querySelectorAll('g[id^="section-group-"]'); 349 350 allSectionGroups.forEach(function(sectionG) { 351 const sectionId = sectionG.id.replace('section-group-', ''); 352 const info = sectionAccInfo[sectionId]; 353 354 if (!info || (info.wheelchair + info.companion) === 0) { 355 // No accessible seats â dim the whole section group 356 sectionG.setAttribute('data-acc-prev-opacity', sectionG.style.opacity || ''); 357 sectionG.style.opacity = '0.15'; 358 sectionG.setAttribute('data-acc-dimmed', '1'); 359 } else { 360 // Has accessible seats â highlight boundary + add badge 361 const boundary = sectionG.querySelector('.section-boundary-guide'); 362 if (boundary) { 363 boundary.setAttribute('data-acc-prev-stroke', boundary.style.stroke || ''); 364 boundary.setAttribute('data-acc-prev-sw', boundary.getAttribute('stroke-width') || ''); 365 boundary.style.stroke = '#1a73e8'; 366 boundary.style.strokeWidth = '4px'; 367 boundary.style.filter = 'drop-shadow(0 0 6px rgba(26,115,232,0.7))'; 368 boundary.setAttribute('data-acc-highlighted', '1'); 369 } 370 371 // Build badge text: e.g. "â¿ 4 ð¤ 2" 372 let badgeParts = []; 373 if (info.wheelchair > 0) badgeParts.push('â¿ ' + info.wheelchair); 374 if (info.companion > 0) badgeParts.push('ð¤ ' + info.companion); 375 const badgeText = badgeParts.join(' '); 376 377 // Get section element data for badge positioning 378 const sectionEl = config.layout.elements.find(function(e) { return e.id === sectionId; }); 379 const badgeY = sectionEl ? ((sectionEl.height || 300) / 2) - 20 : 120; 380 381 // Create SVG badge group 382 const badgeG = document.createElementNS(svgNS, 'g'); 383 badgeG.setAttribute('class', 'acc-filter-badge'); 384 badgeG.setAttribute('pointer-events', 'none'); 385 386 // Badge pill background 387 const badgeRect = document.createElementNS(svgNS, 'rect'); 388 badgeRect.setAttribute('x', '-52'); 389 badgeRect.setAttribute('y', String(badgeY - 14)); 390 badgeRect.setAttribute('width', '104'); 391 badgeRect.setAttribute('height', '28'); 392 badgeRect.setAttribute('rx', '14'); 393 badgeRect.setAttribute('fill', '#1a73e8'); 394 badgeRect.setAttribute('fill-opacity', '0.92'); 395 396 // Badge text 397 const badgeTxt = document.createElementNS(svgNS, 'text'); 398 badgeTxt.setAttribute('x', '0'); 399 badgeTxt.setAttribute('y', String(badgeY + 5)); 400 badgeTxt.setAttribute('text-anchor', 'middle'); 401 badgeTxt.setAttribute('dominant-baseline', 'central'); 402 badgeTxt.setAttribute('font-size', '14'); 403 badgeTxt.setAttribute('fill', '#ffffff'); 404 badgeTxt.setAttribute('font-weight', 'bold'); 405 badgeTxt.textContent = badgeText; 406 407 badgeG.appendChild(badgeRect); 408 badgeG.appendChild(badgeTxt); 409 sectionG.appendChild(badgeG); 410 } 411 }); 412 413 } else { 414 // ââ DETAIL MODE: force-render all sections, dim/restore individual seats ââ 415 const sections = config.layout.elements.filter(function(el) { 416 return !el.parent_id && el.type === 'section'; 417 }); 418 sections.forEach(function(section) { 419 if (!renderedSections.has(section.id)) { 420 const seatContainer = document.getElementById('seats-' + section.id); 421 if (seatContainer) { 422 renderSectionChildren(seatContainer, section, section.id === activeDetailSectionId); 423 renderedSections.add(section.id); 424 } 425 } 426 }); 427 428 // Dim all seats, GA areas, tables 429 svgEl.querySelectorAll('circle.seat').forEach(function(c) { 430 c.setAttribute('data-acc-prev-opacity', c.style.opacity || ''); 431 c.style.opacity = '0.1'; 432 }); 433 svgEl.querySelectorAll('text.seat-label').forEach(function(t) { 434 t.setAttribute('data-acc-prev-opacity', t.style.opacity || ''); 435 t.style.opacity = '0.1'; 436 }); 437 svgEl.querySelectorAll('.ga-area-shape, .ga-area-label').forEach(function(el) { 438 el.setAttribute('data-acc-prev-opacity', el.style.opacity || ''); 439 el.style.opacity = '0.1'; 440 }); 441 svgEl.querySelectorAll('.table-shape, .table-label').forEach(function(el) { 442 el.setAttribute('data-acc-prev-opacity', el.style.opacity || ''); 443 el.style.opacity = '0.1'; 444 }); 445 446 // Restore accessible seat circles 447 svgEl.querySelectorAll('circle.seat').forEach(function(circle) { 448 const seatId = circle.getAttribute('data-seat-id'); 449 if (!seatId || !accessibleSeatMap[seatId]) return; 450 circle.style.opacity = '1'; 451 circle.setAttribute('data-acc-prev-stroke', circle.style.stroke || ''); 452 circle.setAttribute('data-acc-prev-sw', circle.style.strokeWidth || ''); 453 const isCompanion = accessibleSeatMap[seatId] === 'companion'; 454 circle.style.stroke = isCompanion ? '#1565c0' : '#0d47a1'; 455 circle.style.strokeWidth = '2.5px'; 456 const parentG = circle.parentNode; 457 if (parentG) { 458 parentG.querySelectorAll('text.seat-label').forEach(function(t) { 459 t.style.opacity = '1'; 460 }); 461 } 462 }); 463 } 464 } 465 466 function clearAccessibilityFilter() { 467 const svgEl = document.getElementById('seating-map-svg'); 468 if (!svgEl) return; 469 470 // Remove all injected badge groups 471 svgEl.querySelectorAll('.acc-filter-badge').forEach(function(el) { 472 el.parentNode && el.parentNode.removeChild(el); 473 }); 474 475 // Restore dimmed section groups 476 svgEl.querySelectorAll('g[data-acc-dimmed]').forEach(function(g) { 477 g.style.opacity = g.getAttribute('data-acc-prev-opacity') || ''; 478 g.removeAttribute('data-acc-prev-opacity'); 479 g.removeAttribute('data-acc-dimmed'); 480 }); 481 482 // Restore highlighted section boundaries 483 svgEl.querySelectorAll('.section-boundary-guide[data-acc-highlighted]').forEach(function(b) { 484 b.style.stroke = b.getAttribute('data-acc-prev-stroke') || ''; 485 b.style.strokeWidth = b.getAttribute('data-acc-prev-sw') || ''; 486 b.style.filter = ''; 487 b.removeAttribute('data-acc-prev-stroke'); 488 b.removeAttribute('data-acc-prev-sw'); 489 b.removeAttribute('data-acc-highlighted'); 490 }); 491 492 // Restore individually dimmed elements (detail mode) 493 svgEl.querySelectorAll('[data-acc-prev-opacity]').forEach(function(el) { 494 el.style.opacity = el.getAttribute('data-acc-prev-opacity') || ''; 495 el.removeAttribute('data-acc-prev-opacity'); 496 }); 497 498 // Restore stroke overrides on accessible seat circles 499 svgEl.querySelectorAll('circle.seat[data-acc-prev-stroke]').forEach(function(circle) { 500 circle.style.stroke = circle.getAttribute('data-acc-prev-stroke') || ''; 501 circle.style.strokeWidth = circle.getAttribute('data-acc-prev-sw') || ''; 502 circle.removeAttribute('data-acc-prev-stroke'); 503 circle.removeAttribute('data-acc-prev-sw'); 504 }); 505 } 506 507 function pollForUpdates() { 508 $.ajax({ 509 url: config.ajax_url, type: 'POST', 510 data: { action: 'les_get_locked_seats', nonce: config.nonce, event_id: config.event_id }, 511 success: function(response) { 512 if (response.success) { 513 const newLocked = response.data.locked_seats || []; 514 const newGa = response.data.ga_bookings || {}; 515 const newBookings = response.data.bookings || {}; 516
517 const lockedChanged = newLocked.length !== softLockedSeats.size; 518 const gaChanged = JSON.stringify(newGa) !== JSON.stringify(gaBookings); 519 const bookingsChanged = JSON.stringify(newBookings) !== JSON.stringify(config.layout.bookings || {}); 520 521 if (lockedChanged || gaChanged || bookingsChanged) { 522 softLockedSeats = new Set(newLocked); 523 gaBookings = newGa; 524 if (bookingsChanged) { 525 config.layout.bookings = newBookings; 526 rebuildBookedSeatIndex(); 527 } 528 refreshSeatStatus(); 529 updateSummaryAndValidation(); 530 } 531 } 532 } 533 }); 534 } 535 536 function renderBaseLayer() { 537 $svg.empty(); 538 renderedSections.clear(); 539 const defs = createSVGElement('defs'); 540 $svg.append(defs); 541 $svg.attr('viewBox', `${view.x} ${view.y} ${view.w} ${view.h}`); 542 const mainLayer = createSVGElement('g', { id: 'main-layer' }); 543 544 // --- Background Image --- 545 const settings = config.layout && config.layout.settings; 546 if (settings && settings.backgroundImage) { 547 const bgW = settings.backgroundImageWidth || settings.canvasWidth || view.w; 548 const bgH = settings.backgroundImageHeight || settings.canvasHeight || view.h; 549 const bgImg = createSVGElement('image', { 550 href: settings.backgroundImage, 551 x: 0, 552 y: 0, 553 width: bgW, 554 height: bgH, 555 preserveAspectRatio: 'xMidYMid meet' 556 }); 557 mainLayer.appendChild(bgImg); 558 } 559 // --- END Background Image --- 560 561 let topLevelElements = config.layout.elements.filter(el => !el.parent_id); 562 topLevelElements.sort((a, b) => { 563 if (a.type === 'section' && b.type !== 'section') return -1; 564 if (a.type !== 'section' && b.type === 'section') return 1; 565 return 0; 566 }); 567 568 topLevelElements.forEach(element => { 569 const secScaleX = element.flipX ? -1 : 1; 570 const secScaleY = element.flipY ? -1 : 1; 571 const group = createSVGElement('g', { 572 transform: `translate(${element.x}, ${element.y}) rotate(${element.rotation || 0}) scale(${secScaleX}, ${secScaleY})`, 573 id: `section-group-${element.id}`, 574 'data-element-id': element.id, 575 'data-variation-id': element.variation_id || '' 576 }); 577 578 if (element.type === 'section') { 579 drawSectionOverview(group, element); 580 } else { 581 drawElement(group, element); 582 } 583 584 if (currentPriceFilter !== null) { 585 if (isElementMatchingFilter(element, currentPriceFilter)) { 586 group.classList.add('les-highlighted'); 587 } else { 588 group.classList.add('les-dimmed'); 589 } 590 } 591 mainLayer.appendChild(group); 592 }); 593 $svg.append(mainLayer); 594 595 if ($('#les-zoom-back-btn').length === 0) { 596 $('.les-view-controls').prepend('<button type="button" class="les-zoom-btn" id="les-zoom-back-btn" title="Reset View" style="display: none;"><span class="dashicons dashicons-arrow-left-alt2"></span></button>'); 597 } 598 if (currentPriceFilter !== null) applyPriceFilter(); 599 if (currentAccessibilityMap !== null) { 600 clearAccessibilityFilter(); 601 applyAccessibilityFilter(currentAccessibilityMap); 602 } 603 } 604 605 606 function drawSectionOverview(group, section) { 607 let boundaryShape; 608 const shapeAttrs = { 'data-section-id': section.id, class: 'section-boundary-guide' }; 609 if (section.shape === 'polygon' && section.points) { 610 const pointsString = section.points.map(p => `${p.x},${p.y}`).join(' '); 611 boundaryShape = createSVGElement('polygon', { points: pointsString, ...shapeAttrs }); 612 } else { 613 const width = section.width || 400; const height = section.height || 300; 614 boundaryShape = createSVGElement('rect', { x: -width / 2, y: -height / 2, width: width, height: height, rx: 5, ...shapeAttrs }); 615 } 616 const secColor = section.color || '#e2e4e7'; 617 boundaryShape.setAttribute('style', `fill: ${secColor}`); 618 group.appendChild(boundaryShape); 619 620 const childElements = config.layout.elements.filter(el => el.parent_id === section.id); 621 if (childElements.length > 0) { 622 const previewGroup = createSVGElement('g', { class: 'section-preview-layer', 'pointer-events': 'none' });
623 childElements.forEach(child => { 624 const visibleTypes = ['line', 'table', 'stage', 'image', 'text', 'ga_area', 'polygon', 'seat_block']; 625 if(visibleTypes.includes(child.type)) { 626 const childScaleX = child.flipX ? -1 : 1; 627 const childScaleY = child.flipY ? -1 : 1; 628 const childG = createSVGElement('g', { 629 transform: `translate(${child.x}, ${child.y}) rotate(${child.rotation || 0}) scale(${childScaleX}, ${childScaleY})`, 630 'data-element-id': child.id, 631 'data-variation-id': child.variation_id || '' 632 }); 633 634 if(child.type === 'line') drawLineElement(childG, child); 635 else if (child.type === 'text') drawTextElement(childG, child); 636 else if (child.type === 'image') drawImageElement(childG, child); 637 else if (child.type === 'stage') drawStage(childG, child); 638 else if (child.type === 'ga_area') drawGAArea(childG, child); 639 else if (child.type === 'polygon') drawPolygonShape(childG, child); 640 else if (child.type === 'seat_block') { 641 const rows = child.rows || 5; 642 const seatsPerRow = child.seats_per_row || 10; 643 const blockHeight = child.height || 100; 644 const blockWidth = child.width || 200; 645 const rowSpacing = blockHeight / rows; 646 const colorAvailable = child.seatColor || child.color || '#4CAF50'; 647 const colorBlocked = config.color_booked || '#BDBDBD'; 648 const STROKE_WIDTH = 2; 649 const rowSkewX = child.row_skew || 0; 650 const rowSkewY = child.row_skew_y || 0; 651 652 for(let r=1; r<=rows; r++) { 653 const rowY = (-blockHeight / 2) + (r * rowSpacing) - (rowSpacing / 2); 654 let visualRowLabel = r; 655 if (child.row_label_style === 'alpha_upper') { visualRowLabel = numberToAlpha(r); } 656 visualRowLabel = (child.row_label_prefix || '') + visualRowLabel; 657 const skewOffsetX = (r - 1) * rowSkewX; 658 let currentPoints = []; 659 let currentType = null; 660 661 const flushSegment = () => { 662 if (currentPoints.length === 0) return; 663 const strokeColor = (currentType === 'blocked') ? colorBlocked : colorAvailable; 664 if (currentPoints.length === 1) { 665 childG.appendChild(createSVGElement('circle', { cx: currentPoints[0].x, cy: currentPoints[0].y, r: 2, fill: strokeColor })); 666 } else { 667 const d = currentPoints.map((p, i) => (i === 0 ? 'M' : 'L') + `${p.x},${p.y}`).join(' '); 668 childG.appendChild(createSVGElement('path', { d: d, stroke: strokeColor, 'stroke-width': STROKE_WIDTH, fill: 'none', 'vector-effect': 'non-scaling-stroke' })); 669 } 670 return currentPoints[currentPoints.length - 1]; 671 }; 672 673 const curve = child.curve || 0; 674 const seatSpacing = blockWidth / seatsPerRow; 675 const absCurve = Math.abs(curve); 676 const theta = absCurve * (Math.PI / 180); 677 const radius = (blockWidth / 2) / Math.sin(theta / 2); 678 const startAngle = -theta / 2; 679 const angleStep = seatsPerRow > 1 ? theta / (seatsPerRow - 1) : 0; 680 681 for(let s=1; s<=seatsPerRow; s++) { 682 const seatId = `${child.id}-R${visualRowLabel}-S${s}`; 683 if (child.killed_seats && child.killed_seats.includes(seatId)) { 684 flushSegment(); currentPoints = []; currentType = null; continue; 685 } 686 const isBlocked = (child.unavailable_seats && child.unavailable_seats.includes(seatId)) || child.status === 'booked'; 687 const thisType = isBlocked ? 'blocked' : 'available'; 688 const skewOffsetY = (s - 1) * rowSkewY; 689 let sx, sy; 690 if(absCurve < 1) { 691 sx = (-blockWidth / 2) + (s * seatSpacing) - (seatSpacing / 2); sy = rowY; 692 } else { 693 const currentAngle = startAngle + (s - 1) * angleStep;
694 sx = radius * Math.sin(currentAngle); 695 const bulge = radius * (Math.cos(currentAngle) - Math.cos(theta / 2)); 696 if (curve > 0) sy = rowY - bulge; else sy = rowY + bulge; 697 } 698 const newPoint = { x: sx + skewOffsetX, y: sy + skewOffsetY }; 699 if (currentType !== null && currentType !== thisType) { 700 const lastPoint = flushSegment(); 701 currentPoints = [lastPoint, newPoint]; 702 } else { 703 currentPoints.push(newPoint); 704 } 705 currentType = thisType; 706 } 707 flushSegment(); 708 } 709 } 710 else if (child.type === 'table') { 711 const tShapeAttrs = { style: 'fill: none; stroke: #888; stroke-width: 1;' }; 712 if (child.shape === 'circle') { 713 childG.appendChild(createSVGElement('circle', { r: child.radius || 60, ...tShapeAttrs })); 714 } else { 715 const w = child.width||180, h = child.height||70; 716 childG.appendChild(createSVGElement('rect', { x: -w/2, y: -h/2, width: w, height: h, ...tShapeAttrs })); 717 } 718 } 719 previewGroup.appendChild(childG); 720 } 721 }); 722 group.appendChild(previewGroup); 723 } 724 725 const secScaleX = section.flipX ? -1 : 1; 726 const secScaleY = section.flipY ? -1 : 1; 727 const labelAttrs = { class: 'section-label', 'pointer-events': 'none', 'text-anchor': 'middle', 'dominant-baseline': 'central' }; 728 if (section.textColor) labelAttrs.fill = section.textColor; 729 const label = createSVGElement('text', { ...labelAttrs, transform: `scale(${secScaleX}, ${secScaleY})` }); 730 label.textContent = section.label; 731 group.appendChild(label); 732 const seatLayer = createSVGElement('g', { class: 'section-seats-layer', id: `seats-${section.id}` }); 733 group.appendChild(seatLayer); 734 } 735 736 function updateVisibleSectors() { 737 const isSingleLayout = config.layout.settings.mode === 'single'; 738 const detailThreshold = fullMapBounds.w * 0.3; 739 const isFocusedDetailMode = activeDetailSectionId && view.w < (fullMapBounds.w * 0.95); 740 const isDetailMode = isSingleLayout || isFocusedDetailMode || (view.w < detailThreshold); 741 742 if (!isDetailMode) { 743 if (!$svg.hasClass('zoom-level-overview')) { 744 $svg.addClass('zoom-level-overview').removeClass('zoom-level-detail'); 745 $('#les-zoom-back-btn').fadeOut(); 746 $('.section-seats-layer').empty(); 747 renderedSections.clear(); 748 } 749 return; 750 } else { 751 if (!$svg.hasClass('zoom-level-detail')) { 752 $svg.addClass('zoom-level-detail').removeClass('zoom-level-overview'); 753 if (!isSingleLayout) { 754 $('#les-zoom-back-btn').fadeIn(); 755 } else { 756 $('#les-zoom-back-btn').hide(); 757 } 758 } 759 } 760 761 const topLevelElements = config.layout.elements.filter(el => !el.parent_id && el.type === 'section'); 762 763 topLevelElements.forEach(section => { 764 const seatContainer = document.getElementById(`seats-${section.id}`); 765 if (!seatContainer) return; 766 767 if (!renderedSections.has(section.id)) { 768 renderSectionChildren(seatContainer, section, section.id === activeDetailSectionId); 769 renderedSections.add(section.id); 770 } 771 }); 772 updateSelectionVisuals(); 773 if (currentPriceFilter !== null) applyPriceFilter(); 774 if (currentAccessibilityMap !== null) { 775 clearAccessibilityFilter(); 776 applyAccessibilityFilter(currentAccessibilityMap); 777 } 778 } 779
780 function renderSectionChildren(container, section, includeDecorativeElements = true) { 781 const childElements = config.layout.elements.filter(el => el.parent_id === section.id); 782 const frag = document.createDocumentFragment(); 783 childElements.forEach(child => { 784 if (!includeDecorativeElements && !['table', 'seat_block', 'ga_area'].includes(child.type)) return; 785 const childScaleX = child.flipX ? -1 : 1; const childScaleY = child.flipY ? -1 : 1; 786 const childGroup = createSVGElement('g', { 787 transform: `translate(${child.x}, ${child.y}) rotate(${child.rotation || 0}) scale(${childScaleX}, ${childScaleY})`, 788 'data-element-id': child.id, 789 'data-variation-id': child.variation_id || '' 790 }); 791 drawElement(childGroup, child); 792 793 if (currentPriceFilter !== null) { 794 if (isElementMatchingFilter(child, currentPriceFilter)) { 795 childGroup.classList.add('les-highlighted'); 796 } else { 797 childGroup.classList.add('les-dimmed'); 798 } 799 } 800 801 frag.appendChild(childGroup); 802 }); 803 container.appendChild(frag); 804 } 805 806 function refreshSeatStatus() { 807 // Update individual seats 808 $svg.find('.seat').each(function() { 809 const $el = $(this); 810 const seatId = $el.data('seat-id'); 811 const parentId = $el.data('parent-id'); 812 const parentElement = config.layout.elements.find(el => el.id === parentId); 813 const isManuallyBooked = parentElement && parentElement.status === 'booked'; 814 const isPermanentlyBooked = config.layout.bookings && Boolean(config.layout.bookings[seatId]); 815 const isSoftLocked = softLockedSeats.has(seatId); 816 const isUnavailable = parentElement && parentElement.unavailable_seats && parentElement.unavailable_seats.includes(seatId); 817 const isBooked = isManuallyBooked || isPermanentlyBooked || isSoftLocked || isUnavailable; 818 819 const accessEntry = parentElement ? (parentElement.accessible_seats || []).find(a => a.id === seatId) : null; 820 let seatClass = isBooked ? 'seat booked' : 'seat available'; 821 if (accessEntry) { 822 seatClass += (accessEntry.type === 'companion') ? ' accessible-companion' : ' accessible-wheelchair'; 823 } 824 if ($el.hasClass('selected')) { 825 seatClass += ' selected'; 826 } 827 $el.attr('class', seatClass); 828 829 if (isBooked) { 830 $el.removeAttr('style'); 831 } else if (accessEntry) { 832 $el.attr('style', (accessEntry.type === 'companion') ? 'fill: #64b5f6' : 'fill: #1a73e8'); 833 } else if (parentElement && parentElement.seatColor) { 834 $el.attr('style', `fill: ${parentElement.seatColor}`); 835 } else { 836 $el.removeAttr('style'); 837 } 838 }); 839 840 // Update whole tables 841 $svg.find('.whole-table-booking').each(function() { 842 const $group = $(this); 843 const tableId = $group.data('element-id') || $group.find('.table-shape').data('table-id'); 844 const table = config.layout.elements.find(el => el.id === tableId); 845 if (!table || !table.whole_table) return; 846 const firstSeatId = `${table.id}-S1`; 847 const isManuallyBooked = table.status === 'booked'; 848 const isPermanentlyBooked = config.layout.bookings && (config.layout.bookings[firstSeatId] || config.layout.bookings[table.id]); 849 const isSoftLocked = softLockedSeats.has(firstSeatId) || softLockedSeats.has(table.id); 850 const isBooked = isManuallyBooked || isPermanentlyBooked || isSoftLocked; 851 const $shape = $group.find('.table-shape'); 852 if (isBooked) { 853 $group.addClass('booked').removeClass('available'); 854 $shape.addClass('booked').removeClass('available'); 855 } else { 856 $group.removeClass('booked').addClass('available'); 857 $shape.removeClass('booked').addClass('available'); 858 } 859 }); 860 861 // Update GA areas
862 $svg.find('.ga-area-shape').each(function() { 863 const $shape = $(this); 864 const areaId = $shape.data('area-id'); 865 const area = config.layout.elements.find(el => el.id === areaId); 866 if (!area) return; 867 const sold = gaBookings[area.id] || 0; 868 const remaining = area.capacity - sold; 869 const isSoldOut = remaining <= 0; 870 const isManuallyBooked = area.status === 'booked'; 871 const isBooked = isSoldOut || isManuallyBooked; 872 if (isBooked) { 873 $shape.addClass('booked').removeClass('available'); 874 } else { 875 $shape.removeClass('booked').addClass('available'); 876 } 877 const $group = $shape.closest('g'); 878 const $capText = $group.find('.ga-capacity-text'); 879 if ($capText.length) { 880 if (isManuallyBooked) { 881 $capText.text(i18n.unavailable || 'Unavailable'); 882 } else if (isSoldOut) {
883 $capText.text(i18n.soldOut || 'Sold Out'); 884 } else { 885 $capText.text((i18n.gaAvailable || '%1$d / %2$d Available').replace('%1$d', remaining).replace('%2$d', area.capacity)); 886 } 887 } 888 }); 889 } 890 891 function handleSectionClick(e) { 892 if (!$svg.hasClass('zoom-level-overview')) return; 893 handleTooltipHide(); 894 const sectionId = $(e.currentTarget).data('section-id'); 895 const sectionElement = config.layout.elements.find(el => el.id === sectionId); 896 if (!sectionElement) return; 897 898 const childElements = config.layout.elements.filter(el => el.parent_id === sectionId); 899 if (childElements.length === 0) return; 900 901 activeDetailSectionId = sectionId; 902 903 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 904 childElements.forEach(el => { 905 const sectionOffsetX = parseFloat(sectionElement.x) || 0; 906 const sectionOffsetY = parseFloat(sectionElement.y) || 0; 907 const localX = (parseFloat(el.x) || 0) + sectionOffsetX; 908 const localY = (parseFloat(el.y) || 0) + sectionOffsetY; 909 const w = parseFloat(el.width) || 0; 910 const h = parseFloat(el.height) || 0; 911 const r = (parseFloat(el.radius) || 0) * 2; 912 const halfSize = Math.max(w, h, r) / 2; 913 914 minX = Math.min(minX, localX - halfSize); 915 minY = Math.min(minY, localY - halfSize); 916 maxX = Math.max(maxX, localX + halfSize); 917 maxY = Math.max(maxY, localY + halfSize); 918 }); 919 920 const buffer = 40; 921 const contentW = Math.max(maxX - minX, 1); 922 const contentH = Math.max(maxY - minY, 1); 923 const maxDetailW = Math.max(fullMapBounds.w * 0.28, 1); 924 const maxDetailH = Math.max(fullMapBounds.h * 0.28, 1); 925 926 let targetW = Math.min(contentW + (buffer * 2), maxDetailW); 927 let targetH = Math.min(contentH + (buffer * 2), maxDetailH); 928 let targetX = minX - buffer; 929 let targetY = minY - buffer; 930 931 const svgWidth = $svg.width(); 932 const svgHeight = $svg.height(); 933 if (svgWidth > 0 && svgHeight > 0) { 934 const containerAspect = svgWidth / svgHeight; 935 const targetAspect = targetW / targetH; 936 if (targetAspect > containerAspect) { 937 const oldH = targetH; 938 targetH = targetW / containerAspect; 939 const centerY = targetY + oldH / 2; 940 targetY = centerY - targetH / 2; 941 } else { 942 const oldW = targetW; 943 targetW = targetH * containerAspect; 944 const centerX = targetX + oldW / 2; 945 targetX = centerX - targetW / 2; 946 } 947 } 948 949 const centerX = (minX + maxX) / 2; 950 const centerY = (minY + maxY) / 2; 951 targetX = centerX - (targetW / 2); 952 targetY = centerY - (targetH / 2); 953 954 animateViewBox(targetX, targetY, targetW, targetH, 600, function() { 955 updateVisibleSectors(); 956 }); 957 } 958 959 function handlePanMove(e) { 960 const type = e.type; 961 const isTouch = type === 'touchmove'; 962 963 if (e.cancelable) e.preventDefault(); 964 965 if (isTouch && e.touches.length === 2 && view.isPinching) { 966 const newDist = getPinchDistance(e.touches); 967 if (view.startPinchDist > 0) { 968 const zoomFactor = newDist / view.startPinchDist; 969 applyZoom(zoomFactor); 970 view.startPinchDist = newDist; 971 requestAnimationFrame(debouncedUpdateSectors); 972 } 973 return; 974 } 975 976 if (!view.isPanning) return; 977 978 let clientX, clientY; 979 if (isTouch) { 980 clientX = e.touches[0].clientX; 981 clientY = e.touches[0].clientY; 982 } else { 983 clientX = e.clientX; 984 clientY = e.clientY; 985 } 986 987 const dx = clientX - view.panStart.x; 988 const dy = clientY - view.panStart.y; 989 990 const svgDeltaX = dx * (view.w / $svg.width()); 991 const svgDeltaY = dy * (view.h / $svg.height()); 992 993 view.x -= svgDeltaX; 994 view.y -= svgDeltaY; 995 996 view.panStart.x = clientX; 997 view.panStart.y = clientY; 998 999 requestAnimationFrame(() => { 1000 $svg.attr('viewBox', `${view.x} ${view.y} ${view.w} ${view.h}`); 1001 }); 1002 debouncedUpdateSectors(); 1003 } 1004 1005 const debouncedUpdateSectors = debounce(updateVisibleSectors, 100); 1006 1007 function handleZoomReset() { 1008 activeDetailSectionId = null; 1009 animateViewBox(fullMapBounds.x, fullMapBounds.y, fullMapBounds.w, fullMapBounds.h, 500, function() { 1010 updateVisibleSectors(); 1011 }); 1012 } 1013 1014 function drawElement(group, element) { 1015 switch(element.type) { 1016 case 'table': drawTableAndSeats(group, element); break; 1017 case 'seat_block': drawSeatBlockDetail(group, element); break; 1018 case 'ga_area': drawGAArea(group, element); break; 1019 case 'stage': drawStage(group, element); break; 1020 case 'image': drawImageElement(group, element); break; 1021 case 'polygon': drawPolygonShape(group, element); break; 1022 case 'text': drawTextElement(group, element); break; 1023 case 'line': drawLineElement(group, element); break; 1024 } 1025 } 1026 1027 function drawLineElement(group, element) { 1028 const w = element.width || 100; 1029 const strokeWidth = element.strokeWidth || 2; 1030 const color = element.color || '#000000'; 1031 group.appendChild(createSVGElement('line', { x1: -w / 2, y1: 0, x2: w / 2, y2: 0, stroke: color, 'stroke-width': strokeWidth, 'pointer-events': 'none' })); 1032 } 1033 1034 function drawTextElement(group, element) { const labelAttrs = { x: 0, y: 0, class: 'static-text-element', 'pointer-events': 'none', 'text-anchor': 'middle', 'dominant-baseline': 'central' }; if (element.textColor) labelAttrs.fill = element.textColor; if (element.fontSize) labelAttrs['font-size'] = `${element.fontSize}px`; const label = createSVGElement('text', labelAttrs); label.textContent = element.label; group.appendChild(label); } 1035 1036 function drawSeatBlockDetail(group, block) { 1037 const blockWidth = block.width || 200, blockHeight = block.height || 100; 1038 const rows = block.rows || 5, seatsPerRow = block.seats_per_row || 10; 1039 const seatSize = 8; 1040 const rowSpacing = blockHeight / rows; 1041 const curve = block.curve || 0; 1042 const seatLabelStyle = block.seat_label_style || 'numeric'; 1043 const startAt = block.seat_start_at || 1; 1044 const rowLabelStyle = block.row_label_style || 'numeric'; 1045 const rowStartAt = block.row_start_at || 1; 1046 const rowLabelPrefix = block.row_label_prefix || ''; 1047 const rowLabelPosition = block.row_label_position || 'left'; 1048 const rowSkewX = block.row_skew || 0; 1049 const rowSkewY = block.row_skew_y || 0; 1050 1051 let parentFlipX = 1; let parentFlipY = 1; 1052 if (block.parent_id) { 1053 const parent = config.layout.elements.find(el => el.id === block.parent_id); 1054 if (parent) { if (parent.flipX) parentFlipX = -1; if (parent.flipY) parentFlipY = -1; } 1055 } 1056 const tsX = parentFlipX * (block.flipX ? -1 : 1); 1057 const tsY = parentFlipY * (block.flipY ? -1 : 1); 1058 const getTextTransform = (x, y) => `translate(${x}, ${y}) scale(${tsX}, ${tsY})`; 1059 1060 const labelAttrs = { class: 'table-label', 'pointer-events': 'none' }; 1061 if (block.textColor) labelAttrs.fill = block.textColor; 1062 if (block.fontSize) labelAttrs['font-size'] = `${block.fontSize}px`; 1063 const position = block.label_position || 'top'; 1064 let labelX = 0, labelY = 0; 1065 let anchor = 'middle'; 1066 switch (position) { 1067 case 'bottom': labelY = (blockHeight / 2) + 20; break; 1068 case 'left': anchor = 'end'; labelX = (-blockWidth / 2) - 10; break; 1069 case 'right': anchor = 'start'; labelX = (blockWidth / 2) + 10; break; 1070 default: labelY = (-blockHeight / 2) - 10; 1071 } 1072 const labelAttrsWithTitle = { ...labelAttrs, 'text-anchor': anchor, transform: getTextTransform(labelX, labelY), title: block.label }; 1073 if (block.label_hidden) { 1074 labelAttrsWithTitle.display = 'none'; 1075 } 1076 const label = createSVGElement('text', labelAttrsWithTitle); 1077 label.textContent = block.label; 1078 group.appendChild(label); 1079 1080 for (let r = 1; r <= rows; r++) { 1081 const rowY = (-blockHeight / 2) + (r * rowSpacing) - (rowSpacing / 2); 1082 let currentRowVal = rowStartAt + (r - 1); 1083 let visualRowLabel = currentRowVal; 1084 if (rowLabelStyle === 'alpha_upper') { visualRowLabel = numberToAlpha(currentRowVal); } 1085 visualRowLabel = rowLabelPrefix + visualRowLabel; 1086 const skewOffsetX = (r - 1) * rowSkewX; 1087 1088 let sideLabelY = rowY; 1089 if (Math.abs(curve) >= 1) { 1090 const theta = Math.abs(curve) * (Math.PI / 180); 1091 const radius = (blockWidth / 2) / Math.sin(theta / 2); 1092 const startAngle = -theta / 2; 1093 const bulge = radius * (Math.cos(startAngle) - Math.cos(theta / 2)); 1094 if (curve > 0) sideLabelY = rowY - bulge; else sideLabelY = rowY + bulge; 1095 } 1096 let sideLabelX = (-blockWidth / 2) + skewOffsetX - 25; 1097 let rowLabelAnchor = 'end'; 1098 if (rowLabelPosition === 'right') { 1099 sideLabelX = (blockWidth / 2) + skewOffsetX + 25; 1100 rowLabelAnchor = 'start'; 1101 } else if (rowLabelPosition === 'top') { 1102 sideLabelX = skewOffsetX; 1103 sideLabelY -= 15; 1104 rowLabelAnchor = 'middle'; 1105 } else if (rowLabelPosition === 'bottom') { 1106 sideLabelX = skewOffsetX; 1107 sideLabelY += 15; 1108 rowLabelAnchor = 'middle'; 1109 } 1110 const rowLabelText = createSVGElement('text', { class: 'row-name-label', 'text-anchor': rowLabelAnchor, 'dominant-baseline': 'central', 'font-size': '12px', fill: '#888', 'pointer-events': 'none', transform: `translate(${sideLabelX}, ${sideLabelY}) scale(${tsX}, ${tsY})` }); 1111 if (block.row_label_hidden) rowLabelText.setAttribute('display', 'none'); 1112 rowLabelText.textContent = visualRowLabel; 1113 group.appendChild(rowLabelText); 1114 1115 if (Math.abs(curve) < 1) { 1116 const seatSpacing = blockWidth / seatsPerRow; 1117 for (let s = 1; s <= seatsPerRow; s++) { 1118 const seatX = (-blockWidth / 2) + (s * seatSpacing) - (seatSpacing / 2); 1119 const seatId = `${block.id}-R${visualRowLabel}-S${s}`; 1120 if (block.killed_seats && block.killed_seats.includes(seatId)) continue; 1121 let visualSeatLabel = startAt + (s - 1); 1122 if (seatLabelStyle === 'alpha_upper') { visualSeatLabel = numberToAlpha(startAt + (s - 1)); } 1123 const skewOffsetY = (s - 1) * rowSkewY; 1124 const finalX = seatX + skewOffsetX; 1125 const finalY = rowY + skewOffsetY; 1126 const seat = createSeatElement(seatId, finalX, finalY, block, seatSize, visualSeatLabel); 1127 group.appendChild(seat); 1128 const sl = createSVGElement('text', { class: 'seat-label', 'text-anchor': 'middle', 'dominant-baseline': 'central', dy: '0.09em', transform: getTextTransform(finalX, finalY) }); 1129 sl.textContent = visualSeatLabel; 1130 group.appendChild(sl); 1131 } 1132 } else { 1133 const absCurve = Math.abs(curve); 1134 const theta = absCurve * (Math.PI / 180); 1135 const radius = (blockWidth / 2) / Math.sin(theta / 2); 1136 const startAngle = -theta / 2; 1137 const angleStep = seatsPerRow > 1 ? theta / (seatsPerRow - 1) : 0; 1138 for (let s = 1; s <= seatsPerRow; s++) { 1139 const currentAngle = startAngle + (s - 1) * angleStep;
1140 const seatX = radius * Math.sin(currentAngle); 1141 const bulge = radius * (Math.cos(currentAngle) - Math.cos(theta / 2)); 1142 let seatY, seatRotation; 1143 if (curve > 0) { seatY = rowY - bulge; seatRotation = currentAngle * (180 / Math.PI); } 1144 else { seatY = rowY + bulge; seatRotation = -currentAngle * (180 / Math.PI); } 1145 const seatId = `${block.id}-R${visualRowLabel}-S${s}`; 1146 if (block.killed_seats && block.killed_seats.includes(seatId)) continue; 1147 let visualSeatLabel = startAt + (s - 1); 1148 if (seatLabelStyle === 'alpha_upper') { visualSeatLabel = numberToAlpha(startAt + (s - 1)); } 1149 const skewOffsetY = (s - 1) * rowSkewY; 1150 const finalX = seatX + skewOffsetX; 1151 const finalY = seatY + skewOffsetY; 1152 const seat = createSeatElement(seatId, 0, 0, block, seatSize, visualSeatLabel); 1153 seat.setAttribute('transform', `translate(${finalX}, ${finalY}) rotate(${seatRotation})`); 1154 group.appendChild(seat); 1155 const sl = createSVGElement('text', { class: 'seat-label', 'text-anchor': 'middle', 'dominant-baseline': 'central', dy: '0.09em', transform: `translate(${finalX}, ${finalY}) rotate(${seatRotation}) scale(${tsX}, ${tsY})` }); 1156 sl.textContent = visualSeatLabel; 1157 group.appendChild(sl); 1158 } 1159 } 1160 } 1161 } 1162 function drawTableAndSeats(group, table) { 1163 let tableShape, label; 1164 const tableShapeAttrs = { 'data-table-id': table.id, class: 'table-shape' }; 1165 if (table.color) tableShapeAttrs.style = `fill: ${table.color}`; 1166 if (table.whole_table) group.classList.add('whole-table-booking'); 1167 const seatLabelStyle = table.seat_label_style || 'numeric'; 1168 const startAt = table.seat_start_at || 1; 1169 1170 let parentFlipX = 1; let parentFlipY = 1; 1171 if (table.parent_id) { 1172 const parent = config.layout.elements.find(el => el.id === table.parent_id); 1173 if (parent) { if (parent.flipX) parentFlipX = -1; if (parent.flipY) parentFlipY = -1; } 1174 } 1175 const tsX = parentFlipX * (table.flipX ? -1 : 1); 1176 const tsY = parentFlipY * (table.flipY ? -1 : 1); 1177 const getTextTransform = (x, y) => `translate(${x}, ${y}) scale(${tsX}, ${tsY})`; 1178 1179 if (table.shape === 'circle') { 1180 const radius = table.radius || 60; 1181 tableShape = createSVGElement('circle', { cx: 0, cy: 0, r: radius, ...tableShapeAttrs }); 1182 } else { 1183 const tableWidth = table.width || 180, tableHeight = table.height || 70; 1184 tableShape = createSVGElement('rect', { x: -tableWidth / 2, y: -tableHeight / 2, width: tableWidth, height: tableHeight, rx: 5, ...tableShapeAttrs }); 1185 } 1186 const labelAttrs = { class: 'table-label', 'pointer-events': 'none', 'text-anchor': 'middle', 'dominant-baseline': 'central' }; 1187 if (table.textColor) labelAttrs.fill = table.textColor; 1188 if (table.fontSize) labelAttrs['font-size'] = `${table.fontSize}px`; 1189 label = createSVGElement('text', { ...labelAttrs, transform: getTextTransform(0,0) }); 1190 label.textContent = table.label; 1191 group.appendChild(tableShape); 1192 group.appendChild(label); 1193 1194 if (table.whole_table) { 1195 const firstSeatId = `${table.id}-S1`; 1196 const isManuallyBooked = table.status === 'booked'; 1197 const isPermanentlyBooked = config.layout.bookings && config.layout.bookings[firstSeatId]; 1198 const isSoftLocked = softLockedSeats.has(firstSeatId); 1199 if (isManuallyBooked || isPermanentlyBooked || isSoftLocked) { 1200 group.classList.add('booked'); 1201 tableShape.classList.add('booked'); 1202 if (isPermanentlyBooked && config.customer_names && config.customer_names[isPermanentlyBooked]) { 1203 const custName = config.customer_names[isPermanentlyBooked]; 1204 const nameLabelAttrs = { class: 'table-customer-name', 'pointer-events': 'none', 'text-anchor': 'middle', 'dominant-baseline': 'central', fill: table.textColor || '#888', 'font-size': (parseInt(table.fontSize)||14) * 0.8 + 'px' }; 1205 const nameLabel = createSVGElement('text', { ...nameLabelAttrs, transform: getTextTransform(0, 15) }); 1206 nameLabel.textContent = custName; 1207 group.appendChild(nameLabel); 1208 label.setAttribute('transform', getTextTransform(0, -10)); 1209 } 1210 } 1211 else { tableShape.classList.add('available'); } 1212 } else { 1213 if (table.shape === 'circle') { 1214 const radius = table.radius || 60; 1215 for (let i = 1; i <= table.seats; i++) { 1216 const angle = ((i - 1) / table.seats) * 2 * Math.PI;
1217 const orbitRadius = radius + 10; 1218 const seatId = `${table.id}-S${i}`; 1219 if (table.killed_seats && table.killed_seats.includes(seatId)) continue; 1220 let visualLabel = startAt + (i - 1); 1221 if (seatLabelStyle === 'alpha_upper') { visualLabel = numberToAlpha(startAt + (i - 1)); } 1222 const sx = orbitRadius * Math.cos(angle); 1223 const sy = orbitRadius * Math.sin(angle); 1224 group.appendChild(createSeatElement(seatId, sx, sy, table, 8, visualLabel)); 1225 const sl = createSVGElement('text', { class: 'seat-label', 'text-anchor': 'middle', 'dominant-baseline': 'central', dy: '0.05em', transform: getTextTransform(sx, sy) }); 1226 sl.textContent = visualLabel; 1227 group.appendChild(sl); 1228 } 1229 } else { 1230 const tableWidth = table.width || 180, tableHeight = table.height || 70; 1231 const arrangement = table.seat_arrangement || 'top_bottom'; 1232 const padding = 10; 1233 const seats = table.seats || 0; 1234 let asTop = 0, asBottom = 0, asLeft = 0, asRight = 0; 1235 if (arrangement === 'custom') { 1236 asTop = table.seats_top || 0; 1237 asBottom = table.seats_bottom || 0; 1238 asLeft = table.seats_left || 0; 1239 asRight = table.seats_right || 0; 1240 } else if (arrangement === 'all_sides') { 1241 const base = Math.floor(seats / 4); const rem = seats % 4; 1242 asTop = base + (rem >= 1 ? 1 : 0); asBottom = base + (rem >= 2 ? 1 : 0); asRight = base + (rem >= 3 ? 1 : 0); asLeft = base; 1243 } else if (arrangement === 'top_left_right' || arrangement === 'three_sides') { 1244 const base = Math.floor(seats / 3); const rem = seats % 3; 1245 asTop = base + (rem >= 1 ? 1 : 0); asRight = base + (rem >= 2 ? 1 : 0); asLeft = base; asBottom = 0; 1246 } else if (arrangement === 'bottom_left_right') { 1247 const base = Math.floor(seats / 3); const rem = seats % 3; 1248 asBottom = base + (rem >= 1 ? 1 : 0); asRight = base + (rem >= 2 ? 1 : 0); asLeft = base; asTop = 0; 1249 } else if (arrangement === 'top_bottom_left') { 1250 const base = Math.floor(seats / 3); const rem = seats % 3; 1251 asTop = base + (rem >= 1 ? 1 : 0); asBottom = base + (rem >= 2 ? 1 : 0); asLeft = base; asRight = 0; 1252 } else if (arrangement === 'top_bottom_right') { 1253 const base = Math.floor(seats / 3); const rem = seats % 3; 1254 asTop = base + (rem >= 1 ? 1 : 0); asBottom = base + (rem >= 2 ? 1 : 0); asRight = base; asLeft = 0; 1255 } 1256 const isDistributedAround = ['all_sides', 'top_left_right', 'three_sides', 'bottom_left_right', 'top_bottom_left', 'top_bottom_right', 'custom'].includes(arrangement); 1257 for (let i = 1; i <= seats; i++) { 1258 let x, y; const idx = i - 1; 1259 if (isDistributedAround) { 1260 if (idx < asTop) { const spacing = tableWidth / (asTop + 1); x = -tableWidth / 2 + (idx + 1) * spacing; y = -tableHeight / 2 - padding; } 1261 else if (idx < (asTop + asRight)) { const sideIdx = idx - asTop; const spacing = tableHeight / (asRight + 1); x = tableWidth / 2 + padding; y = -tableHeight / 2 + (sideIdx + 1) * spacing; } 1262 else if (idx < (asTop + asRight + asBottom)) { const sideIdx = idx - (asTop + asRight); const spacing = tableWidth / (asBottom + 1); x = -tableWidth / 2 + (sideIdx + 1) * spacing; y = tableHeight / 2 + padding; } 1263 else { const sideIdx = idx - (asTop + asRight + asBottom); const spacing = tableHeight / (asLeft + 1); x = -tableWidth / 2 - padding; y = -tableHeight / 2 + (sideIdx + 1) * spacing; } 1264 } 1265 else if (arrangement === 'top') { const spacing = tableWidth / (seats + 1); x = -tableWidth / 2 + (i) * spacing; y = -tableHeight / 2 - padding; } 1266 else if (arrangement === 'bottom') { const spacing = tableWidth / (seats + 1); x = -tableWidth / 2 + (i) * spacing; y = tableHeight / 2 + padding; } 1267 else if (arrangement === 'left') { const spacing = tableHeight / (seats + 1); x = -tableWidth / 2 - padding; y = -tableHeight / 2 + (i) * spacing; } 1268 else if (arrangement === 'right') { const spacing = tableHeight / (seats + 1); x = tableWidth / 2 + padding; y = -tableHeight / 2 + (i) * spacing; }
1269 else if (arrangement === 'left_right') { 1270 const seatsPerSide = Math.ceil(seats / 2); const spacing = tableHeight / (seatsPerSide + 1); 1271 if (idx < seatsPerSide) { x = -tableWidth / 2 - padding; y = -tableHeight / 2 + (idx + 1) * spacing; } 1272 else { const sideIdx = idx - seatsPerSide; x = tableWidth / 2 + padding; y = -tableHeight / 2 + (sideIdx + 1) * spacing; } 1273 } 1274 else { 1275 const seatsPerSide = Math.ceil(seats / 2); const spacing = tableWidth / (seatsPerSide + 1); 1276 if (idx < seatsPerSide) { x = -tableWidth / 2 + (idx + 1) * spacing; y = -tableHeight / 2 - padding; } 1277 else { const sideIdx = idx - seatsPerSide; x = -tableWidth / 2 + (sideIdx + 1) * spacing; y = tableHeight / 2 + padding; } 1278 } 1279 const seatId = `${table.id}-S${i}`; 1280 if (table.killed_seats && table.killed_seats.includes(seatId)) continue; 1281 let visualLabel = startAt + (i - 1); 1282 if (seatLabelStyle === 'alpha_upper') { visualLabel = numberToAlpha(startAt + (i - 1)); } 1283 group.appendChild(createSeatElement(seatId, x, y, table, 8, visualLabel)); 1284 const sl = createSVGElement('text', { class: 'seat-label', 'text-anchor': 'middle', 'dominant-baseline': 'central', dy: '0.09em', transform: getTextTransform(x, y) }); 1285 sl.textContent = visualLabel; 1286 group.appendChild(sl); 1287 } 1288 } 1289 } 1290 } 1291 function drawGAArea(group, area) { const sold = gaBookings[area.id] || 0; const remaining = area.capacity - sold; const isSoldOut = remaining <= 0; const isManuallyBooked = area.status === 'booked'; const shapeClass = (isSoldOut || isManuallyBooked) ? 'booked' : 'available'; const shapeAttrs = { 'data-area-id': area.id, class: 'ga-area-shape ' + shapeClass, style: area.color ? `fill: ${area.color}` : 'fill: transparent;' }; const labelAttrs = { class: 'ga-area-label', 'pointer-events': 'none', 'text-anchor': 'middle', 'dominant-baseline': 'middle' }; if (area.textColor) labelAttrs.fill = area.textColor; const label = createSVGElement('text', labelAttrs); const textLine1 = createSVGElement('tspan', { x: 0, dy: '-0.5em' }); textLine1.textContent = area.label; 1292 const textLine2 = createSVGElement('tspan', { x: 0, dy: '1.2em', 'font-size': '12px', class: 'ga-capacity-text' }); 1293 if (isManuallyBooked) { textLine2.textContent = i18n.unavailable || 'Unavailable'; } 1294 else if (isSoldOut) { textLine2.textContent = i18n.soldOut || 'Sold Out'; } 1295 else { textLine2.textContent = (i18n.gaAvailable || '%1$d / %2$d Available').replace('%1$d', remaining).replace('%2$d', area.capacity); } 1296 label.appendChild(textLine1); label.appendChild(textLine2); 1297 if (area.shape === 'rect') { const width = area.width || 200, height = area.height || 100; group.appendChild(createSVGElement('rect', { x: -width/2, y: -height/2, width: width, height: height, ...shapeAttrs })); } 1298 else { const pointsString = area.points.map(p => `${p.x},${p.y}`).join(' '); group.appendChild(createSVGElement('polygon', { points: pointsString, ...shapeAttrs })); const centroid = calculatePolygonCentroid(area.points); label.setAttribute('x', centroid.x); label.setAttribute('y', centroid.y); } group.appendChild(label); } 1299 function drawStage(group, stage) { 1300 const stageWidth = stage.width || 200, stageHeight = stage.height || 40; 1301 const shapeAttrs = { class: 'stage-shape', style: stage.color ? `fill: ${stage.color}` : 'fill: #f0f0f1;' }; 1302 let parentFlipX = 1; let parentFlipY = 1; 1303 if (stage.parent_id) { 1304 const parent = config.layout.elements.find(el => el.id === stage.parent_id); 1305 if (parent) { if (parent.flipX) parentFlipX = -1; if (parent.flipY) parentFlipY = -1; } 1306 } 1307 const tsX = parentFlipX * (stage.flipX ? -1 : 1); 1308 const tsY = parentFlipY * (stage.flipY ? -1 : 1); 1309 const getTextTransform = (x, y) => `translate(${x}, ${y}) scale(${tsX}, ${tsY})`; 1310 group.appendChild(createSVGElement('rect', { x: -stageWidth/2, y: -stageHeight/2, width: stageWidth, height: stageHeight, ...shapeAttrs })); 1311 const labelAttrs = { class: 'stage-label', 'pointer-events': 'none', 'text-anchor': 'middle', 'dominant-baseline': 'middle' }; 1312 if (stage.textColor) labelAttrs.fill = stage.textColor; 1313 if (stage.fontSize) { labelAttrs['font-size'] = `${stage.fontSize}px`; } 1314 const label = createSVGElement('text', { ...labelAttrs, transform: getTextTransform(0,0) }); 1315 label.textContent = stage.label; 1316 group.appendChild(label); 1317 } 1318 1319 function drawImageElement(group, element) { if (!element.image_url) return; const imageWidth = element.width || 80, imageHeight = element.height || 80; group.appendChild(createSVGElement('image', { href: element.image_url, x: -imageWidth/2, y: -imageHeight/2, width: imageWidth, height: imageHeight, 'pointer-events': 'none' })); } 1320 function drawPolygonShape(group, element) { const pointsString = element.points.map(p => `${p.x},${p.y}`).join(' '); const shapeAttrs = { points: pointsString, class: 'polygon-shape', style: element.color ? `fill: ${element.color}` : 'fill: transparent;' };
1320 group.appendChild(createSVGElement('polygon', shapeAttrs)); const labelAttrs = { class: 'stage-label', 'pointer-events': 'none' }; if (element.textColor) labelAttrs.fill = element.textColor; const label = createSVGElement('text', labelAttrs); label.textContent = element.label; const centroid = calculatePolygonCentroid(element.points); label.setAttribute('x', centroid.x); label.setAttribute('y', centroid.y); group.appendChild(label); } 1321 1322 function isElementBookable(element) { const isVariableProduct = config.variation_prices && Object.keys(config.variation_prices).length > 0; if (isVariableProduct && !element.variation_id) { return false; } return true; } 1323 1324 function attachEventListeners() { 1325 $mapWrapper.on('click', '.section-boundary-guide', handleSectionClick); 1326 $mapWrapper.on('click', '.seat.available', handleSeatClick); 1327 $mapWrapper.on('click', '.table-shape.available', handleTableClick); 1328 $mapWrapper.on('click', '.ga-area-shape.available', handleGAAreaClick); 1329 1330 // Mouse events 1331 $mapWrapper.on('mouseenter', '.seat, .table-shape, .ga-area-shape, .section-boundary-guide', handleTooltipShow); 1332 $mapWrapper.on('mouseleave', '.seat, .table-shape, .ga-area-shape, .section-boundary-guide', handleTooltipHide); 1333 $mapWrapper.on('mousemove', handleTooltipMove); 1334 1335 // Buttons 1336 $('.les-view-controls').on('click', '#les-zoom-back-btn', handleZoomReset); 1337 $('#les-zoom-in-btn').on('click', function() { applyZoom(1.25); updateVisibleSectors(); }); 1338 $('#les-zoom-out-btn').on('click', function() { applyZoom(0.8); updateVisibleSectors(); }); 1339 $('#les-zoom-reset-btn').on('click', handleZoomReset); 1340 $addToCartBtn.on('click', handleAddToCart); 1341 1342 // Inputs/Remove 1343 $summaryList.on('input', '.ga-quantity-input', handleGAQuantityChange); 1344 $summaryList.on('click', '.les-remove-item-btn', function(e) { 1345 e.preventDefault(); e.stopPropagation(); 1346 const id = $(this).data('id'); const type = $(this).data('type'); 1347 removeSelectedItem(type, id); updateSummaryAndValidation(); 1348 if (type === 'ga_area') { $svg.find(`.ga-area-shape[data-area-id="${id}"]`).removeClass('selected'); } 1349 else if (type === 'table') { $svg.find(`.table-shape[data-table-id="${id}"]`).removeClass('selected'); } 1350 else { $svg.find(`.seat[data-seat-id="${id}"]`).removeClass('selected'); } 1351 }); 1352 1353 const svgEl = document.getElementById('seating-map-svg'); 1354 if (svgEl) { 1355 // Desktop Mouse 1356 $svg.on('mousedown', handlePanStart); 1357 $svg.on('mousemove', function(e) { if (view.isPanning) handlePanMove(e); }); 1358 $svg.on('mouseup mouseleave', function(e) { if (view.isPanning) handlePanEnd(e); }); 1359 $svg.on('wheel', function(e) { e.preventDefault(); const direction = e.originalEvent.deltaY > 0 ? 0.9 : 1.1; applyZoom(direction); requestAnimationFrame(debouncedUpdateSectors); }); 1360 1361 // Mobile Touch (Native) 1362 svgEl.addEventListener('touchstart', handlePanStart, { passive: false }); 1363 svgEl.addEventListener('touchmove', handlePanMove, { passive: false }); 1364 svgEl.addEventListener('touchend', handlePanEnd, { passive: false }); 1365 svgEl.addEventListener('touchcancel', handlePanEnd, { passive: false }); 1366 } 1367 1368 // --- GROUP BOOKING INTEGRATION: Listeners --- 1369 1370 // 1. Auto-select incoming group seats 1371 $(document).on('les_auto_select_seats', function(e, eventId, seatsArray) { 1372 if (String(config.event_id) !== String(eventId)) return; 1373 1374 // Clear current selection to ensure accurate group numbers 1375 selectedItems = []; 1376 1377 seatsArray.forEach(function(seatId) { 1378 const parts = seatId.split('-'); 1379 const parentElementId = parts[0]; 1380 const parentElement = config.layout.elements.find(el => el.id === parentElementId); 1381 1382 if (parentElement && isElementBookable(parentElement)) { 1383 addSelectedItem('seat', seatId, parentElement.variation_id || '',
13831); 1384 } 1385 }); 1386 1387 updateSummaryAndValidation(); 1388 }); 1389 1390 // 2. Clear auto-selection 1391 $(document).on('les_clear_selection', function(e, eventId) { 1392 if (String(config.event_id) !== String(eventId)) return; 1393 selectedItems = []; 1394 updateSummaryAndValidation(); 1395 }); 1396 1397 // --- Custom Quantity Plus/Minus Button Logic --- 1398 $(document).on('click', '.les-qty-btn', function(e) { 1399 e.preventDefault(); 1400 const $btn = $(this); 1401 const isPlus = $btn.hasClass('plus'); 1402 const $wrapper = $btn.closest('.les-qty-wrapper'); 1403 const $input = $wrapper.find('input[type="number"]'); 1404 1405 if (!$input.length) return; 1406 1407 let val = parseInt($input.val(), 10) || 0; 1408 const min = parseInt($input.attr('min'), 10) || 0; 1409 const maxAttr = $input.attr('max'); 1410 const max = (maxAttr !== undefined && maxAttr !== false) ? parseInt(maxAttr, 10) : 9999; 1411 1412 if (isPlus && val < max) { 1413 $input.val(val + 1).trigger('input'); // Update value and trigger change 1414 } else if (!isPlus && val > min) { 1415 $input.val(val - 1).trigger('input'); // Update value and trigger change 1416 } 1417 }); 1418 // --- End Custom Quantity Logic --- 1419 } 1420 1421 function handleTooltipShow(e) { clearTimeout(tooltipHideTimeout);const $target = $(e.currentTarget); let htmlContent = ''; if ($target.hasClass('section-boundary-guide') && $svg.hasClass('zoom-level-overview')) { htmlContent = generateSectionTooltipContent($target.data('section-id')); } else { const seatId = $target.data('seat-id'); const tableId = $target.data('table-id'); const areaId = $target.data('area-id'); if (seatId) { htmlContent = generateSeatTooltipContent($target, seatId); } else if (tableId) { htmlContent = generateTableTooltipContent($target, tableId); } else if (areaId) { htmlContent = generateAreaTooltipContent($target, areaId); } } if (htmlContent) { $tooltip.html(htmlContent).show(); setTimeout(() => { $tooltip.css({ 'opacity': '1', 'transform': 'translateY(0)' }); }, 10); } } 1422 1423 function generateSeatTooltipContent($target, seatId) { const parentElement = config.layout.elements.find(el => el.id === $target.data('parent-id')); if (!parentElement) return ''; const isSelected = selectedItems.some(item => item.id === seatId); let statusText = getStatusText($target); let html = `<div class="les-tooltip-content"><div class="tooltip-title">${getPrettySeatName(seatId)}</div><div class="tooltip-row"><span class="tooltip-label">${i18n.seatPrice || 'Price'}:</span><span class="tooltip-value">${formatPrice(getElementPrice(parentElement))}</span></div><div class="tooltip-row"><span class="tooltip-label">${i18n.statusLabel || 'Status:'}</span><span class="tooltip-value">${statusText}</span></div>`; 1424 // Accessibility badge in tooltip 1425 const acc = config.accessibility || {}; 1426 if (acc.enabled) { 1427 const accessEntry = (parentElement.accessible_seats || []).find(a => a.id === seatId); 1428 if (accessEntry) { 1429 const badgeText = (accessEntry.type === 'companion') 1430 ? (acc.companionLabel || 'Companion Seat') 1431 : (acc.wheelchairLabel || 'Wheelchair Accessible'); 1432 const badgeIcon = (accessEntry.type === 'companion') ? 'ð¤' : 'â¿'; 1433 html += `<div class="tooltip-row" style="margin-top:4px;"><span style="background:#1a73e8;color:#fff;border-radius:4px;padding:2px 6px;font-size:11px;font-weight:600;">${badgeIcon} ${badgeText}</span></div>`; 1434 } 1435 } 1436 if (isSelected) { html += `<div class="tooltip-status">${i18n.selected || 'Selected'}</div>`; } html += `</div>`; return html; } 1437 function generateTableTooltipContent($target, tableId) { const element = config.layout.elements.find(el => el.id === tableId); if (!element || !element.whole_table) return '';
1437 const isSelected = selectedItems.some(item => item.id === tableId); const totalPrice = getElementPrice(element) * element.seats; const $shape = $target.hasClass('table-shape') ? $target : $target.find('.table-shape'); 1438let statusText = getStatusText($shape); 1439// Double safety: if class-based check still misses, read directly from element.status 1440if (!statusText || statusText === 'N/A') { 1441 statusText = (element.status === 'booked') ? (i18n.statusBooked || 'Booked') : (i18n.statusAvailable || 'Available'); 1442} let html = `<div class="les-tooltip-content"><div class="tooltip-title">${element.label}</div><div class="tooltip-row"><span class="tooltip-label">${i18n.tablePrice || 'Total Price'}:</span><span class="tooltip-value">${formatPrice(totalPrice)}</span></div><div class="tooltip-row"><span class="tooltip-label">Seats:</span><span class="tooltip-value">${element.seats}</span></div><div class="tooltip-row"><span class="tooltip-label">${i18n.statusLabel || 'Status:'}</span><span class="tooltip-value">${statusText}</span></div>`; 1443const firstSeatId = `${element.id}-S1`; 1444const isPermanentlyBooked = config.layout.bookings && config.layout.bookings[firstSeatId]; 1445if (isPermanentlyBooked && config.customer_names && config.customer_names[isPermanentlyBooked]) { html += `<div class="tooltip-row"><span class="tooltip-label">Booked By:</span><span class="tooltip-value" style="font-weight:bold;">${config.customer_names[isPermanentlyBooked]}</span></div>`; } 1446if (isSelected) { html += `<div class="tooltip-status">${i18n.selected || 'Selected'}</div>`; } html += `</div>`; return html; } 1447 function generateAreaTooltipContent($target, areaId) { const element = config.layout.elements.find(el => el.id === areaId); if (!element) return ''; const isSelected = selectedItems.some(item => item.id === areaId); let statusText = getStatusText($target); let html = `<div class="les-tooltip-content"><div class="tooltip-title">${element.label}</div><div class="tooltip-row"><span class="tooltip-label">${i18n.seatPrice || 'Price'}:</span><span class="tooltip-value">${formatPrice(getElementPrice(element))} ${i18n.perPerson || ''}</span></div><div class="tooltip-row"><span class="tooltip-label">${i18n.statusLabel || 'Status:'}</span><span class="tooltip-value">${statusText}</span></div>`; if (isSelected) { html += `<div class="tooltip-status">${i18n.selected || 'Selected'}</div>`; } html += `</div>`; return html; } 1448 1449 function generateSectionTooltipContent(sectionId) { 1450 const section = config.layout.elements.find(el => el.id === sectionId); 1451 if (!section) return ''; 1452 let availableCount = 0; let totalCount = 0; 1453 const childElements = config.layout.elements.filter(el => el.parent_id === sectionId); 1454 childElements.forEach(el => { 1455 if (el.type === 'ga_area') { 1456 const capacity = parseInt(el.capacity || 0); 1457 if (el.status !== 'booked') { totalCount += capacity; const sold = gaBookings[el.id] || 0; availableCount += Math.max(0, capacity - sold); } 1458 return; 1459 } 1460 if (el.type === 'seat_block' || el.type === 'table') { 1461 if (el.status === 'booked') { 1462 let rawTotal = 0; 1463 if (el.type === 'table') rawTotal = parseInt(el.seats || 0); else rawTotal = parseInt(el.rows || 0) * parseInt(el.seats_per_row || 0); 1464 const killed = (el.killed_seats || []).length; 1465 totalCount += Math.max(0, rawTotal - killed); 1466 return; 1467 } 1468 if (el.type === 'table') { 1469 if (el.whole_table) { 1470 const killed = (el.killed_seats || []).length; 1471 const netSeats = parseInt(el.seats || 0) - killed; 1472 totalCount += netSeats; 1473 const firstSeatId = `${el.id}-S1`; 1474 const isBooked = config.layout.bookings[firstSeatId] || softLockedSeats.has(firstSeatId) || (el.unavailable_seats && el.unavailable_seats.includes(firstSeatId)); 1475 if (!isBooked) availableCount += netSeats; 1476 } else { 1477 const startAt = parseInt(el.seat_start_at || 1); 1478 for(let i=0; i < (el.seats || 0); i++) { 1479 const seatIndex = i + 1; const seatId = `${el.id}-S${seatIndex}`; 1480 if (el.killed_seats && el.killed_seats.includes(seatId)) continue; 1481 totalCount++; 1482 const isBooked = config.layout.bookings[seatId] || softLockedSeats.has(seatId) || (el.unavailable_seats && el.unavailable_seats.includes(seatId)); 1483 if (!isBooked) availableCount++; 1484 } 1485 } 1486 } else if (el.type === 'seat_block') { 1487 const rows = parseInt(el.rows || 0); const perRow = parseInt(el.seats_per_row || 0); const rowStartAt = parseInt(el.row_start_at || 1); const rowLabelPrefix = el.row_label_prefix || ''; 1488 for(let r=0; r < rows; r++) { 1489 let currentRowVal = rowStartAt + r; let visualRowLabel = currentRowVal; 1490 if (el.row_label_style === 'alpha_upper') { visualRowLabel = numberToAlpha(currentRowVal); } 1491 visualRowLabel = rowLabelPrefix + visualRowLabel; 1492 for(let s=1; s <= perRow; s++) { 1493 const seatId = `${el.id}-R${visualRowLabel}-S${s}`; 1494 if (el.killed_seats && el.killed_seats.includes(seatId)) continue; 1495 totalCount++; 1496 const isBooked = config.layout.bookings[seatId] || softLockedSeats.has(seatId) || (el.unavailable_seats && el.unavailable_seats.includes(seatId)); 1497 if (!isBooked) availableCount++; 1498 } 1499 } 1500 } 1501 } 1502 }); 1503 return `<div class="les-tooltip-content"><div class="tooltip-title">${section.label}</div><div class="tooltip-row"><span class="tooltip-label">${i18n.availableSeats.split(':')[0] || 'Availability'}:</span><span class="tooltip-value">${availableCount} / ${totalCount}</span></div></div>`; 1504 } 1505 1506 function getStatusText($el) { if ($el.hasClass('booked')) return i18n.statusBooked || 'Booked'; if ($el.hasClass('in-progress')) return i18n.statusInCart || 'In Another Cart'; if ($el.hasClass('unavailable')) return i18n.statusUnavailable || 'Unavailable'; if ($el.hasClass('available')) return i18n.statusAvailable || 'Available'; return 'N/A'; } 1507 function handleTooltipHide() { $tooltip.css({ 'opacity': '0', 'transform': 'translateY(5px)' }); tooltipHideTimeout = setTimeout(() => { $tooltip.hide(); }, 200); } 1508 function handleTooltipMove(e) { const parentOffset = $mapWrapper.offset(); if (!parentOffset) return; const relX = e.pageX - parentOffset.left, relY = e.pageY - parentOffset.top; const tooltipWidth = $tooltip.outerWidth(), tooltipHeight = $tooltip.outerHeight();
1508 const wrapperWidth = $mapWrapper.width(), wrapperHeight = $mapWrapper.height(); const offset = 15; let newLeft = relX + offset, newTop = relY + offset; if (newLeft + tooltipWidth > wrapperWidth) newLeft = relX - tooltipWidth - offset; if (newTop + tooltipHeight > wrapperHeight) newTop = relY - tooltipHeight - offset; if (newLeft < 0) newLeft = offset; if (newTop < 0) newTop = offset; $tooltip.css({ left: `${newLeft}px`, top: `${newTop}px` }); } 1509 function handleSeatClick(e) { const seat = $(e.currentTarget); const seatId = seat.data('seat-id'); if (!seatId) return; if (selectedItems.some(item => item.id === seatId)) { removeSelectedItem('seat', seatId); } else { const parentElement = config.layout.elements.find(el => el.id === seat.data('parent-id')); if (!parentElement || !isElementBookable(parentElement)) { $errorMessage.text(i18n.itemNotConfigured || 'This item is not available for booking.').stop(true, true).fadeIn().delay(3000).fadeOut(); return; }addSelectedItem('seat', seatId, parentElement ? parentElement.variation_id || '' : '', 1); } updateSummaryAndValidation(); handleTooltipShow(e); } 1510 function handleTableClick(e) { const tableId = $(e.currentTarget).data('table-id'); const element = config.layout.elements.find(el => el.id === tableId); if (!element) return;if (!isElementBookable(element)) { $errorMessage.text(i18n.itemNotConfigured || 'This item is not available for booking.').stop(true, true).fadeIn().delay(3000).fadeOut(); return; } if (element.whole_table) { if (selectedItems.some(item => item.id === tableId)) { removeSelectedItem('table', tableId); } else { addSelectedItem('table', tableId, element.variation_id || '', element.seats); } } else { const availableSeats = $svg.find(`.seat.available[data-table-id='${tableId}']:not(.selected)`); handleFullTableSelection(tableId, element, availableSeats); } updateSummaryAndValidation(); handleTooltipShow(e); } 1511 function handleGAAreaClick(e) { const areaId = $(e.currentTarget).data('area-id'); if (selectedItems.some(item => item.id === areaId)) { removeSelectedItem('ga_area', areaId); } else { const area = config.layout.elements.find(el => el.id === areaId); if (area.status === 'booked') { $errorMessage.text(i18n.itemNotConfigured || 'This area is currently unavailable.').stop(true, true).fadeIn().delay(2000).fadeOut(); return; } if (!isElementBookable(area)) { $errorMessage.text(i18n.itemNotConfigured || 'This item is not available for booking.').stop(true, true).fadeIn().delay(3000).fadeOut(); return; } if (area) { if ((area.capacity - (gaBookings[area.id] || 0)) > 0) { addSelectedItem('ga_area', areaId, area.variation_id || '', 1); } else { $errorMessage.text((i18n.areaSoldOut || '"%s" is sold out.').replace('%s', area.label)).fadeIn().delay(2000).fadeOut(); } } } updateSummaryAndValidation(); handleTooltipShow(e); } 1512 1513 function handleFullTableSelection(tableId, element, seatsToSelect) { const variationId = element ? element.variation_id || '' : ''; seatsToSelect.each(function() { addSelectedItem('seat', $(this).data('seat-id'), variationId, 1); }); } 1514 function handleGAQuantityChange(e) { const $input = $(e.currentTarget), areaId = $input.data('area-id'); let quantity = parseInt($input.val(), 10) || 0; const area = config.layout.elements.find(el => el.id === areaId); if (!area) return; const remaining = area.capacity - (gaBookings[area.id] || 0); if (quantity < 0) quantity = 0; if (quantity > remaining) { quantity = remaining; $input.val(remaining); } if (quantity === 0) removeSelectedItem('ga_area', areaId); else { const item = selectedItems.find(i => i.id === areaId); if (item) item.quantity = quantity; } updateSummaryAndValidation(); } 1515 function updateSummary() { let summaryHTML = '', currentTotal = 0; selectedItems.forEach(item => { currentTotal += getSelectedItemPrice(item); }); if (selectedItems.length === 0) { summaryHTML = `<li>${i18n.clickToBegin || 'Click a seat or area to begin.'}</li>`; } else { selectedItems.forEach(item => { const elementId = item.type === 'seat' ? item.id.split('-')[0] : item.id; const element = config.layout.elements.find(el => el.id === elementId); if (!element) return; const removeBtn = `<button type="button" class="les-remove-item-btn" data-id="${item.id}" data-type="${item.type}" title="Remove" aria-label="Remove seat">Ã</button>`; if (item.type === 'ga_area') { const remaining = element.capacity - (gaBookings[item.id] || 0); summaryHTML += `<li class="ga-summary-item"> 1516 <span class="ga-info">${element.label} (${formatPrice(getElementPrice(element))})</span> 1517 <div class="les-qty-wrapper"> 1518 <button type="button" class="les-qty-btn minus">-</button> 1519 <input type="number" class="ga-quantity-input" data-area-id="${item.id}" value="${item.quantity}" min="0" max="${remaining}" /> 1520 <button type="button" class="les-qty-btn plus">+</button> 1521 </div> 1522 ${removeBtn} 1523</li>`; } else { summaryHTML += `<li>${getPrettyItemName(item)} - ${formatPrice(getSelectedItemPrice(item))}${removeBtn}</li>`; } }); } $summaryList.html(summaryHTML); $totalPrice.text(formatPrice(currentTotal)); updateMobileCartToggle(); }
1524 function updateSelectionVisuals() { $svg.find('.selected').removeClass('selected'); selectedItems.forEach(item => { let selector = ''; if (item.type === 'ga_area') selector = `.ga-area-shape[data-area-id="${item.id}"]`; else if (item.type === 'table') selector = `.table-shape[data-table-id="${item.id}"]`; else selector = `.seat[data-seat-id="${item.id}"]`; if (selector) $svg.find(selector).addClass('selected'); }); } 1525 function updateSummaryAndValidation() { updateSummary(); validateSelection(); updateSelectionVisuals(); } 1526 function createSeatElement(seatId, cx, cy, parentElement, radius = 10, visualLabel = '') { 1527 const isManuallyBooked = parentElement.status === 'booked'; 1528 const isPermanentlyBooked = config.layout.bookings[seatId]; 1529 const isSoftLocked = softLockedSeats.has(seatId); 1530 const isUnavailable = parentElement.unavailable_seats && parentElement.unavailable_seats.includes(seatId); 1531 const isBooked = isManuallyBooked || isPermanentlyBooked || isSoftLocked || isUnavailable; 1532 let seatClass = isBooked ? 'seat booked' : 'seat available'; 1533 1534 // Accessibility overlay 1535 const accessEntry = (parentElement.accessible_seats || []).find(a => a.id === seatId); 1536 if (accessEntry) { 1537 seatClass += (accessEntry.type === 'companion') ? ' accessible-companion' : ' accessible-wheelchair'; 1538 } 1539 1540 const seatGroupAttrs = {}; 1541 if (accessEntry) { 1542 seatGroupAttrs.class = (accessEntry.type === 'companion') ? 'seat-group-accessible-companion' : 'seat-group-accessible-wheelchair'; 1543 } 1544 const seatGroup = createSVGElement('g', seatGroupAttrs); 1545 const seatCircleAttrs = { 'data-seat-id': seatId, 'data-parent-id': parentElement.id, 'data-table-id': parentElement.id, cx: cx || 0, cy: cy || 0, r: radius, class: seatClass }; 1546 1547 if (accessEntry && !isBooked) { 1548 seatCircleAttrs.style = (accessEntry.type === 'companion') ? 'fill: #64b5f6' : 'fill: #1a73e8'; 1549 } else if (parentElement.seatColor && !isBooked) { 1550 seatCircleAttrs.style = `fill: ${parentElement.seatColor}`; 1551 } 1552 1553 const seatCircle = createSVGElement('circle', seatCircleAttrs); 1554 seatGroup.appendChild(seatCircle); 1555 1556 // â¿ icon as SVG text overlay on wheelchair seats (only when seat radius is large enough) 1557 if (accessEntry && accessEntry.type === 'wheelchair' && !isBooked && radius >= 7) { 1558 const iconText = createSVGElement('text', { 1559 x: cx || 0, y: cy || 0, 1560 'text-anchor': 'middle', 'dominant-baseline': 'central', 1561 'font-size': Math.round(radius * 1.2), 1562 'pointer-events': 'none', 1563 style: 'fill:#fff; user-select:none;' 1564 }); 1565 iconText.textContent = 'â¿'; 1566 seatGroup.appendChild(iconText); 1567 } 1568 1569 return seatGroup; 1570 } 1571 function addSelectedItem(type, id, variationId, quantity) { if (!selectedItems.some(item => item.id === id)) selectedItems.push({ type, id, variation_id: variationId, quantity }); } 1572 function removeSelectedItem(type, id) { selectedItems = selectedItems.filter(item => item.id !== id); } 1573 function getSelectedSummaryLabel() { 1574 if (selectedItems.length === 0) return ''; 1575 const tableItems = selectedItems.filter(item => item.type === 'table'); 1576 const seatItems = selectedItems.filter(item => item.type === 'seat'); 1577 if (tableItems.length === 1 && selectedItems.length === 1) { 1578 return tableItems[0].id; 1579 } 1580 if (seatItems.length === 1 && selectedItems.length === 1) { 1581 return getPrettySeatName(seatItems[0].id); 1582 } 1583 return `${selectedItems.length} ${i18n.bookButtonCount ? (i18n.bookButtonCount.replace('%d', selectedItems.length)) : 'seats'}`; 1584 } 1585 function openAddonsModal(addons) { 1586 currentAddons = addons || []; 1587 addonSelections = {}; 1588 const $modal = $('#les-addons-modal'); 1589 const $content = $modal.find('.les-addons-modal-content'); 1590 $content.html(buildAddonsModalHtml()); 1591 updateAddonsSubtotal(); 1592 $modal.show(); 1593 addonModalOpen = true; 1594 isBooking = false; // Allow proceeding from modal
1595 $addToCartBtn.text(i18n.bookButton || 'Book Selected Seats').prop('disabled', false); // Reset button 1596 } 1597 function closeAddonsModal() { 1598 $('#les-addons-modal').hide(); 1599 addonModalOpen = false; 1600 currentAddons = []; 1601 addonSelections = {}; 1602 } 1603 function buildAddonsModalHtml() { 1604 const selectedLabel = getSelectedSummaryLabel(); 1605 let html = `<h2>${i18n.addonsTitle || 'Choose Add-ons'}</h2>`; 1606 html += `<div class="les-addons-summary">${i18n.selectedSummary || 'You selected:'} <strong>${selectedLabel}</strong></div>`; 1607 if (!currentAddons.length) { 1608 html += `<p>${i18n.noAddonsAvailable || 'No add-ons are available for this event.'}</p>`; 1609 } else { 1610 html += '<div class="les-addons-list">'; 1611 currentAddons.forEach(addon => { 1612 const qty = addon.type === 'per_seat' ? (addon.quantity || getTotalSelectedSeats()) : (addon.quantity || 1); 1613 addonSelections[addon.product_id] = qty; 1614 const labelText = addon.display_label || addon.name; 1615 html += ` 1616 <div class="les-addon-item" data-product-id="${addon.product_id}"> 1617 <img src="${addon.image}" alt="${addon.name}" /> 1618 <div class="les-addon-main"> 1619 <p class="les-addon-name">${labelText}</p> 1620 <p class="les-addon-desc">${addon.description || ''}</p> 1621 <div class="les-addon-meta"><span>${addon.price_html || ''}</span><span>${addon.type === 'per_seat' ? (i18n.perSeatNote || 'Quantity will auto-suggest per seat.') : (i18n.perOrderNote || 'Flat quantity for the booking.')}</span></div> 1622 </div> 1623 <div class="les-addon-qty"> 1624 <label>${i18n.quantityLabel || 'Quantity'}</label> 1625 <div class="les-qty-wrapper"> 1626 <button type="button" class="les-qty-btn minus">-</button> 1627 <input type="number" min="0" value="${qty}" data-product-id="${addon.product_id}" class="les-addon-qty-input" /> 1628 <button type="button" class="les-qty-btn plus">+</button> 1629 </div> 1630 </div> 1631 </div>`; 1632 }); 1633 html += '</div>'; 1634 } 1635 html += `<div class="les-addons-subtotal"><span>${i18n.addonsSubtotal || 'Add-ons subtotal:'}</span><strong id="les-addons-subtotal">${formatPrice(0)}</strong></div>`; 1636 html += '<div class="les-addons-actions">'; 1637 html += `<button type="button" class="button" id="les-skip-addons-btn">${i18n.skipAddonsButton || 'Skip Add-ons & Book'}</button>`; 1638 html += `<button type="button" class="button button-primary" id="les-confirm-addons-btn">${i18n.confirmAddonsButton || i18n.bookButton || 'Book Selected Seats'}</button>`; 1639 html += '</div>'; 1640 html += `<p class="les-addons-note">${i18n.addonsModalNote || 'You can review your add-ons in the cart before checkout.'}</p>`; 1641 return html; 1642 } 1643 function updateAddonsSubtotal() { 1644 let subtotal = 0; 1645 currentAddons.forEach(addon => { 1646 const qty = addonSelections[addon.product_id] || 0; 1647 const price = parseFloat(addon.price) || 0; 1648 subtotal += price * qty; 1649 }); 1650 $('#les-addons-subtotal').text(formatPrice(subtotal)); 1651 } 1652 function fetchAddonsForEvent() { 1653 return $.ajax({ 1654 url: config.ajax_url, 1655 type: 'POST', 1656 data: { action: 'les_get_addons', nonce: config.nonce, event_id: config.event_id } 1657 }); 1658 } 1659 function handleBookingWithoutAddons() { 1660 closeAddonsModal();
1661 return handleAddToCartRequest({}); 1662 } 1663 function handleBookingWithAddons(addons) { 1664 closeAddonsModal(); 1665 return handleAddToCartRequest({ addons }); 1666 } 1667 function handleConfirmAddons() { 1668 const selectedAddons = []; 1669 currentAddons.forEach(addon => { 1670 const qty = parseInt(addonSelections[addon.product_id], 10) || 0; 1671 if (qty > 0) { 1672 selectedAddons.push({ 1673 product_id: addon.product_id, 1674 quantity: qty, 1675 type: addon.type, 1676 display_label: addon.display_label || addon.name 1677 }); 1678 } 1679 }); 1680 if (selectedAddons.length === 0) { 1681 return handleBookingWithoutAddons(); 1682 } 1683 handleBookingWithAddons(selectedAddons); 1684 } 1685 function handleAddToCartRequest(extraData) { 1686 if (selectedItems.length === 0) return; 1687 if ( ! isBooking ) { 1688 isBooking = true; 1689 $addToCartBtn.text(i18n.booking || 'Booking...').prop('disabled', true); 1690 } 1691 const requestData = { 1692 action: 'les_add_to_cart', 1693 nonce: config.nonce, 1694 product_id: config.product_id, 1695 event_id: config.event_id, 1696 source_url: window.location.href, 1697 seats: JSON.stringify(selectedItems) 1698 }; 1699 if (extraData.addons) { 1700 requestData.addons = JSON.stringify(extraData.addons); 1701 } 1702 $.ajax({ 1703 url: config.ajax_url, 1704 type: 'POST', 1705 data: requestData, 1706 success: (response) => { 1707 if (response.success) { 1708 window.location.href = config.cart_url || config.wc_cart_url || window.location.origin; 1709 } else { 1710 $errorMessage.text(response.data.message || (i18n.unknownError || 'An unknown error occurred.')).fadeIn().delay(3000).fadeOut(); 1711 if (response.data.reload) { 1712 setTimeout(() => window.location.reload(), 2500); 1713 } else { 1714 isBooking = false; 1715 updateSummaryAndValidation(); 1716 } 1717 } 1718 }, 1719 error: () => { 1720 $errorMessage.text(i18n.requestFailed || 'Request failed. Please try again.').fadeIn().delay(3000).fadeOut(); 1721 isBooking = false; 1722 updateSummaryAndValidation(); 1723 } 1724 }); 1725 } 1726 function attachAddonModalEvents() { 1727 $(document).off('click.lesAddons', '#les-skip-addons-btn').on('click.lesAddons', '#les-skip-addons-btn', function() { 1728 handleBookingWithoutAddons(); 1729 }); 1730 $(document).off('click.lesConfirmAddons', '#les-confirm-addons-btn').on('click.lesConfirmAddons', '#les-confirm-addons-btn', function() { 1731 handleConfirmAddons(); 1732 }); 1733 $(document).off('click.lesCloseAddons', '.les-addons-modal-close, .les-addons-modal-backdrop').on('click.lesCloseAddons', '.les-addons-modal-close, .les-addons-modal-backdrop', function() { 1734 closeAddonsModal(); 1735 isBooking = false; 1736 updateSummaryAndValidation(); 1737 }); 1738 $(document).off('input.lesAddonQty', '.les-addon-qty-input').on('input.lesAddonQty', '.les-addon-qty-input', function() { 1739 const productId = $(this).data('product-id'); 1740 const qty = Math.max(0, parseInt($(this).val(), 10) || 0); 1741 addonSelections[productId] = qty; 1742 $(this).val(qty); 1743 updateAddonsSubtotal(); 1744 }); 1745 } 1746 attachAddonModalEvents(); 1747 function handleAddToCart() { if (isBooking || selectedItems.length === 0) return; isBooking = true;
1747 $addToCartBtn.text(i18n.booking || 'Booking...').prop('disabled', true); fetchAddonsForEvent().done(function(response) { if (response.success && response.data && response.data.length > 0) { openAddonsModal(response.data); } else { handleAddToCartRequest({}); } }).fail(function() { handleAddToCartRequest({}); }); } 1748 function getElementPrice(element) { 1749 let price = config.product_price; 1750 if (element.variation_id && config.variation_prices && config.variation_prices[element.variation_id]) { 1751 const varData = config.variation_prices[element.variation_id]; 1752 if (typeof varData === 'object' && varData !== null && varData.price !== undefined) { price = varData.price; } 1753 else { price = varData; } 1754 } 1755 return parseFloat(price || 0); 1756 } 1757 function getSelectedItemPrice(item) { return getElementPrice({ variation_id: item.variation_id }) * item.quantity; } 1758 function getPrettyItemName(item) { const elementId = item.type === 'seat' ? item.id.split('-')[0] : item.id; const element = config.layout.elements.find(el => el.id === elementId); const name = element ? element.label : ''; if (item.type === 'ga_area') return `${name} (x${item.quantity})`; if (item.type === 'table') return `${name} ${i18n.tableSuffix || '(Table)'}`; return getPrettySeatName(item.id); } 1759 function validateSelection() { const totalSelected = getTotalSelectedSeats(); const maxSeats = config.maxSeatsPerOrder || 0; let errorMessage = ''; if (maxSeats > 0 && totalSelected > maxSeats) { errorMessage = (i18n.maxTicketsError || 'You cannot select more than %d tickets.').replace('%d', maxSeats); } if (!errorMessage && !config.isOrphanRuleDisabled && totalSelected > 0) { if (checkForOrphanSeats()) { errorMessage = i18n.orphanSeatError || 'This selection would leave a single empty seat.'; } } if (errorMessage) { $errorMessage.text(errorMessage).show(); } else { $errorMessage.hide(); } const isInvalid = !!errorMessage; $addToCartBtn.text(totalSelected === 0 ? (i18n.bookButton || 'Book Selected Seats') : (i18n.bookButtonCount || 'Book %d Seats').replace('%d', totalSelected)).prop('disabled', isInvalid || totalSelected === 0); } 1760 1761 1762 function checkForOrphanSeats() { 1763 const selectedSeatIds = selectedItems.filter(i => i.type === 'seat').map(i => i.id); 1764 const parentElementIds = new Set(selectedSeatIds.map(id => id.split('-')[0])); 1765 const unavailableSeatIds = new Set(selectedSeatIds); 1766 parentElementIds.forEach(parentId => { 1767 const bookedSeats = bookedSeatsByParent.get(parentId); 1768 if (bookedSeats) bookedSeats.forEach(seatId => unavailableSeatIds.add(seatId)); 1769 }); 1770 softLockedSeats.forEach(seatId => { 1771 if (parentElementIds.has(seatId.split('-')[0])) unavailableSeatIds.add(seatId); 1772 }); 1773 const blockedSeatIds = new Set(); 1774 const killedSeatIds = new Set(); // â NEW: Track killed seats separately 1775 1776 const relevantElements = config.layout.elements.filter(el => parentElementIds.has(el.id)); 1777 1778 for (const element of relevantElements) { 1779 // â Track killed seats separately - they should be IGNORED in orphan logic 1780 if(element.killed_seats) element.killed_seats.forEach(sid => killedSeatIds.add(sid)); 1781 1782 // â Only add NON-KILLED unavailable seats to blocked set 1783 if(element.unavailable_seats) { 1784 element.unavailable_seats.forEach(sid => { 1785 if (!killedSeatIds.has(sid)) { 1786 blockedSeatIds.add(sid); 1787 } 1788 }); 1789 } 1790 if(element.status === 'booked') { continue; } 1791 1792 let seatsToCheck = []; 1793 if (element.type === 'seat_block') { 1794 for (let r = 1; r <= element.rows; r++) { 1795 seatsToCheck = []; 1796 let visualRowLabel = r; 1797 if(element.row_label_style === 'alpha_upper') visualRowLabel = numberToAlpha(r); 1798 visualRowLabel = (element.row_label_prefix || '') + visualRowLabel; 1799 for (let s = 1; s <= element.seats_per_row; s++) {
1800 const seatId = `${element.id}-R${visualRowLabel}-S${s}`; 1801 // â Skip killed seats entirely - they don't exist for adjacency/orphan logic 1802 if (!killedSeatIds.has(seatId)) { 1803 seatsToCheck.push(seatId); 1804 } 1805 } 1806 if (hasOrphanInArray(seatsToCheck, unavailableSeatIds, blockedSeatIds, false)) return true; 1807 } 1808 } else if (element.type === 'table' && !element.whole_table) { 1809 for (let i = 1; i <= element.seats; i++) { 1810 const seatId = `${element.id}-S${i}`; 1811 if (!killedSeatIds.has(seatId)) { 1812 seatsToCheck.push(seatId); 1813 } 1814 } 1815 const isCircle = element.shape === 'circle'; 1816 if (hasOrphanInArray(seatsToCheck, unavailableSeatIds, blockedSeatIds, isCircle)) return true; 1817 } 1818 } 1819 return false; 1820 } 1821 1822 function hasOrphanInArray(seatArray, unavailableSet, blockedSet, isCircular = false) { 1823 const isFree = (id) => !unavailableSet.has(id) && !blockedSet.has(id); 1824 for (let i = 0; i < seatArray.length; i++) { 1825 const currentSeatId = seatArray[i]; 1826 if (isFree(currentSeatId)) { 1827 let leftIdx = i - 1; let isLeftTaken = false; 1828 if (leftIdx < 0) { if (isCircular) { leftIdx = seatArray.length - 1; isLeftTaken = !isFree(seatArray[leftIdx]); } else { isLeftTaken = true; } } else { isLeftTaken = !isFree(seatArray[leftIdx]); } 1829 1830 let rightIdx = i + 1; let isRightTaken = false; 1831 if (rightIdx >= seatArray.length) { if (isCircular) { rightIdx = 0; isRightTaken = !isFree(seatArray[rightIdx]); } else { isRightTaken = true; } } else { isRightTaken = !isFree(seatArray[rightIdx]); } 1832 1833 if (isLeftTaken && isRightTaken) { return true; } 1834 } 1835 } 1836 return false; 1837 } 1838 1839 function getTotalSelectedSeats() { return selectedItems.reduce((total, item) => total + item.quantity, 0); } 1840 function getPrettySeatName(seatId) { if (!seatId) return ''; const elementId = seatId.split('-')[0]; const element = config.layout.elements.find(el => el.id === elementId); if (!element) return seatId; const elementName = element.label || elementId; let seatIdentifier = ''; const startAt = element.seat_start_at || 1; const seatLabelStyle = element.seat_label_style || 'numeric'; const rowMatch = seatId.match(/-R(.+)-S(\d+)$/); const tableMatch = seatId.match(/-S(\d+)$/); if (rowMatch) { const rowIdentifier = rowMatch[1]; const seatIndex = parseInt(rowMatch[2], 10); let visualSeatLabelValue = startAt + (seatIndex - 1); let visualSeatLabel = visualSeatLabelValue; if (seatLabelStyle === 'alpha_upper') { visualSeatLabel = numberToAlpha(visualSeatLabelValue); } seatIdentifier = (i18n.rowSeat || 'Row %1$s, Seat %2$s').replace('%1$s', rowIdentifier).replace('%2$s', visualSeatLabel); } else if (tableMatch) { const seatIndex = parseInt(tableMatch[1], 10); let visualLabelValue = startAt + (seatIndex - 1); let visualLabel = visualLabelValue; if (seatLabelStyle === 'alpha_upper') { visualLabel = numberToAlpha(visualLabelValue); } seatIdentifier = (i18n.seat || 'Seat %s').replace('%s', visualLabel); } else { seatIdentifier = seatId; } let sectionPrefix = ''; if (element.parent_id) { const section = config.layout.elements.find(el => el.id === element.parent_id); if (section && section.label) { sectionPrefix = section.label + ' ⺠'; } } return `${sectionPrefix}${elementName} - ${seatIdentifier}`; } 1841 function createSVGElement(tag, attrs) { const el = document.createElementNS(svgNS, tag); for (let key in attrs) { if (tag === 'image' && (key === 'href' || key === 'xlink:href')) { el.setAttribute('href', attrs[key]); el.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', attrs[key]); } else { el.setAttribute(key, attrs[key]); } } return el; } 1842 function formatPrice(price) { return (config.currency_symbol || '$') + parseFloat(price).toFixed(config.price_decimals ?? 2); } 1843 function animateViewBox(x, y, w, h, duration, callback) { isAnimating = true; const startViewBox = { ...view }; const startTime = performance.now(); function frame(currentTime) { const progress = Math.min((currentTime - startTime) / duration, 1); const easedProgress = 0.5 - 0.5 * Math.cos(progress * Math.PI); view.x = startViewBox.x + (x - startViewBox.x) * easedProgress; view.y = startViewBox.y + (y - startViewBox.y) * easedProgress; view.w = startViewBox.w + (w - startViewBox.w) * easedProgress; view.h = startViewBox.h + (h - startViewBox.h) * easedProgress; $svg.attr('viewBox', `${view.x} ${view.y} ${view.w} ${view.h}`); if (progress < 1) { requestAnimationFrame(frame); } else { isAnimating = false; if (callback) { callback(); } } } requestAnimationFrame(frame); } 1844 function calculatePolygonCentroid(pts) { if(!pts || pts.length === 0) return {x:0, y:0}; let first = pts[0], last = pts[pts.length - 1]; if (first.x != last.x || first.y != last.y) pts.push(first); let twicearea = 0, x = 0, y = 0, nPts = pts.length, p1, p2, f;
1844 for (let i = 0, j = nPts - 1; i < nPts; j = i++) { p1 = pts[i]; p2 = pts[j]; f = p1.x * p2.y - p2.x * p1.y; twicearea += f; x += (p1.x + p2.x) * f; y += (p1.y + p2.y) * f; } f = twicearea * 3; if (Math.abs(f) < 1e-10) return {x:0, y:0}; return { x: x / f, y: y / f }; } 1845 function findDynamicViewBox(elements, padding = 50) { const defaults = { viewBox: `0 0 ${config.layout.settings.canvasWidth || 1000} ${config.layout.settings.canvasHeight || 800}`, x: 0, y: 0, width: config.layout.settings.canvasWidth || 1000, height: config.layout.settings.canvasHeight || 800 }; if (!elements || elements.length === 0) return defaults; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; elements.forEach(element => { const elX = parseFloat(element.x), elY = parseFloat(element.y); if (isNaN(elX) || isNaN(elY)) return; const buffer = Math.max(parseFloat(element.width) || 0, parseFloat(element.height) || 0, (parseFloat(element.radius) || 0) * 2) / 2 + 30; minX = Math.min(minX, elX - buffer); minY = Math.min(minY, elY - buffer); maxX = Math.max(maxX, elX + buffer); maxY = Math.max(maxY, elY + buffer); }); if (minX === Infinity) return defaults; minX -= padding; minY -= padding; maxX += padding; maxY += padding; const viewBoxWidth = maxX - minX, viewBoxHeight = maxY - minY; return { viewBox: `${minX} ${minY} ${viewBoxWidth} ${viewBoxHeight}`, x: minX, y: minY, width: viewBoxWidth, height: viewBoxHeight }; } 1846 function applyZoom(zoomFactor) { $tooltip.hide().css('opacity', 0); const newWidth = view.w / zoomFactor; const newHeight = view.h / zoomFactor; view.x += (view.w - newWidth) / 2; view.y += (view.h - newHeight) / 2; view.w = newWidth; view.h = newHeight; $svg.attr('viewBox', `${view.x} ${view.y} ${view.w} ${view.h}`); } 1847 1848 function handlePanStart(e) { 1849 if (isAnimating) return; 1850 $tooltip.hide().css('opacity', 0); 1851 1852 if ($(e.target).closest('.seat, .table-shape, .ga-area-shape').length > 0) return; 1853 if ($svg.hasClass('zoom-level-overview') && $(e.target).hasClass('section-boundary-guide')) return; 1854 1855 const type = e.type; 1856 const isTouch = type === 'touchstart'; 1857 1858 if (isTouch && e.touches.length === 2) { 1859 view.isPanning = false; 1860 view.isPinching = true; 1861 view.startPinchDist = getPinchDistance(e.touches); 1862 e.preventDefault(); 1863 return; 1864 } 1865 1866 let clientX, clientY; 1867 if (isTouch) { 1868 clientX = e.touches[0].clientX; 1869 clientY = e.touches[0].clientY; 1870 if (e.cancelable) e.preventDefault(); 1871 } else { 1872 e.preventDefault(); 1873 clientX = e.clientX; 1874 clientY = e.clientY; 1875 } 1876 1877 view.isPanning = true; 1878 view.panStart.x = clientX; 1879 view.panStart.y = clientY; 1880 $svg.addClass('is-panning'); 1881 } 1882 1883 function handlePanEnd(e) { 1884 if (view.isPanning) { 1885 view.isPanning = false; 1886 $svg.removeClass('is-panning'); 1887 } 1888 if (view.isPinching) { 1889 view.isPinching = false; 1890 view.startPinchDist = 0; 1891 } 1892 } 1893 1894 function getElementBBox(el) { let bbox = {}; const buffer = 0; switch(el.type) { case 'section': if (el.shape === 'polygon' && el.points) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; el.points.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); bbox = { x: el.x + minX, y: el.y + minY, width: maxX - minX, height: maxY - minY }; } else { const w = el.width || 400; const h = el.height || 300; bbox = { x: el.x - w / 2, y: el.y - h / 2, width: w, height: h }; } break; default: const w = el.width || 200; const h = el.height || 100; bbox = { x: el.x - w / 2, y: el.y - h / 2, width: w, height: h }; } return bbox; } 1895 1896 function getSectionVisibilityBBox(section) { 1897 const bounds = getElementBBox(section); 1898 const children = config.layout.elements.filter(el => el.parent_id === section.id); 1899
1900 children.forEach(child => { 1901 const childBounds = getElementBBox(child); 1902 const childX = section.x + childBounds.x; 1903 const childY = section.y + childBounds.y; 1904 const childRight = childX + childBounds.width; 1905 const childBottom = childY + childBounds.height; 1906 const boundsRight = bounds.x + bounds.width; 1907 const boundsBottom = bounds.y + bounds.height; 1908 1909 bounds.x = Math.min(bounds.x, childX); 1910 bounds.y = Math.min(bounds.y, childY); 1911 bounds.width = Math.max(boundsRight, childRight) - bounds.x; 1912 bounds.height = Math.max(boundsBottom, childBottom) - bounds.y; 1913 }); 1914 1915 return bounds; 1916 } 1917 1918 init(); 1919 }); 1920})(jQuery);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.