ticket-selector
Version:
A professional stadium seat selection widget with multi-language support
1,558 lines (1,311 loc) • 59.3 kB
JavaScript
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> <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