PluginProbe ʕ •ᴥ•ʔ
Event Tickets with Ticket Scanner / 3.1.2
Event Tickets with Ticket Scanner v3.1.2
3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.4 trunk 2.6.0 2.7.0 2.7.1 2.7.10 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.10 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.2 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7 2.9.8 2.9.9 3.0.0 3.0.1 3.0.2 3.0.3
event-tickets-with-ticket-scanner / js / seating_frontend.js
event-tickets-with-ticket-scanner / js Last commit date
congress-admin.js 1 week ago congress-frontend.js 1 week ago seating_admin.js 1 week ago seating_designer.js 1 week ago seating_frontend.js 1 week ago
seating_frontend.js
1825 lines
1 /**
2 * Seating Frontend JavaScript
3 *
4 * Handles seat selection UI on product pages.
5 *
6 * @package Event_Tickets_With_Ticket_Scanner
7 * @since 2.8.0
8 */
9
10 (function($) {
11 'use strict';
12
13 // WordPress i18n
14 var __ = wp.i18n.__;
15 var _x = wp.i18n._x;
16
17 /**
18 * Seating Frontend Module
19 */
20 var SasoSeatingFrontend = {
21 /**
22 * Current seat block IDs (for releasing previous selections)
23 * Map of seat_id => block_id
24 */
25 currentBlockIds: {},
26
27 /**
28 * Currently selected seats data (array for multi-select)
29 */
30 selectedSeats: [],
31
32 /**
33 * Temporary selections in modal (before confirm)
34 */
35 tempSelections: [],
36
37 /**
38 * Maximum number of seats to select (from quantity input)
39 */
40 maxSeats: 1,
41
42 /**
43 * Reference to current modal container
44 */
45 $currentModal: null,
46
47 /**
48 * Reference to current selector container
49 */
50 $currentSelector: null,
51
52 /**
53 * Interval ID for auto-refresh while modal is open
54 */
55 refreshIntervalId: null,
56
57 /**
58 * Interval ID for countdown timer
59 */
60 countdownIntervalId: null,
61
62 /**
63 * Auto-refresh interval in milliseconds (30 seconds)
64 */
65 REFRESH_INTERVAL: 30000,
66
67 /**
68 * Plan colors (loaded from data attributes)
69 */
70 planColors: {
71 available: '#4CAF50',
72 reserved: '#FFC107',
73 booked: '#F44336',
74 selected: '#2196F3'
75 },
76
77 /**
78 * Initialize the module
79 */
80 init: function() {
81 this.initSelectors();
82 this.bindEvents();
83 this.initializeExistingSelections();
84 this.initCartCountdowns();
85 this.initLockedDatepickers();
86 this.initHeartbeat();
87 },
88
89 /**
90 * Initialize WordPress Heartbeat API integration
91 * Sends block IDs with each heartbeat to update last_seen on server
92 */
93 initHeartbeat: function() {
94 var self = this;
95
96 // Hook into heartbeat-send to add our data
97 $(document).on('heartbeat-send', function(e, data) {
98 // Get all active block IDs
99 var blockIds = Object.values(self.currentBlockIds);
100 if (blockIds.length > 0) {
101 data.saso_seating_blocks = blockIds;
102 }
103 });
104
105 // Optionally handle response (for debugging or future use)
106 $(document).on('heartbeat-tick', function(e, data) {
107 if (data.saso_seating) {
108 // Server acknowledged our blocks
109 // Could be used to handle expired blocks in the future
110 }
111 });
112 },
113
114 /**
115 * Disable datepickers that have seats selected (date change would invalidate blocks)
116 */
117 initLockedDatepickers: function() {
118 $('.saso-datepicker-locked input').prop('disabled', true);
119 },
120
121 /**
122 * Initialize countdown timers for cart/checkout pages
123 * Finds any existing countdown elements and starts the timer
124 */
125 initCartCountdowns: function() {
126 var self = this;
127
128 // Find all countdown containers (e.g., in cart)
129 $('.saso-selected-seats-labels').each(function() {
130 var $container = $(this);
131 // Only init if has countdown elements and not already part of a selector
132 if ($container.find('.saso-seat-countdown').length > 0 &&
133 !$container.closest('.saso-seating-selector').length) {
134
135 // Stop any existing timer first (important for AJAX reloads)
136 self.stopCountdownTimer($container);
137
138 // Convert remaining-seconds to countdown-end (client timestamp)
139 // Use .attr() instead of .data() to get fresh DOM values after AJAX update
140 $container.find('.saso-seat-countdown[data-remaining-seconds]').each(function() {
141 var $countdown = $(this);
142 var remainingSeconds = parseInt($countdown.attr('data-remaining-seconds')) || 0;
143 if (remainingSeconds > 0) {
144 var countdownEnd = Date.now() + (remainingSeconds * 1000);
145 // Update both cache and attribute
146 $countdown.data('countdown-end', countdownEnd);
147 $countdown.attr('data-countdown-end', countdownEnd);
148 }
149 });
150
151 self.startCountdownTimer($container);
152 }
153 });
154 },
155
156 /**
157 * Initialize all seating selectors on the page
158 * Reads JSON data from script tags and builds UI
159 */
160 initSelectors: function() {
161 var self = this;
162
163 $('.saso-seating-selector').each(function() {
164 var $selector = $(this);
165 var selectorId = $selector.attr('id');
166
167 if (!selectorId) {
168 return;
169 }
170
171 // Get JSON data from associated script tag
172 var $dataScript = $('#' + selectorId + '-data');
173 if (!$dataScript.length) {
174 return;
175 }
176
177 try {
178 var data = JSON.parse($dataScript.text());
179 self.buildSelectorUI($selector, data);
180 } catch (e) {
181 console.error('SasoSeating: Failed to parse selector data', e);
182 }
183 });
184 },
185
186 /**
187 * Build the selector UI from data
188 *
189 * @param {jQuery} $selector The selector container
190 * @param {Object} data Plan and seats data from PHP
191 */
192 buildSelectorUI: function($selector, data) {
193 var html = '';
194
195 // Store data on element for later access
196 $selector.data('plan-data', data);
197 $selector.attr('data-layout', data.layoutType);
198 $selector.attr('data-plan-id', data.planId);
199
200 // Check if date is required but not yet selected
201 var $wrapper = $selector.closest('.saso-seating-wrapper');
202 var requiresDate = $wrapper.data('requires-date') == '1';
203 var eventDate = $selector.data('event-date');
204 var dateNotSelected = requiresDate && !eventDate;
205
206 // Admin preview notice
207 if (data.isPreview) {
208 html += '<div class="saso-seating-preview-notice">' +
209 '<strong>⚠️ ' + __('Admin Preview', 'event-tickets-with-ticket-scanner') + ':</strong> ' +
210 __('This seating plan is not published yet.', 'event-tickets-with-ticket-scanner') + ' ' +
211 '<a href="' + this.escapeHtml(data.adminUrl) + '" target="_blank">' +
212 __('Publish plan', 'event-tickets-with-ticket-scanner') + '</a>' +
213 '</div>';
214 }
215
216 // Label
217 html += '<label class="saso-seating-label">' +
218 __('Select your seat:', 'event-tickets-with-ticket-scanner');
219 if (data.isRequired) {
220 html += ' <span class="required">*</span>';
221 }
222 html += '</label>';
223
224 // Venue plan image button (if set) - always visible
225 if (data.planImage) {
226 html += '<div class="saso-plan-image-preview">' +
227 '<button type="button" class="button saso-view-plan-image" data-image="' + this.escapeHtml(data.planImage) + '">' +
228 __('View Venue Plan', 'event-tickets-with-ticket-scanner') +
229 '</button></div>';
230 }
231
232 // If date required but not selected, show message instead of selector
233 if (dateNotSelected) {
234 html += '<div class="saso-seating-date-required">' +
235 __('Please select a date first to see available seats.', 'event-tickets-with-ticket-scanner') +
236 '</div>';
237 } else {
238 // Build layout-specific UI
239 if (data.layoutType === 'simple') {
240 html += this.buildSimpleSelector(data);
241 } else {
242 html += this.buildVisualSelector(data);
243 }
244 }
245
246 // Status area
247 html += '<div class="saso-seating-status"></div>';
248
249 // Insert HTML before the hidden input
250 $selector.find('.saso-seat-selection-input').before(html);
251
252 // Initialize colors for visual layout
253 if (data.layoutType === 'visual') {
254 this.planColors = {
255 available: (data.meta.colors && data.meta.colors.available) || '#4CAF50',
256 reserved: (data.meta.colors && data.meta.colors.reserved) || '#FFC107',
257 booked: (data.meta.colors && data.meta.colors.booked) || '#F44336',
258 selected: (data.meta.colors && data.meta.colors.selected) || '#2196F3'
259 };
260 }
261 },
262
263 /**
264 * Build simple dropdown selector HTML
265 *
266 * @param {Object} data Plan and seats data
267 * @returns {string} HTML string
268 */
269 buildSimpleSelector: function(data) {
270 var self = this;
271 var selectedId = data.currentSelection ? (data.currentSelection.seat_id || (data.currentSelection[0] && data.currentSelection[0].seat_id)) : '';
272
273 var html = '<select class="saso-seat-dropdown" name="saso_seat_id">';
274 html += '<option value="">' + __('-- Select Seat --', 'event-tickets-with-ticket-scanner') + '</option>';
275
276 data.seats.forEach(function(seat) {
277 var isAvailable = seat.availability === 'free';
278 var isSelected = String(seat.id) === String(selectedId);
279 var label = (seat.meta && seat.meta.seat_label) || seat.seat_identifier;
280 if (seat.meta && seat.meta.seat_category) {
281 label += ' (' + seat.meta.seat_category + ')';
282 }
283
284 var statusText = '';
285 if (!isAvailable && !isSelected) {
286 statusText = seat.availability === 'sold'
287 ? ' (' + __('Sold', 'event-tickets-with-ticket-scanner') + ')'
288 : ' (' + __('Reserved', 'event-tickets-with-ticket-scanner') + ')';
289 }
290
291 html += '<option value="' + seat.id + '"' +
292 ' data-seat-label="' + self.escapeHtml((seat.meta && seat.meta.seat_label) || seat.seat_identifier) + '"' +
293 ' data-seat-category="' + self.escapeHtml((seat.meta && seat.meta.seat_category) || '') + '"' +
294 ' data-seat-desc="' + self.escapeHtml((seat.meta && seat.meta.seat_desc) || '') + '"' +
295 (isSelected ? ' selected' : '') +
296 (!isAvailable && !isSelected ? ' disabled' : '') +
297 '>' + self.escapeHtml(label + statusText) + '</option>';
298 });
299
300 html += '</select>';
301 return html;
302 },
303
304 /**
305 * Build visual seat map selector HTML
306 *
307 * @param {Object} data Plan and seats data
308 * @returns {string} HTML string
309 */
310 buildVisualSelector: function(data) {
311 var self = this;
312 var meta = data.meta || {};
313 var colors = meta.colors || {};
314
315 var colorAvailable = colors.available || '#4CAF50';
316 var colorReserved = colors.reserved || '#FFC107';
317 var colorBooked = colors.booked || '#F44336';
318 var colorSelected = colors.selected || '#2196F3';
319
320 var selectedId = data.currentSelection ? (data.currentSelection.seat_id || (data.currentSelection[0] && data.currentSelection[0].seat_id)) : '';
321
322 // Container with color data attributes
323 var html = '<div class="saso-seat-visual-container"' +
324 ' data-color-available="' + colorAvailable + '"' +
325 ' data-color-reserved="' + colorReserved + '"' +
326 ' data-color-booked="' + colorBooked + '"' +
327 ' data-color-selected="' + colorSelected + '">';
328
329 // Button to open map
330 var buttonText = __('Open Seat Map', 'event-tickets-with-ticket-scanner');
331 if (selectedId && data.seats) {
332 var selectedSeat = data.seats.find(function(s) { return String(s.id) === String(selectedId); });
333 if (selectedSeat) {
334 var seatLabel = (selectedSeat.meta && selectedSeat.meta.seat_label) || selectedSeat.seat_identifier;
335 buttonText = __('Selected: {label} - Click to change', 'event-tickets-with-ticket-scanner').replace('{label}', seatLabel);
336 }
337 }
338 html += '<button type="button" class="button saso-open-seat-map">' + this.escapeHtml(buttonText) + '</button>';
339 html += '</div>';
340
341 // Modal (hidden by default)
342 html += '<div class="saso-seat-map-modal">';
343 html += '<div class="saso-seat-map-header">';
344 html += '<h3>' + this.escapeHtml(data.planName) + '</h3>';
345 html += '<button type="button" class="saso-close-modal">&times;</button>';
346 html += '</div>';
347
348 html += '<div class="saso-seat-map-body">';
349 html += this.buildSvgMap(data, selectedId, colorAvailable, colorReserved, colorBooked, colorSelected);
350 html += '</div>';
351
352 // Legend
353 html += '<div class="saso-seat-map-legend">';
354 html += '<span class="legend-item"><span class="legend-color free"></span> ' + __('Available', 'event-tickets-with-ticket-scanner') + '</span>';
355 html += '<span class="legend-item"><span class="legend-color blocked"></span> ' + __('Reserved', 'event-tickets-with-ticket-scanner') + '</span>';
356 html += '<span class="legend-item"><span class="legend-color sold"></span> ' + __('Sold', 'event-tickets-with-ticket-scanner') + '</span>';
357 html += '<span class="legend-item"><span class="legend-color selected"></span> ' + __('Your Selection', 'event-tickets-with-ticket-scanner') + '</span>';
358 html += '</div>';
359
360 // Footer
361 html += '<div class="saso-seat-map-footer">';
362 html += '<div class="saso-seat-info">' + __('Click a seat to select it', 'event-tickets-with-ticket-scanner') + '</div>';
363 html += '<div class="saso-seat-map-actions">';
364 html += '<button type="button" class="button saso-cancel-selection">' + __('Cancel', 'event-tickets-with-ticket-scanner') + '</button>';
365 html += '<button type="button" class="button button-primary saso-confirm-selection" disabled>' + __('Confirm Selection', 'event-tickets-with-ticket-scanner') + '</button>';
366 html += '</div></div>';
367
368 html += '</div>'; // .saso-seat-map-modal
369
370 return html;
371 },
372
373 /**
374 * Build SVG seat map
375 *
376 * @param {Object} data Plan data
377 * @param {string} selectedId Currently selected seat ID
378 * @param {string} colorAvailable Color for available seats
379 * @param {string} colorReserved Color for reserved seats
380 * @param {string} colorBooked Color for sold seats
381 * @param {string} colorSelected Color for selected seats
382 * @returns {string} SVG HTML string
383 */
384 buildSvgMap: function(data, selectedId, colorAvailable, colorReserved, colorBooked, colorSelected) {
385 var self = this;
386 var meta = data.meta || {};
387 var width = meta.canvas_width || 800;
388 var height = meta.canvas_height || 600;
389 var bgColor = meta.background_color || '#ffffff';
390 var bgImage = meta.background_image || '';
391
392 var svg = '<svg class="saso-seat-map" viewBox="0 0 ' + width + ' ' + height + '" style="background-color: ' + bgColor + ';">';
393
394 // Background image
395 if (bgImage) {
396 svg += '<image href="' + this.escapeHtml(bgImage) + '" x="0" y="0" width="' + width + '" height="' + height + '" preserveAspectRatio="xMidYMid meet" />';
397 }
398
399 // Decorations layer
400 (meta.decorations || []).forEach(function(el) {
401 svg += self.buildSvgElement(el);
402 });
403
404 // Lines layer
405 (meta.lines || []).forEach(function(el) {
406 svg += self.buildSvgElement(el);
407 });
408
409 // Labels layer
410 (meta.labels || []).forEach(function(el) {
411 svg += self.buildSvgElement(el);
412 });
413
414 // Seats layer
415 data.seats.forEach(function(seat) {
416 svg += self.buildSeatElement(seat, selectedId, colorAvailable, colorReserved, colorBooked, colorSelected);
417 });
418
419 svg += '</svg>';
420 return svg;
421 },
422
423 /**
424 * Build SVG element (decoration, line, label)
425 *
426 * @param {Object} el Element data
427 * @returns {string} SVG element string
428 */
429 buildSvgElement: function(el) {
430 var type = el.type || 'rect';
431 var x = parseFloat(el.x) || 0;
432 var y = parseFloat(el.y) || 0;
433 var fill = el.fill || 'transparent';
434 var stroke = el.stroke || 'none';
435 var strokeWidth = el.strokeWidth || 1;
436 var fillOpacity = el.fillOpacity !== undefined ? (parseFloat(el.fillOpacity) / 100) : 1;
437 var strokeOpacity = el.strokeOpacity !== undefined ? (parseFloat(el.strokeOpacity) / 100) : 0;
438
439 var svg = '';
440
441 switch (type) {
442 case 'rect':
443 var rw = parseFloat(el.width) || 50;
444 var rh = parseFloat(el.height) || 50;
445 var rx = el.rx || 0;
446 svg = '<rect x="' + x + '" y="' + y + '" width="' + rw + '" height="' + rh + '" rx="' + rx + '" fill="' + fill + '" fill-opacity="' + fillOpacity + '" stroke="' + stroke + '" stroke-opacity="' + strokeOpacity + '" stroke-width="' + (strokeOpacity > 0 ? strokeWidth : 0) + '" />';
447 if (el.label) {
448 svg += this.buildLabelText(x + rw/2, y + rh/2, el.label, el);
449 }
450 break;
451
452 case 'circle':
453 var r = parseFloat(el.r) || 25;
454 var cx = x + r;
455 var cy = y + r;
456 svg = '<circle cx="' + cx + '" cy="' + cy + '" r="' + r + '" fill="' + fill + '" fill-opacity="' + fillOpacity + '" stroke="' + stroke + '" stroke-opacity="' + strokeOpacity + '" stroke-width="' + (strokeOpacity > 0 ? strokeWidth : 0) + '" />';
457 if (el.label) {
458 svg += this.buildLabelText(cx, cy, el.label, el);
459 }
460 break;
461
462 case 'ellipse':
463 var erx = parseFloat(el.rx) || 50;
464 var ery = parseFloat(el.ry) || 30;
465 var ecx = parseFloat(el.cx) || (x + erx);
466 var ecy = parseFloat(el.cy) || (y + ery);
467 svg = '<ellipse cx="' + ecx + '" cy="' + ecy + '" rx="' + erx + '" ry="' + ery + '" fill="' + fill + '" fill-opacity="' + fillOpacity + '" stroke="' + stroke + '" stroke-opacity="' + strokeOpacity + '" stroke-width="' + (strokeOpacity > 0 ? strokeWidth : 0) + '" />';
468 if (el.label) {
469 svg += this.buildLabelText(ecx, ecy, el.label, el);
470 }
471 break;
472
473 case 'line':
474 var x1 = parseFloat(el.x1) || 0;
475 var y1 = parseFloat(el.y1) || 0;
476 var x2 = parseFloat(el.x2) || 100;
477 var y2 = parseFloat(el.y2) || 100;
478 var lineStrokeOpacity = el.strokeOpacity !== undefined ? (parseFloat(el.strokeOpacity) / 100) : 1;
479 svg = '<line x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '" stroke="' + stroke + '" stroke-opacity="' + lineStrokeOpacity + '" stroke-width="' + strokeWidth + '" stroke-linecap="round" />';
480 if (el.label) {
481 svg += this.buildLabelText((x1+x2)/2, (y1+y2)/2, el.label, el);
482 }
483 break;
484
485 case 'text':
486 var fontSize = el.fontSize || 14;
487 var fontFamily = el.fontFamily || 'sans-serif';
488 var textAnchor = el.textAnchor || 'start';
489 svg = '<text x="' + x + '" y="' + y + '" fill="' + fill + '" fill-opacity="' + fillOpacity + '" font-size="' + fontSize + '" font-family="' + fontFamily + '" text-anchor="' + textAnchor + '">' + this.escapeHtml(el.text || '') + '</text>';
490 break;
491
492 case 'image':
493 var iw = el.width || 100;
494 var ih = el.height || 100;
495 svg = '<image href="' + this.escapeHtml(el.href || '') + '" x="' + x + '" y="' + y + '" width="' + iw + '" height="' + ih + '" opacity="' + fillOpacity + '" />';
496 break;
497 }
498
499 return svg;
500 },
501
502 /**
503 * Build label text element
504 *
505 * @param {number} x Center X
506 * @param {number} y Center Y
507 * @param {string} text Label text
508 * @param {Object} el Original element for color settings
509 * @returns {string} SVG text element
510 */
511 buildLabelText: function(x, y, text, el) {
512 var fillColor = el.labelColor || '#333333';
513 var strokeColor = el.labelStroke || '#ffffff';
514 var fillOpacity = el.labelColorOpacity !== undefined ? (parseFloat(el.labelColorOpacity) / 100) : 1;
515 var strokeOpacity = el.labelStrokeOpacity !== undefined ? (parseFloat(el.labelStrokeOpacity) / 100) : 0.5;
516
517 return '<text x="' + x + '" y="' + y + '" text-anchor="middle" dominant-baseline="middle" fill="' + fillColor + '" fill-opacity="' + fillOpacity + '" stroke="' + strokeColor + '" stroke-opacity="' + strokeOpacity + '" stroke-width="2" paint-order="stroke" font-size="10" font-weight="bold" pointer-events="none" class="saso-element-label">' + this.escapeHtml(text) + '</text>';
518 },
519
520 /**
521 * Build seat SVG element
522 *
523 * @param {Object} seat Seat data
524 * @param {string} selectedId Currently selected seat ID
525 * @param {string} colorAvailable Available color
526 * @param {string} colorReserved Reserved color
527 * @param {string} colorBooked Booked color
528 * @param {string} colorSelected Selected color
529 * @returns {string} SVG elements for seat
530 */
531 buildSeatElement: function(seat, selectedId, colorAvailable, colorReserved, colorBooked, colorSelected) {
532 var meta = seat.meta || {};
533 var posX = parseFloat(meta.pos_x) || 0;
534 var posY = parseFloat(meta.pos_y) || 0;
535 var shapeConfig = meta.shape_config || {width: 30, height: 30};
536 var shapeType = meta.shape_type || 'rect';
537 var seatWidth = parseFloat(shapeConfig.width) || 30;
538 var seatHeight = parseFloat(shapeConfig.height) || 30;
539 var seatLabel = meta.seat_label || seat.seat_identifier;
540
541 var isAvailable = seat.availability === 'free';
542 var isSelected = String(seat.id) === String(selectedId);
543 var statusClass = isSelected ? 'selected' : (isAvailable ? 'free' : (seat.availability === 'sold' ? 'sold' : 'blocked'));
544
545 var seatOwnColor = meta.color || colorAvailable;
546 var fillColor;
547 if (isSelected) {
548 fillColor = colorSelected;
549 } else if (isAvailable) {
550 fillColor = seatOwnColor;
551 } else if (seat.availability === 'sold') {
552 fillColor = colorBooked;
553 } else {
554 fillColor = colorReserved;
555 }
556
557 var strokeColor = isSelected ? '#000' : 'transparent';
558 var strokeWidth = isSelected ? '2' : '0';
559
560 var dataAttrs = 'class="saso-seat ' + statusClass + '" data-seat-id="' + seat.id + '" data-seat-label="' + this.escapeHtml(seatLabel) + '" data-seat-category="' + this.escapeHtml(meta.seat_category || '') + '" data-seat-desc="' + this.escapeHtml(meta.seat_desc || '') + '" data-available="' + (isAvailable ? '1' : '0') + '" data-original-color="' + seatOwnColor + '"';
561
562 var svg = '';
563 var textX, textY;
564
565 if (shapeType === 'circle') {
566 var r = seatWidth / 2;
567 var cx = posX + r;
568 var cy = posY + r;
569 textX = cx;
570 textY = cy;
571 var tooltipText = this.escapeHtml(seatLabel);
572 if (sasoSeatingData.showSeatDescInChooser && meta.seat_desc) {
573 tooltipText += '\n' + this.escapeHtml(meta.seat_desc);
574 }
575 svg = '<circle ' + dataAttrs + ' cx="' + cx + '" cy="' + cy + '" r="' + r + '" fill="' + fillColor + '" stroke="' + strokeColor + '" stroke-width="' + strokeWidth + '"><title>' + tooltipText + '</title></circle>';
576 } else {
577 textX = posX + seatWidth / 2;
578 textY = posY + seatHeight / 2;
579 var tooltipTextRect = this.escapeHtml(seatLabel);
580 if (sasoSeatingData.showSeatDescInChooser && meta.seat_desc) {
581 tooltipTextRect += '\n' + this.escapeHtml(meta.seat_desc);
582 }
583 svg = '<rect ' + dataAttrs + ' x="' + posX + '" y="' + posY + '" width="' + seatWidth + '" height="' + seatHeight + '" rx="3" ry="3" fill="' + fillColor + '" stroke="' + strokeColor + '" stroke-width="' + strokeWidth + '"><title>' + tooltipTextRect + '</title></rect>';
584 }
585
586 // Seat label text
587 var fontSize = Math.min(14, Math.max(8, Math.min(seatWidth, seatHeight) / 3));
588 svg += '<text class="saso-seat-label" x="' + textX + '" y="' + textY + '" text-anchor="middle" dominant-baseline="central" font-size="' + fontSize + '" fill="#fff" pointer-events="none">' + this.escapeHtml(seatLabel) + '</text>';
589
590 return svg;
591 },
592
593 /**
594 * Bind all event handlers
595 */
596 bindEvents: function() {
597 var self = this;
598
599 // Simple selector (dropdown)
600 $(document).on('change', '.saso-seat-dropdown', function(e) {
601 var $selector = $(this).closest('.saso-seating-selector');
602
603 // Check if date is required but not selected
604 var $wrapper = $selector.closest('.saso-seating-wrapper');
605 if ($wrapper.data('requires-date') == '1') {
606 var eventDate = self.getEventDate($selector);
607 if (!eventDate) {
608 $(this).val(''); // Reset dropdown
609 self.setStatus($selector, __('Please select a date first', 'event-tickets-with-ticket-scanner'), 'error');
610 return;
611 }
612 }
613
614 self.onDropdownChange(e);
615 });
616
617 // Visual selector - Open modal
618 $(document).on('click', '.saso-open-seat-map', function(e) {
619 e.preventDefault();
620 var $selector = $(this).closest('.saso-seating-selector');
621
622 // Check if date is required but not selected
623 var $wrapper = $selector.closest('.saso-seating-wrapper');
624 if ($wrapper.data('requires-date') == '1') {
625 var eventDate = self.getEventDate($selector);
626 if (!eventDate) {
627 self.setStatus($selector, __('Please select a date first', 'event-tickets-with-ticket-scanner'), 'error');
628 return;
629 }
630 }
631
632 self.openModal($selector);
633 });
634
635 // Ensure seat selection is submitted with form
636 $(document).on('submit', 'form.cart', function(e) {
637 var $form = $(this);
638 var fieldName = sasoSeatingData.fieldName || 'sasoEventtickets_seat_selection';
639 var $input = $form.find('input[name="' + fieldName + '"]');
640 var inputValue = $input.length ? $input.val() : '';
641
642 // If no input in form, check in selector and copy/create
643 if (!$input.length) {
644 var $selectorInput = $form.find('.saso-seat-selection-input');
645 if ($selectorInput.length) {
646 inputValue = $selectorInput.val();
647 }
648
649 // Create hidden input in form
650 if (self.selectedSeats && self.selectedSeats.length > 0) {
651 inputValue = JSON.stringify(self.selectedSeats);
652 }
653
654 if (inputValue) {
655 $form.append('<input type="hidden" name="' + fieldName + '" value="' + inputValue.replace(/"/g, '&quot;') + '">');
656 }
657 } else if ((!inputValue || inputValue === '') && self.selectedSeats && self.selectedSeats.length > 0) {
658 // Input exists but empty - fill it
659 var jsonValue = JSON.stringify(self.selectedSeats);
660 $input.val(jsonValue);
661 }
662 });
663
664 // Visual selector - Close modal
665 $(document).on('click', '.saso-close-modal, .saso-cancel-selection', function(e) {
666 e.preventDefault();
667 self.closeModal();
668 });
669
670 // Visual selector - Seat click
671 $(document).on('click', '.saso-seat[data-available="1"]', function(e) {
672 self.onSeatClick($(this));
673 });
674
675 // Visual selector - Confirm selection
676 $(document).on('click', '.saso-confirm-selection', function(e) {
677 e.preventDefault();
678 self.confirmSelection();
679 });
680
681 // Close modal on overlay click
682 $(document).on('click', '.saso-seat-map-overlay', function(e) {
683 if ($(e.target).hasClass('saso-seat-map-overlay')) {
684 self.closeModal();
685 }
686 });
687
688 // Close modal on Escape key
689 $(document).on('keydown', function(e) {
690 if (e.key === 'Escape') {
691 if (self.$currentModal) {
692 self.closeModal();
693 }
694 // Also close plan image lightbox
695 $('.saso-plan-image-lightbox').remove();
696 }
697 });
698
699 // View plan image button
700 $(document).on('click', '.saso-view-plan-image', function(e) {
701 e.preventDefault();
702 var imageUrl = $(this).data('image');
703 if (imageUrl) {
704 self.showPlanImage(imageUrl);
705 }
706 });
707
708 // Close plan image lightbox on click
709 $(document).on('click', '.saso-plan-image-lightbox', function(e) {
710 if ($(e.target).hasClass('saso-plan-image-lightbox') || $(e.target).hasClass('saso-lightbox-close')) {
711 $(this).remove();
712 }
713 });
714
715 // Update seat availability when event date changes
716 // Note: Daychooser input has name="event_date_PRODUCT_ID", so we use starts-with selector
717 $(document).on('change', '[data-input-type="daychooser"], [name^="event_date"], .hasDatepicker', function() {
718 self.onEventDateChange($(this));
719 });
720 },
721
722 /**
723 * Initialize any existing selections (e.g., from cart or session blocks)
724 */
725 initializeExistingSelections: function() {
726 var self = this;
727
728 $('.saso-seating-selector').each(function() {
729 var $selector = $(this);
730 var $input = $selector.find('.saso-seat-selection-input');
731 var existingValue = $input.val();
732 var planData = $selector.data('plan-data');
733
734 // First, check hidden input (cart/form data)
735 if (existingValue) {
736 try {
737 var data = JSON.parse(existingValue);
738 var seats = [];
739
740 // Support both single seat (legacy) and array of seats
741 if (Array.isArray(data)) {
742 seats = data;
743 } else if (data && data.seat_id) {
744 // Legacy single seat format
745 seats = [data];
746 }
747
748 // Filter out expired seats (use countdown_end if available)
749 var validSeats = seats.filter(function(seat) {
750 if (!seat.countdown_end) {
751 return true; // No countdown info, keep it
752 }
753 var remaining = self.getTimeRemaining(seat.countdown_end);
754 return remaining > 0;
755 });
756
757 // Update block IDs for valid seats only
758 validSeats.forEach(function(seat) {
759 if (seat.block_id) {
760 self.currentBlockIds[seat.seat_id] = seat.block_id;
761 }
762 });
763
764 self.selectedSeats = validSeats;
765
766 // Update hidden input if some seats were removed (expired)
767 if (validSeats.length !== seats.length) {
768 $input.val(validSeats.length > 0 ? JSON.stringify(validSeats) : '');
769 }
770
771 self.updateButtonText($selector, self.selectedSeats);
772 return; // Done with this selector
773 } catch (e) {
774 // Invalid JSON, continue to check existingBlocks
775 }
776 }
777
778 // If no cart selection, check for existing session blocks (user blocked but didn't add to cart yet)
779 if (planData && planData.existingBlocks && planData.existingBlocks.length > 0) {
780 var restoredSeats = [];
781
782 planData.existingBlocks.forEach(function(block) {
783 // Calculate countdown_end from remaining_seconds (same pattern as AJAX response)
784 var remainingSeconds = block.remaining_seconds || 0;
785 if (remainingSeconds <= 0) {
786 return; // Skip expired blocks
787 }
788 var countdownEnd = Date.now() + (remainingSeconds * 1000);
789
790 var seatData = {
791 seat_id: block.seat_id,
792 seat_label: block.seat_label || '',
793 seat_category: block.seat_category || '',
794 seat_desc: block.seat_desc || '',
795 block_id: block.block_id,
796 expires_at: block.expires_at,
797 countdown_end: countdownEnd
798 };
799
800 // Track block ID
801 self.currentBlockIds[block.seat_id] = block.block_id;
802 restoredSeats.push(seatData);
803 });
804
805 if (restoredSeats.length > 0) {
806 self.selectedSeats = restoredSeats;
807 // Update hidden input with restored selection
808 $input.val(JSON.stringify(restoredSeats));
809 self.updateButtonText($selector, restoredSeats);
810 }
811 }
812 });
813 },
814
815 /**
816 * Handle dropdown change (simple layout)
817 *
818 * @param {Event} e Change event
819 */
820 onDropdownChange: function(e) {
821 var self = this;
822 var $dropdown = $(e.target);
823 var $selector = $dropdown.closest('.saso-seating-selector');
824 var seatId = $dropdown.val();
825
826 if (!seatId) {
827 // Clear selection
828 this.clearSelection($selector);
829 return;
830 }
831
832 var $option = $dropdown.find('option:selected');
833 var seatData = {
834 seat_id: parseInt(seatId),
835 seat_label: $option.data('seat-label') || $option.text(),
836 seat_category: $option.data('seat-category') || '',
837 seat_desc: $option.data('seat-desc') || ''
838 };
839
840 this.selectSeat($selector, seatData);
841 },
842
843 /**
844 * Open the visual seat map modal
845 *
846 * @param {jQuery} $selector The selector container
847 */
848 openModal: function($selector) {
849 var self = this;
850 this.$currentSelector = $selector;
851 this.$currentModal = $selector.find('.saso-seat-map-modal');
852
853 if (!this.$currentModal.length) {
854 return;
855 }
856
857 // Load plan colors from data attributes
858 var $visualContainer = $selector.find('.saso-seat-visual-container');
859 this.planColors = {
860 available: $visualContainer.data('color-available') || '#4CAF50',
861 reserved: $visualContainer.data('color-reserved') || '#FFC107',
862 booked: $visualContainer.data('color-booked') || '#F44336',
863 selected: $visualContainer.data('color-selected') || '#2196F3'
864 };
865
866 // Get quantity from form (default 1)
867 var $form = $selector.closest('form');
868 var $qtyInput = $form.find('input[name="quantity"]');
869 this.maxSeats = $qtyInput.length ? parseInt($qtyInput.val()) || 1 : 1;
870
871 // Restore previous selections as temp selections
872 this.tempSelections = this.selectedSeats.slice();
873
874 // Create overlay if not exists
875 if (!$('.saso-seat-map-overlay').length) {
876 $('body').append('<div class="saso-seat-map-overlay"></div>');
877 }
878
879 // Show modal and overlay
880 $('.saso-seat-map-overlay').addClass('active');
881 this.$currentModal.addClass('active');
882
883 // Disable body scroll
884 $('body').css('overflow', 'hidden');
885
886 // Restore visual selection state for previously selected seats
887 this.restoreVisualSelections();
888
889 // Update confirm button state
890 var canConfirm = this.tempSelections.length === this.maxSeats;
891 this.$currentModal.find('.saso-confirm-selection').prop('disabled', !canConfirm);
892
893 // Update seat info text with counter
894 this.updateModalSeatInfo();
895
896 // Focus trap for accessibility
897 this.$currentModal.find('.saso-close-modal').focus();
898
899 // Refresh availability immediately when opening
900 this.refreshAvailability($selector);
901
902 // Start auto-refresh interval (every 30 seconds)
903 this.startAutoRefresh($selector);
904 },
905
906 /**
907 * Restore visual selection state for temp selections
908 */
909 restoreVisualSelections: function() {
910 var self = this;
911
912 if (!this.$currentModal) {
913 return;
914 }
915
916 // Clear all temp-selected first and reset colors
917 this.$currentModal.find('.saso-seat.temp-selected').each(function() {
918 var $el = $(this);
919 $el.removeClass('temp-selected');
920 var origColor = $el.data('original-color') || self.planColors.available;
921 $el.attr('fill', origColor);
922 });
923
924 // Mark each selected seat and update its color
925 this.tempSelections.forEach(function(seat) {
926 var seatId = String(seat.seat_id); // Ensure string for attribute selector
927 var $seat = self.$currentModal.find('.saso-seat[data-seat-id="' + seatId + '"]');
928 if ($seat.length) {
929 $seat.addClass('temp-selected');
930 $seat.attr('fill', self.planColors.selected);
931 }
932 });
933 },
934
935 /**
936 * Close the modal
937 */
938 closeModal: function() {
939 if (!this.$currentModal) {
940 return;
941 }
942
943 // Stop auto-refresh
944 this.stopAutoRefresh();
945
946 // Hide modal and overlay
947 this.$currentModal.removeClass('active');
948 $('.saso-seat-map-overlay').removeClass('active');
949
950 // Re-enable body scroll
951 $('body').css('overflow', '');
952
953 // Clear temp selection visual
954 this.$currentModal.find('.saso-seat').removeClass('temp-selected');
955
956 // Reset references
957 this.tempSelections = [];
958 this.$currentModal = null;
959 },
960
961 /**
962 * Handle seat click in modal (toggle selection)
963 *
964 * @param {jQuery} $seat The clicked seat element
965 */
966 onSeatClick: function($seat) {
967 if (!this.$currentModal) {
968 return;
969 }
970
971 var seatId = parseInt($seat.data('seat-id'));
972 var seatData = {
973 seat_id: seatId,
974 seat_label: $seat.data('seat-label'),
975 seat_category: $seat.data('seat-category') || '',
976 seat_desc: $seat.data('seat-desc') || ''
977 };
978
979 // Check if seat is already selected (toggle behavior)
980 var existingIndex = this.findSeatIndex(this.tempSelections, seatId);
981
982 if (existingIndex !== -1) {
983 // Seat already selected - check if deselection is allowed
984 var lockSelectedSeats = typeof sasoSeatingData !== 'undefined' && sasoSeatingData.lockSelectedSeats;
985 if (lockSelectedSeats) {
986 // Deselection disabled - show message
987 this.showTempMessage(__('Seat cannot be deselected', 'event-tickets-with-ticket-scanner'));
988 return;
989 }
990 // Deselect: remove from array and restore original color
991 this.tempSelections.splice(existingIndex, 1);
992 $seat.removeClass('temp-selected');
993 // Restore original color
994 var originalColor = $seat.data('original-color') || this.planColors.available;
995 $seat.attr('fill', originalColor);
996 } else {
997 // Check if we've reached max seats
998 if (this.tempSelections.length >= this.maxSeats) {
999 // At max capacity - replace oldest selection (FIFO)
1000 var oldSeatId = this.tempSelections[0].seat_id;
1001 var $oldSeat = this.$currentModal.find('[data-seat-id="' + oldSeatId + '"]');
1002 $oldSeat.removeClass('temp-selected');
1003 // Restore old seat's original color
1004 var oldOriginalColor = $oldSeat.data('original-color') || this.planColors.available;
1005 $oldSeat.attr('fill', oldOriginalColor);
1006 // Remove oldest from array
1007 this.tempSelections.shift();
1008 }
1009
1010 // Add to selections and change to selected color
1011 this.tempSelections.push(seatData);
1012 $seat.addClass('temp-selected');
1013 $seat.attr('fill', this.planColors.selected);
1014 }
1015
1016 // Enable confirm button only when correct number of seats selected
1017 var canConfirm = this.tempSelections.length === this.maxSeats;
1018 this.$currentModal.find('.saso-confirm-selection').prop('disabled', !canConfirm);
1019
1020 // Update seat info display
1021 this.updateModalSeatInfo();
1022 },
1023
1024 /**
1025 * Find seat index in array by seat_id
1026 *
1027 * @param {Array} seats Array of seat objects
1028 * @param {number|string} seatId Seat ID to find
1029 * @returns {number} Index or -1 if not found
1030 */
1031 findSeatIndex: function(seats, seatId) {
1032 var searchId = parseInt(seatId, 10);
1033 for (var i = 0; i < seats.length; i++) {
1034 if (parseInt(seats[i].seat_id, 10) === searchId) {
1035 return i;
1036 }
1037 }
1038 return -1;
1039 },
1040
1041 /**
1042 * Show temporary message in modal
1043 *
1044 * @param {string} message Message to show
1045 */
1046 showTempMessage: function(message) {
1047 var $info = this.$currentModal.find('.saso-seat-info');
1048 var originalHtml = $info.html();
1049
1050 $info.html('<span class="saso-temp-warning">' + this.escapeHtml(message) + '</span>');
1051
1052 setTimeout(function() {
1053 // Restore original content (will be updated by next interaction anyway)
1054 }, 2000);
1055 },
1056
1057 /**
1058 * Update the modal seat info display
1059 *
1060 * Shows counter (X/Y) and selected seat labels
1061 */
1062 updateModalSeatInfo: function() {
1063 if (!this.$currentModal) {
1064 return;
1065 }
1066
1067 var $info = this.$currentModal.find('.saso-seat-info');
1068
1069 if (!$info.length) {
1070 // Create info element if not exists
1071 this.$currentModal.find('.saso-seat-map-footer').prepend(
1072 '<div class="saso-seat-info"></div>'
1073 );
1074 $info = this.$currentModal.find('.saso-seat-info');
1075 }
1076
1077 var selectedCount = this.tempSelections.length;
1078 var maxSeats = this.maxSeats;
1079
1080 // Build counter text
1081 var counterText = '<span class="saso-seat-counter">' +
1082 __('Selected', 'event-tickets-with-ticket-scanner') + ': ' +
1083 '<strong>' + selectedCount + '/' + maxSeats + '</strong>' +
1084 '</span>';
1085
1086 if (selectedCount === 0) {
1087 // No selections yet
1088 var instructionText = maxSeats > 1
1089 ? __('Select {count} seats', 'event-tickets-with-ticket-scanner').replace('{count}', maxSeats)
1090 : __('Select a seat', 'event-tickets-with-ticket-scanner');
1091 $info.html(counterText + ' - ' + instructionText);
1092 } else {
1093 // Show selected seat labels
1094 var labels = this.tempSelections.map(function(seat) {
1095 var label = seat.seat_label;
1096 if (seat.seat_category) {
1097 label += ' (' + seat.seat_category + ')';
1098 }
1099 return label;
1100 });
1101 $info.html(counterText + ' - ' + this.escapeHtml(labels.join(', ')));
1102 }
1103 },
1104
1105 /**
1106 * Confirm the modal selection
1107 */
1108 confirmSelection: function() {
1109 if (!this.tempSelections.length || !this.$currentSelector) {
1110 return;
1111 }
1112
1113 // IMPORTANT: Immediately update hidden input and button BEFORE AJAX
1114 // This ensures form has seat data even if user submits before AJAX completes
1115 var seatsToSave = this.tempSelections.slice();
1116 this.selectedSeats = seatsToSave;
1117 this.updateSelection(this.$currentSelector, seatsToSave);
1118 this.updateButtonText(this.$currentSelector, seatsToSave);
1119
1120 // Check if we should block now or wait for add-to-cart
1121 var blockOnAddToCart = typeof sasoSeatingData !== 'undefined' && sasoSeatingData.blockOnAddToCart;
1122 if (blockOnAddToCart) {
1123 // Don't block now - will be blocked during add-to-cart
1124 // Just show a message that reservation is pending
1125 this.setStatus(this.$currentSelector, __('Seat will be reserved when adding to cart', 'event-tickets-with-ticket-scanner'), 'info');
1126 setTimeout(function() {
1127 var self = this;
1128 // Don't clear status - keep info visible
1129 }.bind(this), 3000);
1130 } else {
1131 // Block all selected seats via AJAX (will update with block_ids when done)
1132 this.blockSeatsInBackground(this.$currentSelector, seatsToSave);
1133 }
1134 this.closeModal();
1135 },
1136
1137 /**
1138 * Block seats in background via AJAX (doesn't clear selectedSeats)
1139 *
1140 * @param {jQuery} $selector The selector container
1141 * @param {Array} seatsData Array of seat data objects
1142 */
1143 blockSeatsInBackground: function($selector, seatsData) {
1144 var self = this;
1145 var productId = $selector.data('product-id');
1146 var eventDate = this.getEventDate($selector);
1147
1148 // Show loading state
1149 $selector.addClass('loading');
1150 this.setStatus($selector, __('Loading...', 'event-tickets-with-ticket-scanner'), 'loading');
1151
1152 // Release all previous seat blocks (but don't clear selectedSeats/hidden input)
1153 var releasePromises = [];
1154 Object.keys(this.currentBlockIds).forEach(function(seatId) {
1155 releasePromises.push(self.releaseSeat(self.currentBlockIds[seatId]));
1156 });
1157
1158 $.when.apply($, releasePromises).always(function() {
1159 self.currentBlockIds = {};
1160
1161 // Block all new seats sequentially
1162 var blockedSeats = [];
1163 var errors = [];
1164
1165 function blockNext(index) {
1166 if (index >= seatsData.length) {
1167 // All done - update UI
1168 $selector.removeClass('loading');
1169
1170 if (errors.length > 0) {
1171 self.setStatus($selector, errors[0], 'error');
1172 // Clear selection on error
1173 self.selectedSeats = [];
1174 self.updateSelection($selector, []);
1175 self.updateButtonText($selector, []);
1176 self.refreshAvailability($selector);
1177 } else if (blockedSeats.length > 0) {
1178 // Update with block_ids
1179 self.selectedSeats = blockedSeats;
1180 self.updateSelection($selector, blockedSeats);
1181 self.updateButtonText($selector, blockedSeats);
1182
1183 var msg = blockedSeats.length > 1
1184 ? __('%d seats selected', 'event-tickets-with-ticket-scanner').replace('%d', blockedSeats.length)
1185 : __('Seat selected', 'event-tickets-with-ticket-scanner');
1186 self.setStatus($selector, msg, 'success');
1187
1188 setTimeout(function() {
1189 self.setStatus($selector, '', '');
1190 }, 3000);
1191 }
1192 return;
1193 }
1194
1195 var seatData = seatsData[index];
1196 self.blockSeat(seatData.seat_id, productId, eventDate, function(response) {
1197 if (response.success) {
1198 // Calculate countdown_end from remaining_seconds (avoids timezone issues)
1199 var remainingSeconds = response.data.remaining_seconds || 0;
1200 var countdownEnd = Date.now() + (remainingSeconds * 1000);
1201
1202 // Copy seat data and add block info
1203 var blockedSeat = {
1204 seat_id: seatData.seat_id,
1205 seat_label: seatData.seat_label,
1206 seat_category: seatData.seat_category,
1207 seat_desc: seatData.seat_desc || '',
1208 block_id: response.data.block_id,
1209 expires_at: response.data.expires_at,
1210 countdown_end: countdownEnd
1211 };
1212 self.currentBlockIds[seatData.seat_id] = response.data.block_id;
1213 blockedSeats.push(blockedSeat);
1214 } else {
1215 var errorMsg = response.data && response.data.error === 'seat_unavailable'
1216 ? __('This seat is no longer available', 'event-tickets-with-ticket-scanner')
1217 : __('Error blocking seat', 'event-tickets-with-ticket-scanner');
1218 errors.push(errorMsg + ' (' + seatData.seat_label + ')');
1219 }
1220 blockNext(index + 1);
1221 });
1222 }
1223
1224 blockNext(0);
1225 });
1226 },
1227
1228 /**
1229 * Select multiple seats (block via AJAX)
1230 *
1231 * @param {jQuery} $selector The selector container
1232 * @param {Array} seatsData Array of seat data objects
1233 */
1234 selectSeats: function($selector, seatsData) {
1235 var self = this;
1236 var productId = $selector.data('product-id');
1237 var eventDate = this.getEventDate($selector);
1238
1239 // Show loading state
1240 $selector.addClass('loading');
1241 this.setStatus($selector, __('Loading...', 'event-tickets-with-ticket-scanner'), 'loading');
1242
1243 // Release all previous seats
1244 var releasePromises = [];
1245 Object.keys(this.currentBlockIds).forEach(function(seatId) {
1246 releasePromises.push(self.releaseSeat(self.currentBlockIds[seatId]));
1247 });
1248
1249 $.when.apply($, releasePromises).always(function() {
1250 self.currentBlockIds = {};
1251 self.selectedSeats = [];
1252
1253 // Block all new seats sequentially to avoid race conditions
1254 var blockedSeats = [];
1255 var errors = [];
1256
1257 function blockNext(index) {
1258 if (index >= seatsData.length) {
1259 // All done - update UI
1260 $selector.removeClass('loading');
1261
1262 if (errors.length > 0) {
1263 self.setStatus($selector, errors[0], 'error');
1264 self.refreshAvailability($selector);
1265 } else if (blockedSeats.length > 0) {
1266 self.selectedSeats = blockedSeats;
1267 self.updateSelection($selector, blockedSeats);
1268 self.updateButtonText($selector, blockedSeats);
1269
1270 var msg = blockedSeats.length > 1
1271 ? __('%d seats selected', 'event-tickets-with-ticket-scanner').replace('%d', blockedSeats.length)
1272 : __('Seat selected', 'event-tickets-with-ticket-scanner');
1273 self.setStatus($selector, msg, 'success');
1274
1275 setTimeout(function() {
1276 self.setStatus($selector, '', '');
1277 }, 3000);
1278 }
1279 return;
1280 }
1281
1282 var seatData = seatsData[index];
1283 self.blockSeat(seatData.seat_id, productId, eventDate, function(response) {
1284 if (response.success) {
1285 // Calculate countdown_end from remaining_seconds (avoids timezone issues)
1286 var remainingSeconds = response.data.remaining_seconds || 0;
1287 seatData.block_id = response.data.block_id;
1288 seatData.expires_at = response.data.expires_at;
1289 seatData.countdown_end = Date.now() + (remainingSeconds * 1000);
1290 self.currentBlockIds[seatData.seat_id] = response.data.block_id;
1291 blockedSeats.push(seatData);
1292 } else {
1293 var errorMsg = response.data && response.data.error === 'seat_unavailable'
1294 ? __('This seat is no longer available', 'event-tickets-with-ticket-scanner')
1295 : __('Error blocking seat', 'event-tickets-with-ticket-scanner');
1296 errors.push(errorMsg + ' (' + seatData.seat_label + ')');
1297 }
1298 blockNext(index + 1);
1299 });
1300 }
1301
1302 blockNext(0);
1303 });
1304 },
1305
1306 /**
1307 * Select a single seat (convenience wrapper)
1308 *
1309 * @param {jQuery} $selector The selector container
1310 * @param {Object} seatData Seat data
1311 */
1312 selectSeat: function($selector, seatData) {
1313 this.selectSeats($selector, [seatData]);
1314 },
1315
1316 /**
1317 * Clear the current selection
1318 *
1319 * @param {jQuery} $selector The selector container
1320 */
1321 clearSelection: function($selector) {
1322 var self = this;
1323
1324 // Release all blocked seats
1325 Object.keys(this.currentBlockIds).forEach(function(seatId) {
1326 self.releaseSeat(self.currentBlockIds[seatId]);
1327 });
1328
1329 this.currentBlockIds = {};
1330 this.selectedSeats = [];
1331 this.updateSelection($selector, []);
1332 this.updateButtonText($selector, []);
1333 },
1334
1335 /**
1336 * Block a seat via AJAX
1337 *
1338 * @param {number} seatId Seat ID
1339 * @param {number} productId Product ID
1340 * @param {string|null} eventDate Event date
1341 * @param {Function} callback Callback function
1342 */
1343 blockSeat: function(seatId, productId, eventDate, callback) {
1344 $.ajax({
1345 url: sasoSeatingData.ajaxurl,
1346 type: 'POST',
1347 data: {
1348 action: sasoSeatingData.action, // sasoEventtickets_executeSeatingFrontend
1349 a: 'blockSeat',
1350 security: sasoSeatingData.nonce,
1351 seat_id: seatId,
1352 product_id: productId,
1353 event_date: eventDate || ''
1354 },
1355 success: function(response) {
1356 callback(response);
1357 },
1358 error: function() {
1359 callback({ success: false, data: { error: 'ajax_error' } });
1360 }
1361 });
1362 },
1363
1364 /**
1365 * Release a seat via AJAX
1366 *
1367 * @param {number} blockId Block ID
1368 * @returns {jQuery.Promise}
1369 */
1370 releaseSeat: function(blockId) {
1371 return $.ajax({
1372 url: sasoSeatingData.ajaxurl,
1373 type: 'POST',
1374 data: {
1375 action: sasoSeatingData.action, // sasoEventtickets_executeSeatingFrontend
1376 a: 'releaseSeat',
1377 security: sasoSeatingData.nonce,
1378 block_id: blockId
1379 }
1380 });
1381 },
1382
1383 /**
1384 * Refresh seat availability
1385 *
1386 * @param {jQuery} $selector The selector container
1387 */
1388 refreshAvailability: function($selector) {
1389 var self = this;
1390 var productId = $selector.data('product-id');
1391 var eventDate = this.getEventDate($selector);
1392
1393 $.ajax({
1394 url: sasoSeatingData.ajaxurl,
1395 type: 'POST',
1396 data: {
1397 action: sasoSeatingData.action, // sasoEventtickets_executeSeatingFrontend
1398 a: 'getAvailableSeats',
1399 security: sasoSeatingData.nonce,
1400 product_id: productId,
1401 event_date: eventDate || ''
1402 },
1403 success: function(response) {
1404 if (response.success) {
1405 self.updateSeatAvailability($selector, response.data.seats);
1406 }
1407 },
1408 error: function(xhr, status, error) {
1409 // AJAX error - silently handle
1410 }
1411 });
1412 },
1413
1414 /**
1415 * Start auto-refresh interval while modal is open
1416 *
1417 * @param {jQuery} $selector The selector container
1418 */
1419 startAutoRefresh: function($selector) {
1420 var self = this;
1421
1422 // Clear any existing interval first
1423 this.stopAutoRefresh();
1424
1425 // Start new interval
1426 this.refreshIntervalId = setInterval(function() {
1427 // Only refresh if modal is still open
1428 if (self.$currentModal && self.$currentModal.hasClass('active')) {
1429 self.refreshAvailability($selector);
1430 } else {
1431 // Modal was closed, stop interval
1432 self.stopAutoRefresh();
1433 }
1434 }, this.REFRESH_INTERVAL);
1435 },
1436
1437 /**
1438 * Stop auto-refresh interval
1439 */
1440 stopAutoRefresh: function() {
1441 if (this.refreshIntervalId) {
1442 clearInterval(this.refreshIntervalId);
1443 this.refreshIntervalId = null;
1444 }
1445 },
1446
1447 /**
1448 * Update seat availability in UI
1449 *
1450 * @param {jQuery} $selector The selector container
1451 * @param {Array} seats Seats with status
1452 */
1453 updateSeatAvailability: function($selector, seats) {
1454 var self = this;
1455 var layout = $selector.data('layout');
1456
1457 if (layout === 'simple') {
1458 // Update dropdown
1459 var $dropdown = $selector.find('.saso-seat-dropdown');
1460 seats.forEach(function(seat) {
1461 var $option = $dropdown.find('option[value="' + seat.id + '"]');
1462 var isAvailable = seat.availability === 'free';
1463 $option.prop('disabled', !isAvailable);
1464 });
1465 } else {
1466 // Update SVG
1467 var $svg = $selector.find('.saso-seat-map');
1468 seats.forEach(function(seat) {
1469 var $seatEl = $svg.find('[data-seat-id="' + seat.id + '"]');
1470 var isAvailable = seat.availability === 'free';
1471 var isSelected = self.findSeatIndex(self.tempSelections, seat.id) !== -1;
1472
1473 $seatEl
1474 .attr('data-available', isAvailable ? '1' : '0')
1475 .removeClass('free blocked sold')
1476 .addClass(seat.availability);
1477
1478 // Update fill color based on status (but keep selected color if selected)
1479 if (isSelected) {
1480 $seatEl.attr('fill', self.planColors.selected);
1481 } else if (isAvailable) {
1482 // Restore original color for available seats
1483 var originalColor = $seatEl.data('original-color') || self.planColors.available;
1484 $seatEl.attr('fill', originalColor);
1485 } else if (seat.availability === 'sold') {
1486 $seatEl.attr('fill', self.planColors.booked);
1487 } else {
1488 $seatEl.attr('fill', self.planColors.reserved);
1489 }
1490 });
1491 }
1492 },
1493
1494 /**
1495 * Handle event date change
1496 *
1497 * @param {jQuery} $input The date input
1498 */
1499 onEventDateChange: function($input) {
1500 var self = this;
1501 var dateValue = $input.val();
1502
1503 // Find matching seating selector by product ID
1504 var productId = $input.data('product-id');
1505 var $selector = null;
1506
1507 if (productId) {
1508 // Match by product ID (works on shop pages with multiple products)
1509 $selector = $('.saso-seating-selector[data-product-id="' + productId + '"]');
1510 } else {
1511 // Fallback: find within same form
1512 var $form = $input.closest('form');
1513 $selector = $form.find('.saso-seating-selector');
1514 }
1515
1516 if ($selector.length && dateValue) {
1517 // Update the event date in selector (use .data() for jQuery cache consistency)
1518 $selector.data('event-date', dateValue);
1519 $selector.attr('data-event-date', dateValue);
1520
1521 // Check if we need to rebuild the UI (was showing "please select date" message)
1522 var $dateRequired = $selector.find('.saso-seating-date-required');
1523 if ($dateRequired.length) {
1524 // Remove old content except hidden input
1525 $selector.children().not('.saso-seat-selection-input').remove();
1526
1527 // Rebuild UI with the stored plan data
1528 var data = $selector.data('plan-data');
1529 if (data) {
1530 this.buildSelectorUI($selector, data);
1531 }
1532 }
1533
1534 // Clear current selection (availability may have changed)
1535 this.clearSelection($selector);
1536
1537 // Refresh availability for the new date
1538 this.refreshAvailability($selector);
1539 }
1540 },
1541
1542 /**
1543 * Get event date from form
1544 *
1545 * @param {jQuery} $selector The selector container
1546 * @returns {string|null}
1547 */
1548 getEventDate: function($selector) {
1549 var $form = $selector.closest('form');
1550 var $dateInput = $form.find('[data-input-type="daychooser"], [name^="event_date"], .hasDatepicker').first();
1551
1552 return $dateInput.length ? $dateInput.val() : $selector.data('event-date') || null;
1553 },
1554
1555 /**
1556 * Update the hidden input with selection data
1557 *
1558 * @param {jQuery} $selector The selector container
1559 * @param {Array} seatsData Array of seat data objects
1560 */
1561 updateSelection: function($selector, seatsData) {
1562 var $input = $selector.find('.saso-seat-selection-input');
1563 var jsonValue = seatsData && seatsData.length ? JSON.stringify(seatsData) : '';
1564 $input.val(jsonValue);
1565 },
1566
1567 /**
1568 * Update the "Open Seat Map" button text and seat labels display with countdown
1569 *
1570 * @param {jQuery} $selector The selector container
1571 * @param {Array} seatsData Array of seat data objects
1572 */
1573 updateButtonText: function($selector, seatsData) {
1574 var self = this;
1575 var $button = $selector.find('.saso-open-seat-map');
1576 var $labelsContainer = $selector.find('.saso-selected-seats-labels');
1577
1578 // Create labels container if it doesn't exist
1579 if (!$labelsContainer.length && $button.length) {
1580 $button.after('<div class="saso-selected-seats-labels"></div>');
1581 $labelsContainer = $selector.find('.saso-selected-seats-labels');
1582 }
1583
1584 if (!$button.length) {
1585 return;
1586 }
1587
1588 // Stop previous countdown timer
1589 this.stopCountdownTimer();
1590
1591 if (seatsData && seatsData.length > 0) {
1592 // Show count in button (cleaner for many seats)
1593 var count = seatsData.length;
1594 var text = count === 1
1595 ? __('1 seat selected', 'event-tickets-with-ticket-scanner')
1596 : __('%d seats selected', 'event-tickets-with-ticket-scanner').replace('%d', count);
1597
1598 $button
1599 .addClass('has-selection')
1600 .html(text + ' - ' + __('Click to change', 'event-tickets-with-ticket-scanner'));
1601
1602 // Sort seats for display
1603 var sortedSeats = seatsData.slice().sort(function(a, b) {
1604 var labelA = a.seat_label || ('Seat ' + a.seat_id);
1605 var labelB = b.seat_label || ('Seat ' + b.seat_id);
1606 return labelA.localeCompare(labelB, undefined, {numeric: true, sensitivity: 'base'});
1607 });
1608
1609 // Check if expiration time should be hidden (option)
1610 var hideExpiration = typeof sasoSeatingData !== 'undefined' && sasoSeatingData.hideExpirationTime;
1611
1612 // Build HTML list with countdown per seat
1613 var html = '<ul class="saso-seat-list">';
1614 sortedSeats.forEach(function(seat) {
1615 var label = seat.seat_label || ('Seat ' + seat.seat_id);
1616 if (seat.seat_category) {
1617 label += ' (' + seat.seat_category + ')';
1618 }
1619 // Use countdown_end (client timestamp) for countdown - avoids timezone issues
1620 var countdownEnd = hideExpiration ? 0 : (seat.countdown_end || 0);
1621 var countdownAttr = countdownEnd ? ' data-countdown-end="' + countdownEnd + '"' : '';
1622
1623 html += '<li class="saso-seat-item" data-seat-id="' + seat.seat_id + '"' + countdownAttr + '>';
1624 html += '<span class="saso-seat-name">' + self.escapeHtml(label) + '</span>';
1625 // Only show countdown if not hidden by option
1626 if (countdownEnd) {
1627 html += '<span class="saso-seat-countdown" data-countdown-end="' + countdownEnd + '"></span>';
1628 }
1629 html += '</li>';
1630 });
1631 html += '</ul>';
1632 $labelsContainer.html(html).show();
1633
1634 // Start countdown timer (only if not hidden)
1635 if (!hideExpiration) {
1636 this.startCountdownTimer($labelsContainer);
1637 }
1638 } else {
1639 $button
1640 .removeClass('has-selection')
1641 .html(__('Open Seat Map', 'event-tickets-with-ticket-scanner'));
1642 $labelsContainer.empty().hide();
1643 }
1644 },
1645
1646 /**
1647 * Start countdown timer for seat reservations
1648 *
1649 * @param {jQuery} $container Container with countdown elements
1650 */
1651 startCountdownTimer: function($container) {
1652 var self = this;
1653
1654 // Stop any existing timer on this container
1655 this.stopCountdownTimer($container);
1656
1657 // Update immediately
1658 this.updateCountdowns($container);
1659
1660 // Update every second - store timer ID on container
1661 var timerId = setInterval(function() {
1662 self.updateCountdowns($container);
1663 }, 1000);
1664 $container.data('countdown-timer', timerId);
1665 },
1666
1667 /**
1668 * Stop countdown timer for a specific container
1669 *
1670 * @param {jQuery} $container Container to stop timer for (optional - stops global if not provided)
1671 */
1672 stopCountdownTimer: function($container) {
1673 if ($container && $container.length) {
1674 var timerId = $container.data('countdown-timer');
1675 if (timerId) {
1676 clearInterval(timerId);
1677 $container.removeData('countdown-timer');
1678 }
1679 } else if (this.countdownIntervalId) {
1680 // Legacy: stop global timer
1681 clearInterval(this.countdownIntervalId);
1682 this.countdownIntervalId = null;
1683 }
1684 },
1685
1686 /**
1687 * Update all countdown displays
1688 *
1689 * @param {jQuery} $container Container with countdown elements
1690 */
1691 updateCountdowns: function($container) {
1692 var self = this;
1693 var totalCountdowns = 0;
1694 var expiredCountdowns = 0;
1695
1696 $container.find('.saso-seat-countdown').each(function() {
1697 var $countdown = $(this);
1698 var countdownEnd = $countdown.data('countdown-end');
1699
1700 if (!countdownEnd) {
1701 return;
1702 }
1703
1704 totalCountdowns++;
1705 var remaining = self.getTimeRemaining(countdownEnd);
1706
1707 if (remaining <= 0) {
1708 $countdown.html('<span class="expired-text">' + __('Expired!', 'event-tickets-with-ticket-scanner') + '</span>');
1709 $countdown.addClass('expired');
1710 $countdown.closest('.saso-seat-item').addClass('expired');
1711 expiredCountdowns++;
1712 } else {
1713 $countdown.html(self.formatCountdown(remaining));
1714 // Add warning class if less than 1 minute
1715 if (remaining < 60) {
1716 $countdown.addClass('warning');
1717 } else {
1718 $countdown.removeClass('warning');
1719 }
1720 }
1721 });
1722
1723 // If ALL countdowns expired, stop timer
1724 if (totalCountdowns > 0 && expiredCountdowns === totalCountdowns) {
1725 this.stopCountdownTimer($container);
1726 }
1727 },
1728
1729 /**
1730 * Get time remaining in seconds
1731 * Uses countdown_end (client timestamp) calculated from server's remaining_seconds
1732 *
1733 * @param {number} countdownEnd Client timestamp when countdown expires (Date.now() based)
1734 * @return {number} Seconds remaining (negative if expired)
1735 */
1736 getTimeRemaining: function(countdownEnd) {
1737 if (!countdownEnd || typeof countdownEnd !== 'number') {
1738 return 0;
1739 }
1740
1741 return Math.floor((countdownEnd - Date.now()) / 1000);
1742 },
1743
1744 /**
1745 * Format remaining seconds as MM:SS
1746 *
1747 * @param {number} seconds Seconds remaining
1748 * @return {string} Formatted time
1749 */
1750 formatCountdown: function(seconds) {
1751 var minutes = Math.floor(seconds / 60);
1752 var secs = seconds % 60;
1753 return minutes + ':' + (secs < 10 ? '0' : '') + secs;
1754 },
1755
1756 /**
1757 * Set status message
1758 *
1759 * @param {jQuery} $selector The selector container
1760 * @param {string} message Status message
1761 * @param {string} type Status type (success, error, loading)
1762 */
1763 setStatus: function($selector, message, type) {
1764 var $status = $selector.find('.saso-seating-status');
1765 $status
1766 .text(message)
1767 .removeClass('success error loading')
1768 .addClass(type);
1769 },
1770
1771 /**
1772 * Show plan image in a lightbox
1773 *
1774 * @param {string} imageUrl URL of the plan image
1775 */
1776 showPlanImage: function(imageUrl) {
1777 // Remove any existing lightbox
1778 $('.saso-plan-image-lightbox').remove();
1779
1780 // Create lightbox
1781 var $lightbox = $('<div class="saso-plan-image-lightbox">' +
1782 '<button type="button" class="saso-lightbox-close">&times;</button>' +
1783 '<img src="' + imageUrl + '" alt="' + __('Venue Plan', 'event-tickets-with-ticket-scanner') + '">' +
1784 '</div>');
1785
1786 $('body').append($lightbox);
1787 },
1788
1789 /**
1790 * Escape HTML entities
1791 *
1792 * @param {string} text Text to escape
1793 * @returns {string}
1794 */
1795 escapeHtml: function(text) {
1796 if (!text) return '';
1797 var div = document.createElement('div');
1798 div.textContent = text;
1799 return div.innerHTML;
1800 }
1801 };
1802
1803 /**
1804 * Initialize on document ready
1805 */
1806 $(document).ready(function() {
1807 if (typeof sasoSeatingData !== 'undefined') {
1808 SasoSeatingFrontend.init();
1809 }
1810 });
1811
1812 /**
1813 * Re-initialize countdowns after WooCommerce AJAX updates (e.g., coupon applied)
1814 */
1815 $(document.body).on('updated_cart_totals updated_wc_div wc_fragments_refreshed', function() {
1816 if (typeof sasoSeatingData !== 'undefined') {
1817 SasoSeatingFrontend.initCartCountdowns();
1818 }
1819 });
1820
1821 // Expose for external access if needed
1822 window.SasoSeatingFrontend = SasoSeatingFrontend;
1823
1824 })(jQuery);
1825