UNPKG

ticket-selector

Version:

A professional stadium seat selection widget with multi-language support

1,558 lines (1,311 loc) 59.3 kB
import I18n from './i18n/index.js'; import { default as panzoom } from 'panzoom'; class TicketSelector extends EventTarget { constructor(container, options = {}) { super(); // Widget version for tracking and debugging this.version = '1.0.8'; this.classSelector = 'ticket-select'; this.isLoading = true; this.container = typeof container === 'string' ? document.querySelector(container) : container; this.i18n = new I18n(options.lang); if (!this.container) { throw new Error(this.i18n.t('errors.containerNotFound')); } this.showInfo = options.showInfo !== false; this.showControls = options.showControls !== false; this.maxSeat = options.maxSeat || Infinity; this.currentView = 'stadium'; this.selectedSeats = new Set(); this.sectorData = null; this.panzoomInstance = null; this.isDragging = false; this.isDraggingSector = false; this.mousePressed = false; this.startX = 0; this.startY = 0; this.isFullscreen = false; this.tooltip = null; this.tooltipTimeout = null; this.TOOLTIP_DELAY = 300; this.stadiumHTML = null; this.BASE_VIEWPORT_WIDTH = 1216; this.BASE_VIEWPORT_HEIGHT = 607; this.MIN_VIEWPORT_WIDTH = 800; this.MIN_VIEWPORT_HEIGHT = 400; this.G_V_HEIGHT = 420.77; this.BASE_ROW_WIDTH = 1125.09; this.BASE_ROW_HEIGHT = 54.81; this.PERSPECTIVE_SCALE = 0.9825; this.ROW_OVERLAP = 20; this.STEP_SPACING = 0; this.BASE_SEAT_SPACING = 2.56; this.MIN_FONT_SIZE = 4; this.MAX_FONT_SIZE = 12; this.FONT_SCALE_FACTOR = 3; this.MIN_CIRCLE_RADIUS = 4; this.MAX_CIRCLE_RADIUS = 10; this.CIRCLE_SCALE_FACTOR = 3; // Initialize with loading state this.initWithLoading(); } t(key, params = {}, count = null) { return this.i18n.t(key, params, count); } setLanguage(lang) { if (this.i18n.setLanguage(lang)) { this.refreshUITexts(); return true; } return false; } getCurrentLanguage() { return this.i18n.getCurrentLanguage(); } getAvailableLanguages() { return this.i18n.getAvailableLanguages(); } refreshUITexts() { if (this.showControls && this.controls) { const zoomInBtn = this.controls.querySelector( `.${this.classSelector}__control-btn--zoom-in` ); const zoomOutBtn = this.controls.querySelector( `.${this.classSelector}__control-btn--zoom-out` ); const fitBtn = this.controls.querySelector( `.${this.classSelector}__control-btn--fit` ); const backBtn = this.controls.querySelector( `.${this.classSelector}__control-btn--back` ); const fullscreenBtn = this.controls.querySelector( `.${this.classSelector}__control-btn--fullscreen` ); if (fullscreenBtn) { const tooltipText = this.isFullscreen ? this.t('controls.exitFullscreen') : this.t('controls.enterFullscreen'); fullscreenBtn.setAttribute('aria-label', tooltipText); fullscreenBtn.setAttribute('data-tooltip', tooltipText); } if (zoomInBtn) { zoomInBtn.setAttribute('aria-label', this.t('controls.zoomIn')); zoomInBtn.setAttribute('data-tooltip', this.t('controls.zoomIn')); } if (zoomOutBtn) { zoomOutBtn.setAttribute('aria-label', this.t('controls.zoomOut')); zoomOutBtn.setAttribute('data-tooltip', this.t('controls.zoomOut')); } if (fitBtn) { fitBtn.setAttribute('aria-label', this.t('controls.fitToScreen')); fitBtn.setAttribute('data-tooltip', this.t('controls.fitToScreen')); } if (backBtn) { backBtn.setAttribute('aria-label', this.t('controls.backToStadium')); backBtn.setAttribute('data-tooltip', this.t('controls.backToStadium')); } } this.updateSelectedCount(); } setupGlobalTooltipListeners() { this.globalClickHandler = (e) => { if (!this.tooltip.contains(e.target)) { this.hideTooltip(); this.cancelTooltip(); } }; this.globalKeydownHandler = (e) => { if (e.key === 'Escape') { this.hideTooltip(); this.cancelTooltip(); } }; document.addEventListener('click', this.globalClickHandler); document.addEventListener('keydown', this.globalKeydownHandler); } createTooltip() { this.tooltip = document.createElement('div'); this.tooltip.className = 'ticket-tooltip'; this.tooltip.style.cssText = ` position: fixed; background: rgba(0, 0, 0, 0.85); color: white; padding: 6px 10px; border-radius: 6px; font-size: 12px; font-weight: 500; z-index: 10000; pointer-events: none; opacity: 0; transform: translateY(8px); transition: opacity 0.15s ease, transform 0.15s ease; max-width: 400px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); white-space: nowrap; `; document.body.appendChild(this.tooltip); } showTooltip(content, x, y) { if (!this.tooltip) return; this.tooltip.innerHTML = content; const tooltipRect = this.tooltip.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let finalX; if (x > viewportWidth / 2) { finalX = x - tooltipRect.width - 10; } else { finalX = x + 10; } let finalY = y - tooltipRect.height - 10; if (finalY < 10) { finalY = y + 20; } this.tooltip.style.left = finalX + 'px'; this.tooltip.style.top = finalY + 'px'; this.tooltip.style.opacity = '1'; this.tooltip.style.transform = 'translateY(0)'; } hideTooltip() { if (!this.tooltip) return; this.tooltip.style.opacity = '0'; this.tooltip.style.transform = 'translateY(10px)'; } scheduleTooltip(content, x, y) { if (this.tooltipTimeout) { clearTimeout(this.tooltipTimeout); } this.tooltipTimeout = setTimeout(() => { this.showTooltip(content, x, y); }, this.TOOLTIP_DELAY); } cancelTooltip() { if (this.tooltipTimeout) { clearTimeout(this.tooltipTimeout); this.tooltipTimeout = null; } this.hideTooltip(); } async initWithLoading() { try { // Small delay to ensure DOM is ready await new Promise((resolve) => setTimeout(resolve, 100)); // Initialize container and basic setup first (with loading screen) this.setupContainer(); // Small delay for loading display await new Promise((resolve) => setTimeout(resolve, 500)); // Complete initialization this.setupEventListeners(); this.setupZoomControls(); this.createTooltip(); this.setupGlobalTooltipListeners(); this.setupFullscreenListener(); // Load stadium view (this will replace loading content) this.loadStadiumView(); // Update sector attributes after initial load setTimeout(() => { this.updateSectorAttributes(); this.setupSectorListeners(); }, 400); this.isLoading = false; // Add is-loaded class to show stadium and hide loading this.container.classList.add('is-loaded'); // Dispatch ready event this.dispatchEvent( new CustomEvent('ready', { detail: { version: this.version }, }) ); } catch (error) { console.error('TicketSelector initialization failed:', error); this.showError('Failed to initialize widget'); } } init() { this.setupContainer(); this.setupEventListeners(); this.setupZoomControls(); this.setupSectorListeners(); } setupContainer() { this.wrapper = this.container.querySelector( `.${this.classSelector}__container` ); this.viewport = this.container.querySelector( `.${this.classSelector}__viewport` ); this.content = this.container.querySelector( `.${this.classSelector}__content` ); if (!this.viewport || !this.content) { console.error(this.t('errors.htmlStructureInvalid')); return; } // Store stadium HTML (keep it in DOM but hidden via CSS) const stadiumContainer = this.content.querySelector( `.${this.classSelector}__stadium` ); if (stadiumContainer) { this.stadiumHTML = stadiumContainer.outerHTML; } else { console.error(this.t('errors.stadiumNotFound')); return; } this.createControlsAndInfo(); this.createInitialLoadingScreen(); } createInitialLoadingScreen() { const loadingMessage = this.t('loading.default'); const loadingHTML = ` <div class="${this.classSelector}__loading"> <div class="${this.classSelector}__loader"> <svg viewBox="0 0 24 24"> <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416"> <animate attributeName="stroke-dashoffset" dur="2s" values="31.416;0" repeatCount="indefinite"/> <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/> </circle> </svg> </div> <div class="${this.classSelector}__loading-text">${loadingMessage}</div> </div> `; // Add loading screen to content this.content.insertAdjacentHTML('beforeend', loadingHTML); } createControlsAndInfo() { let controlsHTML = ''; if (this.showControls) { controlsHTML += ` <div class="${this.classSelector}__controls"> <button class="${this.classSelector}__control-btn ${this.classSelector}__control-btn--fullscreen" aria-label="${this.t('controls.enterFullscreen')}" data-tooltip="${this.t('controls.enterFullscreen')}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z"></path></svg></button> <button class="${this.classSelector}__control-btn ${this.classSelector}__control-btn--zoom-in" aria-label="${this.t('controls.zoomIn')}" data-tooltip="${this.t('controls.zoomIn')}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path></svg></button> <button class="${this.classSelector}__control-btn ${this.classSelector}__control-btn--zoom-out" aria-label="${this.t('controls.zoomOut')}" data-tooltip="${this.t('controls.zoomOut')}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 11V13H19V11H5Z"></path></svg></button> <button class="${this.classSelector}__control-btn ${this.classSelector}__control-btn--fit" aria-label="${this.t('controls.fitToScreen')}" data-tooltip="${this.t('controls.fitToScreen')}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM20 5H4V19H20V5ZM13 17V15H16V12H18V17H13ZM11 7V9H8V12H6V7H11Z"></path></svg></button> <button class="${this.classSelector}__control-btn ${this.classSelector}__control-btn--back" style="display: none;" aria-label="${this.t('controls.backToStadium')}" data-tooltip="${this.t('controls.backToStadium')}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22.0003 13.0001L22.0004 11.0002L5.82845 11.0002L9.77817 7.05044L8.36396 5.63623L2 12.0002L8.36396 18.3642L9.77817 16.9499L5.8284 13.0002L22.0003 13.0001Z"></path></svg></button> </div> `; } const wrapperEl = this.container.querySelector( `.${this.classSelector}__wrapper` ); // Check if info element already exists in HTML const existingInfo = wrapperEl.querySelector( `.${this.classSelector}__info` ); // Only create info element if showInfo is true AND it doesn't already exist in HTML if (this.showInfo && !existingInfo) { controlsHTML += ` <div class="${this.classSelector}__info"> <span class="${this.classSelector}__selected-count">${this.t('info.seatsSelected.zero')}</span> </div> `; } wrapperEl.insertAdjacentHTML('beforeend', controlsHTML); this.controls = this.container.querySelector( `.${this.classSelector}__controls` ); this.backBtn = this.container.querySelector( `.${this.classSelector}__control-btn--back` ); this.selectedCountEl = this.container.querySelector( `.${this.classSelector}__selected-count` ); if (this.backBtn) { this.backBtn.addEventListener('click', () => { this.loadStadiumView(); }); } // Setup fullscreen button if (this.showControls && this.controls) { const fullscreenBtn = this.controls.querySelector( `.${this.classSelector}__control-btn--fullscreen` ); if (fullscreenBtn) { fullscreenBtn.addEventListener('click', () => { this.toggleFullscreen(); }); } this.setupControlTooltips(); } } setupControlTooltips() { const controlButtons = this.controls.querySelectorAll( `.${this.classSelector}__control-btn` ); controlButtons.forEach((button) => { const tooltipText = button.dataset.tooltip; if (!tooltipText) return; button.addEventListener('mouseenter', (e) => { const buttonRect = button.getBoundingClientRect(); const buttonCenterX = buttonRect.left + buttonRect.width / 2; const buttonCenterY = buttonRect.top + buttonRect.height / 2; const tooltipX = buttonRect.left - 8; const tooltipY = buttonCenterY; this.scheduleControlTooltip(tooltipText, tooltipX, tooltipY); }); button.addEventListener('mouseleave', () => { this.cancelTooltip(); }); }); } scheduleControlTooltip(content, x, y) { if (this.tooltipTimeout) { clearTimeout(this.tooltipTimeout); } this.tooltipTimeout = setTimeout(() => { this.showControlTooltip(content, x, y); }, this.TOOLTIP_DELAY); } showControlTooltip(content, x, y) { if (!this.tooltip) return; this.tooltip.innerHTML = content; const tooltipRect = this.tooltip.getBoundingClientRect(); const finalX = x - tooltipRect.width; const finalY = y - tooltipRect.height / 2; this.tooltip.style.left = finalX + 'px'; this.tooltip.style.top = finalY + 'px'; this.tooltip.style.opacity = '1'; this.tooltip.style.transform = 'translateY(0)'; } loadStadiumView() { this.currentView = 'stadium'; if (this.showControls && this.backBtn) { this.backBtn.style.display = 'none'; } this.viewport.classList.remove( `${this.classSelector}__viewport--seats-view` ); // Remove is-loaded class to show loading this.container.classList.remove('is-loaded'); // Show loading first this.showLoading(); // Small delay to show loading state setTimeout(() => { // Restore stadium HTML if (this.stadiumHTML) { this.content.innerHTML = this.stadiumHTML; } // Update sector attributes after stadium is loaded this.updateSectorAttributes(); this.setupSectorListeners(); this.initPanzoom(); // Add is-loaded class to hide loading and show stadium this.container.classList.add('is-loaded'); setTimeout(() => this.fitToScreen(), 100); }, 300); } setupSectorListeners() { this.removeSectorListeners(); const sectors = this.content.querySelectorAll( `.${this.classSelector}__sector` ); sectors.forEach((sector) => { let sectorStartX = 0; let sectorStartY = 0; let isDraggingThisSector = false; const mouseDownHandler = (e) => { const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; sectorStartX = clientX; sectorStartY = clientY; isDraggingThisSector = false; }; const sectorMoveHandler = (e) => { if (sectorStartX !== 0 || sectorStartY !== 0) { const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; const deltaX = Math.abs(clientX - sectorStartX); const deltaY = Math.abs(clientY - sectorStartY); if (deltaX > 5 || deltaY > 5) { isDraggingThisSector = true; this.isDraggingSector = true; } } }; const mouseUpHandler = () => { sectorStartX = 0; sectorStartY = 0; setTimeout(() => { isDraggingThisSector = false; this.isDraggingSector = false; }, 10); }; const clickHandler = (e) => { e.stopPropagation(); e.preventDefault(); // Prevent click if dragging if (isDraggingThisSector || this.isDragging) { return; } // Check if sector is disabled if (sector.dataset.disabled === 'true') { return; // Don't proceed if sector is disabled } this.hideTooltip(); this.cancelTooltip(); // Apply viewport class IMMEDIATELY for instant UI feedback this.viewport.classList.add( `${this.classSelector}__viewport--seats-view` ); // Show loading IMMEDIATELY on sector click this.container.classList.remove('is-loaded'); this.showLoading(); const sectorName = sector.dataset.sector; const sectorId = sector.dataset.sectorId; const sectorClickEvent = new CustomEvent('sectorClick', { detail: { sectorId: sectorId, sectorName: sectorName, element: sector, }, }); this.dispatchEvent(sectorClickEvent); }; const mouseEnterHandler = (e) => { // Check if disabled const isDisabled = sector.dataset.disabled === 'true'; sector.style.fillOpacity = isDisabled ? '0.5' : '0.8'; sector.style.cursor = isDisabled ? 'not-allowed' : 'pointer'; if (!this.isDragging && !this.isDraggingSector) { const sectorInfo = { name: sector.dataset.sector, price: sector.dataset.price || '0 AZN', available: parseInt(sector.dataset.available) || 0, total: parseInt(sector.dataset.total) || 0, category: 'Standard', disabled: isDisabled, }; const tooltipContent = this.createSectorTooltipContent(sectorInfo); this.scheduleTooltip(tooltipContent, e.clientX, e.clientY); } }; const mouseMoveHandler = (e) => { if ( !this.isDragging && !this.isDraggingSector && this.tooltip && this.tooltip.style.opacity === '1' ) { const isDisabled = sector.dataset.disabled === 'true'; const sectorInfo = { name: sector.dataset.sector, price: sector.dataset.price || '0 AZN', available: parseInt(sector.dataset.available) || 0, total: parseInt(sector.dataset.total) || 0, category: 'Standard', disabled: isDisabled, }; const tooltipContent = this.createSectorTooltipContent(sectorInfo); this.showTooltip(tooltipContent, e.clientX, e.clientY); } }; const mouseLeaveHandler = () => { sector.style.fillOpacity = '0.6'; this.cancelTooltip(); }; sector._clickHandler = clickHandler; sector._mouseDownHandler = mouseDownHandler; sector._sectorMoveHandler = sectorMoveHandler; sector._mouseUpHandler = mouseUpHandler; sector._mouseEnterHandler = mouseEnterHandler; sector._mouseMoveHandler = mouseMoveHandler; sector._mouseLeaveHandler = mouseLeaveHandler; sector.addEventListener('mousedown', mouseDownHandler); sector.addEventListener('mousemove', sectorMoveHandler); sector.addEventListener('mouseup', mouseUpHandler); sector.addEventListener('touchstart', mouseDownHandler); sector.addEventListener('touchmove', sectorMoveHandler); sector.addEventListener('touchend', mouseUpHandler); sector.addEventListener('click', clickHandler); sector.addEventListener('mouseenter', mouseEnterHandler); sector.addEventListener('mousemove', mouseMoveHandler); sector.addEventListener('mouseleave', mouseLeaveHandler); }); } removeSectorListeners() { const sectors = this.content.querySelectorAll( `.${this.classSelector}__sector` ); sectors.forEach((sector) => { if (sector._clickHandler) { sector.removeEventListener('click', sector._clickHandler); sector.removeEventListener('mousedown', sector._mouseDownHandler); sector.removeEventListener('mousemove', sector._sectorMoveHandler); sector.removeEventListener('mouseup', sector._mouseUpHandler); sector.removeEventListener('touchstart', sector._mouseDownHandler); sector.removeEventListener('touchmove', sector._sectorMoveHandler); sector.removeEventListener('touchend', sector._mouseUpHandler); sector.removeEventListener('mouseenter', sector._mouseEnterHandler); sector.removeEventListener('mousemove', sector._mouseMoveHandler); sector.removeEventListener('mouseleave', sector._mouseLeaveHandler); delete sector._clickHandler; delete sector._mouseDownHandler; delete sector._sectorMoveHandler; delete sector._mouseUpHandler; delete sector._mouseEnterHandler; delete sector._mouseMoveHandler; delete sector._mouseLeaveHandler; } }); } createSectorTooltipContent(sectorInfo) { // Use cached price and availability if available const price = sectorInfo.price || '100 AZN'; const available = sectorInfo.available || 25; const total = sectorInfo.total || 35; // Check if sector is disabled if (sectorInfo.disabled) { return ` <div style="text-align: center;"> <div style="font-size: 16px; font-weight: bold; margin-bottom: 8px;"> ${this.t('sector.name', { name: sectorInfo.name })} </div> <div style="color: #ff5252; font-weight: bold; font-size: 14px; margin-top: 8px;"> ${this.t('sector.unavailable')} </div> <div style="color: #999; font-size: 12px; margin-top: 4px;"> ${this.t('sector.soldOut')} </div> </div> `; } return ` <div style="text-align: center;"> <div style="font-size: 16px; font-weight: bold; margin-bottom: 8px;"> ${this.t('sector.name', { name: sectorInfo.name })} </div> <div style="margin-bottom: 4px;"> <strong>${this.t('sector.price', { price: price })}</strong> </div> <div style="margin-bottom: 4px;"> <strong>${this.t('sector.available', { available: available, total: total })}</strong> </div> <div style="color: #4CAF50; font-size: 12px; margin-top: 8px;"> ${this.t('sector.category', { category: sectorInfo.category })} </div> </div> `; } setSectorData(sectorData, sectorInfo = {}) { this.currentView = 'seats'; if (this.showControls && this.backBtn) { this.backBtn.style.display = 'flex'; } // Remove is-loaded class and show loading IMMEDIATELY this.container.classList.remove('is-loaded'); this.showLoading(); this.sectorData = sectorData; // Get price and availability from sectorData if not provided in sectorInfo this.sectorPrice = sectorInfo.price || sectorData.price || null; this.sectorAvailability = sectorInfo.availability || sectorData.availability || null; // Small delay to show loading state setTimeout(() => { const sectorColor = sectorInfo.sectorColor || null; const seatsResult = this.generateSeatsForSector(sectorData, sectorColor); this.content.innerHTML = ` <div class="${this.classSelector}__seats-header"> <h2 class="${this.classSelector}__seats-title">${sectorData.name}</h2> </div> <svg class="${this.classSelector}__seats-svg" viewBox="0 0 ${seatsResult.viewBoxWidth} ${seatsResult.viewBoxHeight}" preserveAspectRatio="xMidYMid meet"> <g class="${this.classSelector}__seats-group"> ${seatsResult.content} </g> </svg> `; this.setupSeatListeners(); this.destroyPanzoom(); this.initPanzoom(); // Add is-loaded class to hide loading and show seats this.container.classList.add('is-loaded'); setTimeout(() => this.fitToScreen(), 100); }, 300); } showLoading(message = null) { if (!message) { message = this.t('loading.default'); } const loaderSVG = ` <svg viewBox="0 0 24 24"> <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416"> <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/> <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/> </circle> </svg> `; this.content.innerHTML = ` <div class="${this.classSelector}__loading"> <div class="${this.classSelector}__loader">${loaderSVG}</div> <div class="${this.classSelector}__loading-text">${message}</div> </div> `; } showError(message) { this.content.innerHTML = ` <div class="${this.classSelector}__loading"> <div style="color: #bb2932; font-size: 16px; text-align: center;"> <div style="font-size: 24px; margin-bottom: 10px;">⚠️</div> <div>${message}</div> </div> </div> `; } generateSeatsForSector(sectorData, sectorColor = null) { let svgContent = ''; const currentSeatId = 1000; const viewport = this.calculateDynamicViewport(sectorData); const maxSeatsInRow = this.calculateMaxSeatsInRow(sectorData); const adjustedBaseRowWidth = Math.min( this.BASE_ROW_WIDTH * viewport.widthScale, viewport.width * 0.95 ); svgContent += this.generatePerspectiveBackground( sectorData, this.STEP_SPACING, adjustedBaseRowWidth, viewport, maxSeatsInRow ); const sortedRows = [...sectorData.seats].sort((a, b) => b.id - a.id); sortedRows.forEach((row, rowIndex) => { const rowNumber = row.id; const actualRowIndex = rowNumber - 1; const scaleMultiplier = Math.pow(this.PERSPECTIVE_SCALE, actualRowIndex); const rowWidth = adjustedBaseRowWidth * scaleMultiplier; const rowHeight = this.BASE_ROW_HEIGHT * scaleMultiplier; const totalRows = sectorData.seats.length; let baseRowSpacing = this.BASE_ROW_HEIGHT - this.ROW_OVERLAP; if (maxSeatsInRow > 80) { baseRowSpacing *= 0.6; } else if (maxSeatsInRow > 50) { baseRowSpacing *= 0.8; } const totalContentHeight = totalRows * baseRowSpacing; const availableHeight = viewport.height * 0.8; const startY = (viewport.height - Math.min(totalContentHeight, availableHeight)) / 2; const dynamicRowSpacing = Math.min( baseRowSpacing, availableHeight / totalRows ); const yPosition = startY + (totalRows - 1 - actualRowIndex) * dynamicRowSpacing; const xPosition = (viewport.width - rowWidth) / 2; svgContent += `<g data-row="${rowNumber}" data-sector="${sectorData.id || 'sector'}">`; svgContent += this.generateRowSeats({ row, rowIndex: actualRowIndex, rowWidth, rowHeight, xPosition, yPosition, scaleMultiplier, currentSeatId: currentSeatId + actualRowIndex * 50, sectorName: sectorData.name, maxSeatsInRow, sectorColor, }); const labelX = xPosition - 40; const labelY = yPosition + rowHeight / 2 + 5; const labelFontSize = 14 * scaleMultiplier; svgContent += ` <text class="${this.classSelector}__seats-row-label" transform="translate(${labelX} ${labelY})" style="font-size: ${labelFontSize}px;">${rowNumber}</text> `; svgContent += `</g>`; }); return { content: svgContent, viewBoxWidth: viewport.width, viewBoxHeight: viewport.height, }; } calculateMaxSeatsInRow(sectorData) { let maxSeats = 0; sectorData.seats.forEach((row) => { let rowSeatCount = 0; let currentPosition = 1; row.data.forEach((seat) => { if (seat.skipLeft && seat.skipLeft > 0) { currentPosition += seat.skipLeft; } rowSeatCount = Math.max(rowSeatCount, currentPosition); currentPosition++; if (seat.skipRight && seat.skipRight > 0) { currentPosition += seat.skipRight; } }); maxSeats = Math.max(maxSeats, rowSeatCount); }); return maxSeats; } calculateDynamicViewport(sectorData) { const maxSeatsInRow = this.calculateMaxSeatsInRow(sectorData); const rowCount = sectorData.seats.length; let dynamicWidth = this.BASE_VIEWPORT_WIDTH; let dynamicHeight = this.BASE_VIEWPORT_HEIGHT; if (maxSeatsInRow > 50) { const extraWidthRatio = Math.min(2.0, maxSeatsInRow / 50); dynamicWidth = this.BASE_VIEWPORT_WIDTH * extraWidthRatio; } if (rowCount > 20) { const extraHeightRatio = Math.min(1.5, rowCount / 20); dynamicHeight = this.BASE_VIEWPORT_HEIGHT * extraHeightRatio; } const widthScale = dynamicWidth / this.BASE_VIEWPORT_WIDTH; const heightScale = dynamicHeight / this.BASE_VIEWPORT_HEIGHT; return { width: Math.round(dynamicWidth), height: Math.round(dynamicHeight), widthScale, heightScale, }; } calculateActualSeatCount(row) { let count = 0; let currentPosition = 1; row.data.forEach((seat) => { if (seat.skipLeft && seat.skipLeft > 0) { currentPosition += seat.skipLeft; } count = Math.max(count, currentPosition); currentPosition++; if (seat.skipRight && seat.skipRight > 0) { currentPosition += seat.skipRight; } }); return count; } calculateRowTotalWidth(row, seatWidth, seatSpacing) { let totalWidth = 0; row.data.forEach((seat, index) => { if (seat.skipLeft && seat.skipLeft > 0) { totalWidth += seat.skipLeft * (seatWidth + seatSpacing); } totalWidth += seatWidth; if (seat.skipRight && seat.skipRight > 0) { totalWidth += seat.skipRight * (seatWidth + seatSpacing); } if (index < row.data.length - 1) { totalWidth += seatSpacing; } }); return totalWidth; } generatePerspectiveBackground( sectorData, stepSpacing, baseRowWidth, viewport, maxSeatsInRow ) { let backgroundHTML = ` <g id="v"> <rect class="${this.classSelector}__seats-background" y="0" width="${viewport.width}" height="${viewport.height}"></rect> <g> `; const sortedRows = [...sectorData.seats].sort((a, b) => a.id - b.id); sortedRows.forEach((row, rowIndex) => { const actualRowIndex = rowIndex; const scaleMultiplier = Math.pow(this.PERSPECTIVE_SCALE, actualRowIndex); const stepWidth = baseRowWidth * scaleMultiplier + 100; const rowHeight = this.BASE_ROW_HEIGHT * scaleMultiplier; const totalRows = sectorData.seats.length; let baseRowSpacing = this.BASE_ROW_HEIGHT - this.ROW_OVERLAP; if (maxSeatsInRow > 80) { baseRowSpacing *= 0.6; } else if (maxSeatsInRow > 50) { baseRowSpacing *= 0.8; } const totalContentHeight = totalRows * baseRowSpacing; const availableHeight = viewport.height * 0.8; const startY = (viewport.height - Math.min(totalContentHeight, availableHeight)) / 2; const dynamicRowSpacing = Math.min( baseRowSpacing, availableHeight / totalRows ); const seatYPosition = startY + (totalRows - 1 - actualRowIndex) * dynamicRowSpacing; const stepY = (seatYPosition + rowHeight + stepSpacing) * this.PERSPECTIVE_SCALE - 18; const stepX = (viewport.width - stepWidth) / 2; const stepHeight = 12; backgroundHTML += ` <rect class="${this.classSelector}__seats-step" x="${stepX}" y="${stepY}" width="${stepWidth}" height="${stepHeight}"></rect> `; }); backgroundHTML += ` </g> </g> `; return backgroundHTML; } generateRowSeats({ row, rowIndex, rowWidth, rowHeight, xPosition, yPosition, scaleMultiplier, currentSeatId, sectorName, maxSeatsInRow, sectorColor = null, }) { let seatsHTML = ''; const seatSpacing = this.BASE_SEAT_SPACING * scaleMultiplier; const totalMaxSpacing = (maxSeatsInRow - 1) * seatSpacing; const availableWidth = rowWidth * 0.85; const maxSeatWidth = (availableWidth - totalMaxSpacing) / maxSeatsInRow; const actualSeatCount = this.calculateActualSeatCount(row); const seatWidth = maxSeatWidth; const seatHeight = rowHeight * 0.9; const totalSeatsWidth = this.calculateRowTotalWidth( row, seatWidth, seatSpacing ); const startX = xPosition + (rowWidth - totalSeatsWidth) / 2; let currentX = startX; let seatCounter = 0; row.data.forEach((seat, seatIndex) => { if (seat.skipLeft && seat.skipLeft > 0) { currentX += seat.skipLeft * (seatWidth + seatSpacing); } const seatX = currentX; const seatY = yPosition + (rowHeight - seatHeight) / 2; const seatId = `seat-${seat.id}`; const isAvailable = seat.available !== false; let seatClass = isAvailable ? `${this.classSelector}__seat ${this.classSelector}__seat--available` : `${this.classSelector}__seat ${this.classSelector}__seat--unavailable`; if (sectorColor) { seatClass += ` ${this.classSelector}__seat--color-${sectorColor}`; } seatCounter++; // Eğer back-end'den number parametresi geldiyse onu kullan, yoksa seatCounter'ı kullan const seatName = seat.number ? `${seat.number}` : `${seatCounter}`; seatsHTML += this.generatePerspectiveSeat({ seatId, row: row.id, seat: seat.id, seatName: seatName, x: seatX, y: seatY, width: seatWidth, height: seatHeight, scale: scaleMultiplier, isAvailable, seatClass, sectorName, }); currentX += seatWidth + seatSpacing; if (seat.skipRight && seat.skipRight > 0) { currentX += seat.skipRight * (seatWidth + seatSpacing); } }); return seatsHTML; } generatePerspectiveSeat({ seatId, row, seat, seatName, x, y, width, height, scale, isAvailable, seatClass, sectorName, }) { const cornerRadius = 6; const cornerRadiusBottom = cornerRadius / 2; const pathData = [ `M${x + cornerRadius},${y}`, `L${x + width - cornerRadius},${y}`, `Q${x + width},${y} ${x + width},${y + cornerRadius}`, `L${x + width},${y + height - cornerRadiusBottom}`, `Q${x + width},${y + height} ${x + width - cornerRadiusBottom},${y + height}`, `L${x + cornerRadiusBottom},${y + height}`, `Q${x},${y + height} ${x},${y + height - cornerRadiusBottom}`, `L${x},${y + cornerRadius}`, `Q${x},${y} ${x + cornerRadius},${y}`, 'Z', ].join(' '); let baseCircleRadius = Math.min(width, height) / this.CIRCLE_SCALE_FACTOR; baseCircleRadius = Math.min(baseCircleRadius, this.MAX_CIRCLE_RADIUS); baseCircleRadius = Math.max(baseCircleRadius, this.MIN_CIRCLE_RADIUS); const circleRadius = Math.max( baseCircleRadius * scale, this.MIN_CIRCLE_RADIUS * 0.8 ); const circleX = x + width / 2; const baseCircleYOffset = height / 3.5; const circleY = y + baseCircleYOffset * scale; const textX = circleX; const textY = circleY + circleRadius / 8; let baseFontSize = Math.min( width / this.FONT_SCALE_FACTOR, this.MAX_FONT_SIZE ); baseFontSize = Math.max(baseFontSize, this.MIN_FONT_SIZE); const fontSize = Math.max(baseFontSize * scale, this.MIN_FONT_SIZE * 0.7); const availableAttrs = isAvailable ? ` data-sector-label="${sectorName}" data-row-label="${row}" data-seat-label="${seatName}" data-seat-id="${seatId}" ` : ''; return ` <g id="${seatId}" data-row="${row}" data-seat="${seat}" data-sector="17641" class="${seatClass}" ${availableAttrs}> <path d="${pathData}"/> <circle cx="${circleX}" cy="${circleY}" r="${circleRadius}"/> <text x="${textX}" y="${textY}" text-anchor="middle" dominant-baseline="central" ${fontSize ? `style="font-size: ${fontSize}px;"` : ''}>${seatName}</text> </g> `; } setupSeatListeners() { const seats = this.content.querySelectorAll( `.${this.classSelector}__seat.${this.classSelector}__seat--available` ); seats.forEach((seatGroup) => { const seatPath = seatGroup.querySelector('path'); const seatId = seatGroup.id; if ( !seatGroup.classList.contains(`${this.classSelector}__seat--available`) ) { return; } const seatClickHandler = (e) => { e.stopPropagation(); e.preventDefault(); if (this.isDragging) { return; } this.hideTooltip(); this.cancelTooltip(); this.toggleSeat(seatId, seatGroup, seatPath); }; seatGroup.addEventListener('click', seatClickHandler); seatGroup.addEventListener('touchend', seatClickHandler); seatGroup.addEventListener('mouseenter', (e) => { if (this.isDragging) return; const isMaxReached = this.selectedSeats.size >= this.maxSeat; const isAlreadySelected = this.selectedSeats.has(seatId); if (!isAlreadySelected) { if (isMaxReached) { seatPath.style.filter = ''; seatGroup.style.cursor = 'not-allowed'; } else { seatPath.style.filter = 'brightness(1.2)'; seatGroup.style.cursor = 'pointer'; } } const seatInfo = this.getSeatInfo(seatId); const tooltipContent = this.createSeatTooltipContent( seatInfo, isMaxReached && !isAlreadySelected ); this.scheduleTooltip(tooltipContent, e.clientX, e.clientY); }); seatGroup.addEventListener('mousemove', (e) => { if ( !this.isDragging && this.tooltip && this.tooltip.style.opacity === '1' ) { const isMaxReached = this.selectedSeats.size >= this.maxSeat; const isAlreadySelected = this.selectedSeats.has(seatId); const seatInfo = this.getSeatInfo(seatId); const tooltipContent = this.createSeatTooltipContent( seatInfo, isMaxReached && !isAlreadySelected ); this.showTooltip(tooltipContent, e.clientX, e.clientY); } }); seatGroup.addEventListener('mouseleave', () => { if (!this.selectedSeats.has(seatId)) { seatPath.style.filter = ''; } this.cancelTooltip(); }); }); } calculateSeatPosition(rowData, targetSeatId) { let physicalPosition = 1; let seatNumber = 0; for (let seatIndex = 0; seatIndex < rowData.data.length; seatIndex++) { const seat = rowData.data[seatIndex]; if (seat.skipLeft && seat.skipLeft > 0) { physicalPosition += seat.skipLeft; } seatNumber++; if (seat.id === targetSeatId) { return { physicalPosition: physicalPosition, seatNumber: seatNumber, isAvailable: seat.available !== false, }; } physicalPosition++; if (seat.skipRight && seat.skipRight > 0) { physicalPosition += seat.skipRight; } } return null; } getSeatInfo(seatId) { if (!this.sectorData || !this.sectorData.seats) { return { seatId: seatId, sector: 'A2', row: 1, seat: 1, price: this.sectorPrice || '120 ₼', category: 'Standard', isSelected: this.selectedSeats.has(seatId), }; } const seatIdNumber = parseInt(seatId.replace('seat-', '')); for ( let rowIndex = 0; rowIndex < this.sectorData.seats.length; rowIndex++ ) { const rowData = this.sectorData.seats[rowIndex]; const position = this.calculateSeatPosition(rowData, seatIdNumber); if (position) { return { seatId: seatId, sector: this.sectorData.name || 'A2', row: rowData.id, seat: position.seatNumber, physicalPosition: position.physicalPosition, price: this.sectorPrice || '120 ₼', category: 'Standard', isSelected: this.selectedSeats.has(seatId), isAvailable: position.isAvailable, }; } } return { seatId: seatId, sector: 'A2', row: 1, seat: 1, price: this.sectorPrice || '120 ₼', category: 'Standard', isSelected: this.selectedSeats.has(seatId), }; } createSeatTooltipContent(seatInfo, isMaxReached = false) { if (isMaxReached) { return ` <div style="text-align: center;"> <div style="font-size: 16px; font-weight: bold; margin-bottom: 8px; color: #FF5722;"> ${this.t('seat.maxReached', { max: this.maxSeat })} </div> <div style="color: #999; font-size: 12px;"> ${this.t('seat.cannotSelectMore')} </div> </div> `; } const statusColor = seatInfo.isSelected ? '#FF5722' : '#4CAF50'; const statusText = seatInfo.isSelected ? this.t('seat.selected') : this.t('seat.available'); return ` <div style="text-align: center;"> <div style="font-size: 16px; font-weight: bold; margin-bottom: 8px;"> ${this.t('seat.sector', { sector: seatInfo.sector })} </div> <div style="margin-bottom: 4px;"> <strong>${this.t('seat.row', { row: seatInfo.row })}</strong> &nbsp;&nbsp; <strong>${this.t('seat.seat', { seat: seatInfo.seat })}</strong> </div> <div style="margin-bottom: 4px;"> <strong>${this.t('seat.price', { price: seatInfo.price })}</strong> </div> <div style="color: ${statusColor}; font-size: 12px; margin-top: 8px;"> ${statusText} • ${seatInfo.category} </div> </div> `; } toggleSeat(seatId, seatGroup, seatPath) { if (this.selectedSeats.has(seatId)) { this.selectedSeats.delete(seatId); seatGroup.classList.remove(`${this.classSelector}__seat--selected`); } else { // Check if max seats reached if (this.selectedSeats.size >= this.maxSeat) { // Show max reached message briefly const tooltipContent = ` <div style="text-align: center;"> <div style="font-size: 16px; font-weight: bold; margin-bottom: 8px; color: #FF5722;"> ${this.t('seat.maxReached', { max: this.maxSeat })} </div> <div style="color: #999; font-size: 12px;"> ${this.t('seat.cannotSelectMore')} </div> </div> `; const rect = seatGroup.getBoundingClientRect(); this.showTooltip(tooltipContent, rect.left + rect.width / 2, rect.top); setTimeout(() => this.hideTooltip(), 2000); return; } this.selectedSeats.add(seatId); seatGroup.classList.add(`${this.classSelector}__seat--selected`); } this.updateSelectedCount(); } updateSelectedCount() { // First check if selectedCountEl exists, if not try to find it if (!this.selectedCountEl) { this.selectedCountEl = this.container.querySelector( `.${this.classSelector}__selected-count` ); } // If still no element found or showInfo is false (and element wasn't in HTML), return if (!this.selectedCountEl) { return; } const count = this.selectedSeats.size; this.selectedCountEl.textContent = this.t('info.seatsSelected', {}, count); // Update button state if it exists const buyButton = this.container.querySelector( `.${this.classSelector}__info-btn` ); if (buyButton) { if (count > 0) { buyButton.classList.add(`${this.classSelector}__info-btn--active`); buyButton.removeAttribute('disabled'); } else { buyButton.classList.remove(`${this.classSelector}__info-btn--active`); buyButton.setAttribute('disabled', 'disabled'); } // Update count span inside button const countSpan = buyButton.querySelector('[data-ticket-selector-count]'); if (countSpan) { if (count > 0) { countSpan.textContent = ` (${count})`; countSpan.style.display = 'inline'; } else { countSpan.textContent = ''; countSpan.style.display = 'none'; } } } } initPanzoom() { if (this.panzoomInstance) { this.destroyPanzoom(); } // Use bundled panzoom instead of window.panzoom if (!panzoom) { console.error(this.t('errors.panzoomNotLoaded')); return; } const viewportRect = this.viewport.getBoundingClientRect(); const bounds = { contain: 'outside', width: viewportRect.width, height: viewportRect.height, }; this.panzoomInstance = panzoom(this.content, { maxZoom: 6, minZoom: 0.6, smoothScroll: false, bounds: true, boundsPadding: 0.1, contain: 'outside', autocenter: false, startScale: 1, startX: 0, startY: 0, }); this.mouseDownHandler = (e) => { this.mousePressed = true; const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; this.startX = clientX; this.startY = clientY; }; this.mouseMoveHandler = (e) => { if (this.mousePressed) { const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; const deltaX = Math.abs(clientX - this.startX); const deltaY = Math.abs(clientY - this.startY); if (deltaX > 5 || deltaY > 5) { this.isDragging = true; } } }; this.mouseUpHandler = () => { this.mousePressed = false; setTimeout(() => { this.isDragging = false; }, 10); }; this.content.addEventListener('mousedown', this.mouseDownHandler); this.content.addEventListener('mousemove', this.mouseMoveHandler); this.content.addEventListener('mouseup', this.mouseUpHandler); this.content.addEventListener('touchstart', this.mouseDownHandler); this.content.addEventListener('touchmove', this.mouseMoveHandler); this.content.addEventListener('touchend', this.mouseUpHandler); } destroyPanzoom() { if (this.panzoomInstance) { this.content.removeEventListener('mousedown', this.mouseDownHandler); this.content.removeEventListener('mousemove', this.mouseMoveHandler); this.content.removeEventListener('mouseup', this.mouseUpHandler); this.content.removeEventListener('touchstart', this.mouseDownHandler); this.content.removeEventListener('touchmove', this.mouseMoveHandler); this.content.removeEventListener('touchend', this.mouseUpHandler); this.panzoomInstance.dispose(); this.panzoomInstance = null; } } setupEventListeners() { this.backBtn.addEventListener('click', () => { this.loadStadiumView