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 / includes / seating / class-seating-frontend.php
event-tickets-with-ticket-scanner / includes / seating Last commit date
class-seating-admin.php 1 week ago class-seating-base.php 1 week ago class-seating-block.php 1 week ago class-seating-frontend.php 1 week ago class-seating-plan.php 1 week ago class-seating-seat.php 1 week ago
class-seating-frontend.php
623 lines
1 <?php
2 /**
3 * Seating Frontend Handler
4 *
5 * Handles frontend seat selection UI and AJAX requests.
6 *
7 * @package Event_Tickets_With_Ticket_Scanner
8 * @subpackage Seating
9 * @since 2.8.0
10 */
11
12 // Exit if accessed directly
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 require_once __DIR__ . '/class-seating-base.php';
18
19 /**
20 * Seating Frontend Class
21 *
22 * Provides frontend UI for seat selection in cart/checkout.
23 *
24 * @since 2.8.0
25 */
26 class sasoEventtickets_Seating_Frontend extends sasoEventtickets_Seating_Base {
27
28 /**
29 * Constructor
30 *
31 * @param sasoEventtickets $main Main plugin instance
32 */
33 public function __construct($main) {
34 parent::__construct($main);
35 $this->initHooks();
36 }
37
38 /**
39 * Get meta object structure - not used by Frontend class
40 *
41 * Required by abstract parent class but Frontend doesn't manage meta objects.
42 *
43 * @return array Empty array
44 */
45 public function getMetaObject(): array {
46 return [];
47 }
48
49 /**
50 * Initialize WordPress hooks
51 *
52 * Note: AJAX hooks are registered in sasoEventtickets_Seating constructor
53 * to ensure they're available before lazy loading
54 */
55 private function initHooks(): void {
56 // AJAX handlers are now registered in sasoEventtickets_Seating::__construct()
57 // to ensure availability on AJAX requests before getFrontendManager() is called
58
59 // WordPress Heartbeat API hooks for seat block keep-alive
60 add_filter('heartbeat_received', [$this, 'heartbeatReceived'], 10, 2);
61 }
62
63 /**
64 * Handle WordPress heartbeat - update last_seen for active seat blocks
65 *
66 * @param array $response Heartbeat response data
67 * @param array $data Heartbeat request data from JS
68 * @return array Modified response
69 */
70 public function heartbeatReceived(array $response, array $data): array {
71 // Check if seating data was sent
72 if (empty($data['saso_seating_blocks'])) {
73 return $response;
74 }
75
76 $blockIds = array_map('intval', (array) $data['saso_seating_blocks']);
77 if (empty($blockIds)) {
78 return $response;
79 }
80
81 // Get session ID to verify ownership
82 $sessionId = WC()->session ? WC()->session->get_customer_id() : session_id();
83
84 // Update last_seen for these blocks
85 $blockManager = $this->MAIN->getSeating()->getBlockManager();
86 $updated = $blockManager->updateLastSeen($blockIds, $sessionId);
87
88 // Return status of blocks
89 $response['saso_seating'] = [
90 'updated' => $updated,
91 'timestamp' => current_time('timestamp')
92 ];
93
94 return $response;
95 }
96
97 /**
98 * Enqueue frontend scripts and styles
99 */
100 public function enqueueScripts(): void {
101 wp_enqueue_script('jquery');
102 wp_enqueue_script('jquery-ui-dialog');
103 wp_enqueue_style('wp-jquery-ui-dialog');
104
105 wp_enqueue_script(
106 'saso-seating-frontend',
107 plugins_url('js/seating_frontend.js', dirname(__DIR__)),
108 ['jquery', 'wp-i18n'],
109 $this->MAIN->getPluginVersion(),
110 true
111 );
112
113 wp_localize_script('saso-seating-frontend', 'sasoSeatingData', [
114 'ajaxurl' => admin_url('admin-ajax.php'),
115 'action' => $this->MAIN->getPrefix() . '_executeSeatingFrontend',
116 'nonce' => wp_create_nonce('sasoEventtickets'),
117 'fieldName' => $this->getFieldSeatSelection(),
118 'hideExpirationTime' => $this->MAIN->getOptions()->isOptionCheckboxActive('seatingHideExpirationTime'),
119 'lockSelectedSeats' => $this->MAIN->getOptions()->isOptionCheckboxActive('seatingLockSelectedSeats'),
120 'blockOnAddToCart' => $this->MAIN->getOptions()->isOptionCheckboxActive('seatingBlockOnAddToCart'),
121 'showSeatDescInChooser' => $this->MAIN->getOptions()->isOptionCheckboxActive('seatingShowDescInChooser'),
122 ]);
123
124 wp_set_script_translations('saso-seating-frontend', 'event-tickets-with-ticket-scanner', dirname(dirname(__DIR__)) . '/languages');
125
126 wp_enqueue_style(
127 'saso-seating-frontend',
128 plugins_url('css/seating_frontend.css', dirname(__DIR__)),
129 [],
130 $this->MAIN->getPluginVersion()
131 );
132 }
133
134 /**
135 * Get seating plan for a product for FRONTEND display
136 *
137 * Calls parent's getPlanForProduct() and applies frontend-specific filtering:
138 * - Only returns published plans for customers
139 * - Admins can preview drafts via ?preview_seating=1 query parameter
140 *
141 * Note: Caller is responsible for WPML normalization.
142 *
143 * @param int $productId Product ID (or variation ID)
144 * @param int|null $variationId Variation ID (optional)
145 * @return array|null Plan data or null if not available for frontend
146 */
147 public function getPlanForProductFrontend(int $productId, ?int $variationId = null): ?array {
148 // Validate product ID
149 if ($productId <= 0) {
150 $this->MAIN->getDB()->logError('getPlanForProductFrontend: Invalid product ID: ' . $productId);
151 return null;
152 }
153
154 // Get the base plan (SRP: reuse inherited getPlanForProduct)
155 $plan = $this->getPlanForProduct($productId, $variationId);
156
157 if (!$plan) {
158 return null;
159 }
160
161 // Frontend filter: must be active
162 if (empty($plan['aktiv'])) {
163 return null;
164 }
165
166 // Check published status and admin preview
167 $isPublished = !empty($plan['published_at']);
168 $allowDraftPreview = isset($_GET['preview_seating']) && $_GET['preview_seating'] === '1';
169 $isAdminPreview = $allowDraftPreview && current_user_can('manage_woocommerce');
170
171 // Not published and not admin preview - don't show
172 if (!$isPublished && !$isAdminPreview) {
173 return null;
174 }
175
176 // getById() returns: meta (decoded), meta_draft (JSON string), meta_published (JSON string)
177 // Plan-level meta (from edit modal: image_id, description) - already decoded by getById()
178 $planMeta = $plan['meta'] ?? [];
179
180 // Get designer meta (visual elements, canvas settings) - need to decode JSON
181 $planManager = $this->MAIN->getSeating()->getPlanManager();
182 if ($isPublished && !$isAdminPreview) {
183 // Customer sees published version
184 $designerMeta = !empty($plan['meta_published'])
185 ? json_decode($plan['meta_published'], true)
186 : [];
187 $plan['_using_published'] = true;
188 } else {
189 // Admin preview - use draft
190 $designerMeta = !empty($plan['meta_draft'])
191 ? json_decode($plan['meta_draft'], true)
192 : [];
193 $plan['_using_draft'] = true;
194 $plan['_is_preview'] = true;
195 }
196
197 // Merge: defaults < plan meta (image_id etc) < designer meta (canvas, elements)
198 $plan['meta'] = array_replace_recursive(
199 $planManager->getMetaObject(),
200 is_array($planMeta) ? $planMeta : [],
201 is_array($designerMeta) ? $designerMeta : []
202 );
203
204 return $plan;
205 }
206
207 /**
208 * Render seat selector container for a product
209 *
210 * Only renders a minimal container div with data attributes.
211 * All UI rendering is handled by JavaScript (seating_frontend.js).
212 *
213 * @param int $productId Product ID
214 * @param string|null $eventDate Event date
215 * @param string|null $cartItemKey Cart item key (for cart context)
216 * @param array|null $currentSelection Current selected seat data
217 * @return string HTML output
218 */
219 public function renderSeatSelector(int $productId, ?string $eventDate = null, ?string $cartItemKey = null, ?array $currentSelection = null): string {
220 // Validate product ID
221 if ($productId <= 0) {
222 $this->MAIN->getDB()->logError('renderSeatSelector: Invalid product ID: ' . $productId);
223 return '';
224 }
225
226 $manager = $this->MAIN->getSeating();
227
228 // Use frontend method - only returns published plans (or draft for admin preview)
229 $plan = $this->getPlanForProductFrontend($productId);
230
231 if (!$plan) {
232 return '';
233 }
234
235 // Check for user's existing blocks (not yet in cart) - restore on page reload
236 $existingBlocks = [];
237 if (empty($currentSelection)) {
238 $sessionId = WC()->session ? WC()->session->get_customer_id() : session_id();
239 $blockManager = $manager->getBlockManager();
240 $blocksData = $blockManager->getSessionBlocks($sessionId, $productId, $eventDate);
241
242 if (!empty($blocksData)) {
243 foreach ($blocksData as $block) {
244 $existingBlocks[] = [
245 'seat_id' => (int) $block['seat_id'],
246 'seat_label' => $block['seat_label'],
247 'seat_category' => $block['seat_category'],
248 'seat_desc' => $block['seat_desc'] ?? '',
249 'block_id' => (int) $block['id'],
250 'expires_at' => $block['expires_at'],
251 'event_date' => $block['event_date'],
252 ];
253 }
254 }
255 }
256
257 // Prepare data for JS
258 $seats = $manager->getSeatsWithStatus((int) $plan['id'], $productId, $eventDate);
259 $planImageId = $plan['meta']['image_id'] ?? 0;
260 $planImage = !empty($planImageId) ? wp_get_attachment_url((int) $planImageId) : '';
261
262 // Calculate remaining seconds for existing blocks
263 $now = current_time('timestamp');
264 foreach ($existingBlocks as &$block) {
265 $expiresTimestamp = strtotime($block['expires_at']);
266 $block['remaining_seconds'] = max(0, $expiresTimestamp - $now);
267 }
268 unset($block);
269
270 $jsData = [
271 'planId' => (int) $plan['id'],
272 'planName' => $plan['name'] ?? '',
273 'layoutType' => $plan['layout_type'] ?? self::LAYOUT_SIMPLE,
274 'isPreview' => !empty($plan['_is_preview']),
275 'isRequired' => $manager->isSeatingRequired($productId),
276 'planImage' => $planImage,
277 'seats' => $seats,
278 'currentSelection' => $currentSelection,
279 'existingBlocks' => $existingBlocks,
280 'meta' => [
281 'canvas_width' => $plan['meta']['canvas_width'] ?? 800,
282 'canvas_height' => $plan['meta']['canvas_height'] ?? 600,
283 'background_color' => $plan['meta']['background_color'] ?? '#ffffff',
284 'background_image' => $plan['meta']['background_image'] ?? '',
285 'colors' => $plan['meta']['colors'] ?? [],
286 'decorations' => $plan['meta']['decorations'] ?? [],
287 'lines' => $plan['meta']['lines'] ?? [],
288 'labels' => $plan['meta']['labels'] ?? [],
289 ],
290 'adminUrl' => admin_url('admin.php?page=sasoEventTickets&tab=seating'),
291 ];
292
293 // Unique ID for this selector instance
294 $instanceId = 'saso-seating-' . $productId . '-' . uniqid();
295
296 ob_start();
297 ?>
298 <div class="saso-seating-selector"
299 id="<?php echo esc_attr($instanceId); ?>"
300 data-product-id="<?php echo esc_attr($productId); ?>"
301 data-event-date="<?php echo esc_attr($eventDate ?? ''); ?>"
302 data-cart-item-key="<?php echo esc_attr($cartItemKey ?? ''); ?>">
303 <input type="hidden" name="<?php echo esc_attr($this->getFieldSeatSelection()); ?>" class="saso-seat-selection-input"
304 value="<?php echo esc_attr($currentSelection ? json_encode($currentSelection) : ''); ?>">
305 </div>
306 <script type="application/json" id="<?php echo esc_attr($instanceId); ?>-data"><?php echo wp_json_encode($jsData); ?></script>
307 <?php
308 return ob_get_clean();
309 }
310
311 // =========================================================================
312 // AJAX Handlers
313 // =========================================================================
314
315 /**
316 * Execute Seating Frontend AJAX
317 *
318 * Central switch handler for all seating frontend AJAX requests.
319 * Called via relay_executeSeatingFrontend() in index.php
320 */
321 public function executeSeatingFrontend(): void {
322 $nonce_mode = $this->MAIN->_js_nonce;
323 if (!SASO_EVENTTICKETS::issetRPara('security') || !wp_verify_nonce(SASO_EVENTTICKETS::getRequestPara('security'), $nonce_mode)) {
324 wp_send_json(['nonce_fail' => 1]);
325 exit;
326 }
327 if (!SASO_EVENTTICKETS::issetRPara('a')) {
328 wp_send_json_error("a not provided");
329 return;
330 }
331
332 $ret = "";
333 $a = trim(SASO_EVENTTICKETS::getRequestPara('a'));
334 try {
335 switch ($a) {
336 case "blockSeat":
337 $this->doBlockSeat();
338 return; // doBlockSeat calls wp_send_json_* itself
339 case "releaseSeat":
340 $this->doReleaseSeat();
341 return;
342 case "getAvailableSeats":
343 $this->doGetAvailableSeats();
344 return;
345 default:
346 throw new Exception("#7001 " . sprintf(
347 esc_html__('function "%s" not implemented', 'event-tickets-with-ticket-scanner'),
348 $a
349 ));
350 }
351 } catch (Exception $e) {
352 $this->MAIN->getAdmin()->logErrorToDB($e);
353 wp_send_json_error(['msg' => $e->getMessage()]);
354 return;
355 }
356 wp_send_json_success($ret);
357 }
358
359 /**
360 * Internal: Block a seat
361 */
362 private function doBlockSeat(): void {
363 $seatId = isset($_POST['seat_id']) ? (int) $_POST['seat_id'] : 0;
364 $productIdRaw = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0;
365 $eventDate = isset($_POST['event_date']) ? sanitize_text_field($_POST['event_date']) : null;
366
367 if (!$seatId || !$productIdRaw) {
368 wp_send_json_error(['error' => 'missing_params']);
369 return;
370 }
371
372 // WPML: Normalize to original product ID
373 $productId = $this->MAIN->getTicketHandler()->getWPMLProductId($productIdRaw);
374
375 $result = $this->MAIN->getSeating()->blockSeatForCart($productId, $seatId, $eventDate);
376
377 if ($result['success']) {
378 // Get seat info for response
379 $seatInfo = $this->MAIN->getSeating()->getSeatInfo($seatId);
380
381 // Calculate remaining seconds for countdown (avoids timezone issues)
382 $expiresTimestamp = strtotime($result['expires_at']);
383 $remainingSeconds = max(0, $expiresTimestamp - current_time('timestamp'));
384
385 wp_send_json_success([
386 'block_id' => $result['block_id'],
387 'expires_at' => $result['expires_at'],
388 'remaining_seconds' => $remainingSeconds,
389 'extended' => $result['extended'] ?? false,
390 'seat' => $seatInfo
391 ]);
392 } else {
393 wp_send_json_error($result);
394 }
395 }
396
397 /**
398 * Internal: Release a seat
399 */
400 private function doReleaseSeat(): void {
401 $blockId = isset($_POST['block_id']) ? (int) $_POST['block_id'] : 0;
402
403 if (!$blockId) {
404 wp_send_json_error(['error' => 'missing_params']);
405 return;
406 }
407
408 $success = $this->MAIN->getSeating()->releaseSeatFromCart($blockId);
409
410 if ($success) {
411 wp_send_json_success();
412 } else {
413 wp_send_json_error(['error' => 'release_failed']);
414 }
415 }
416
417 /**
418 * Internal: Get available seats for product/date
419 */
420 private function doGetAvailableSeats(): void {
421 $productIdRaw = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0;
422 $eventDate = isset($_POST['event_date']) ? sanitize_text_field($_POST['event_date']) : null;
423
424 if (!$productIdRaw) {
425 wp_send_json_error(['error' => 'missing_params']);
426 return;
427 }
428
429 // WPML: Normalize to original product ID
430 $productId = $this->MAIN->getTicketHandler()->getWPMLProductId($productIdRaw);
431
432 $plan = $this->getPlanForProduct($productId);
433
434 if (!$plan) {
435 wp_send_json_error(['error' => 'no_plan']);
436 return;
437 }
438
439 $seating = $this->MAIN->getSeating();
440 $seats = $seating->getSeatsWithStatus((int) $plan['id'], $productId, $eventDate);
441 $stats = $seating->getStats((int) $plan['id'], $eventDate);
442
443 wp_send_json_success([
444 'plan' => [
445 'id' => $plan['id'],
446 'name' => $plan['name'],
447 'layout_type' => $plan['layout_type'] ?? self::LAYOUT_SIMPLE
448 ],
449 'seats' => $seats,
450 'stats' => $stats
451 ]);
452 }
453
454 /**
455 * AJAX: Block a seat (legacy - kept for compatibility)
456 * @deprecated Use executeSeatingFrontend with a=blockSeat instead
457 */
458 public function ajaxBlockSeat(): void {
459 check_ajax_referer('sasoEventtickets', 'security');
460
461 $seatId = isset($_POST['seat_id']) ? (int) $_POST['seat_id'] : 0;
462 $productIdRaw = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0;
463 $eventDate = isset($_POST['event_date']) ? sanitize_text_field($_POST['event_date']) : null;
464
465 if (!$seatId || !$productIdRaw) {
466 wp_send_json_error(['error' => 'missing_params']);
467 return;
468 }
469
470 // WPML: Normalize to original product ID
471 $productId = $this->MAIN->getTicketHandler()->getWPMLProductId($productIdRaw);
472
473 $result = $this->MAIN->getSeating()->blockSeatForCart($productId, $seatId, $eventDate);
474
475 if ($result['success']) {
476 // Get seat info for response
477 $seatInfo = $this->MAIN->getSeating()->getSeatInfo($seatId);
478
479 // Calculate remaining seconds for countdown (avoids timezone issues)
480 $expiresTimestamp = strtotime($result['expires_at']);
481 $remainingSeconds = max(0, $expiresTimestamp - current_time('timestamp'));
482
483 wp_send_json_success([
484 'block_id' => $result['block_id'],
485 'expires_at' => $result['expires_at'],
486 'remaining_seconds' => $remainingSeconds,
487 'extended' => $result['extended'] ?? false,
488 'seat' => $seatInfo
489 ]);
490 } else {
491 wp_send_json_error($result);
492 }
493 }
494
495 /**
496 * AJAX: Release a seat
497 */
498 public function ajaxReleaseSeat(): void {
499 check_ajax_referer('sasoEventtickets', 'security');
500
501 $blockId = isset($_POST['block_id']) ? (int) $_POST['block_id'] : 0;
502
503 if (!$blockId) {
504 wp_send_json_error(['error' => 'missing_params']);
505 return;
506 }
507
508 $success = $this->MAIN->getSeating()->releaseSeatFromCart($blockId);
509
510 if ($success) {
511 wp_send_json_success();
512 } else {
513 wp_send_json_error(['error' => 'release_failed']);
514 }
515 }
516
517 /**
518 * AJAX: Get available seats for product/date
519 */
520 public function ajaxGetAvailableSeats(): void {
521 check_ajax_referer('sasoEventtickets', 'security');
522
523 $productIdRaw = isset($_POST['product_id']) ? (int) $_POST['product_id'] : 0;
524 $eventDate = isset($_POST['event_date']) ? sanitize_text_field($_POST['event_date']) : null;
525
526 if (!$productIdRaw) {
527 wp_send_json_error(['error' => 'missing_params']);
528 return;
529 }
530
531 // WPML: Normalize to original product ID
532 $productId = $this->MAIN->getTicketHandler()->getWPMLProductId($productIdRaw);
533
534 $plan = $this->getPlanForProduct($productId);
535
536 if (!$plan) {
537 wp_send_json_error(['error' => 'no_plan']);
538 return;
539 }
540
541 $seating = $this->MAIN->getSeating();
542 $seats = $seating->getSeatsWithStatus((int) $plan['id'], $productId, $eventDate);
543 $stats = $seating->getStats((int) $plan['id'], $eventDate);
544
545 wp_send_json_success([
546 'plan' => [
547 'id' => $plan['id'],
548 'name' => $plan['name'],
549 'layout_type' => $plan['layout_type'] ?? self::LAYOUT_SIMPLE
550 ],
551 'seats' => $seats,
552 'stats' => $stats
553 ]);
554 }
555
556 /**
557 * Get cart item seat selection
558 *
559 * @param array $cartItem Cart item data
560 * @return array|null Seat selection data or null
561 */
562 public function getCartItemSeatSelection(array $cartItem): ?array {
563 $metaKey = $this->getMetaCartItemSeat();
564 if (!isset($cartItem[$metaKey])) {
565 return null;
566 }
567
568 $selection = $cartItem[$metaKey];
569 if (is_string($selection)) {
570 $selection = json_decode($selection, true);
571 }
572
573 return is_array($selection) ? $selection : null;
574 }
575
576 /**
577 * Validate seat selection for cart item
578 *
579 * @param int $productId Product ID
580 * @param int $seatId Seat ID
581 * @param string|null $eventDate Event date
582 * @return array Validation result: ['valid' => bool, 'error' => string]
583 */
584 public function validateSeatSelection(int $productId, int $seatId, ?string $eventDate = null): array {
585 // Validate IDs
586 if ($productId <= 0) {
587 $this->MAIN->getDB()->logError('validateSeatSelection: Invalid product ID: ' . $productId);
588 return ['valid' => false, 'error' => 'invalid_product'];
589 }
590 if ($seatId <= 0) {
591 $this->MAIN->getDB()->logError('validateSeatSelection: Invalid seat ID: ' . $seatId);
592 return ['valid' => false, 'error' => 'invalid_seat'];
593 }
594
595 // Check if product has seating plan
596 $plan = $this->getPlanForProduct($productId);
597 if (!$plan) {
598 return ['valid' => false, 'error' => 'no_seating_plan'];
599 }
600
601 $seating = $this->MAIN->getSeating();
602
603 // Check if seat exists and belongs to this plan
604 $seat = $seating->getSeatManager()->getById($seatId);
605 if (!$seat || (int) $seat['seatingplan_id'] !== (int) $plan['id']) {
606 return ['valid' => false, 'error' => 'invalid_seat'];
607 }
608
609 // Check if seat is active
610 if ((int) $seat['aktiv'] !== 1) {
611 return ['valid' => false, 'error' => 'seat_inactive'];
612 }
613
614 // Check availability - exclude current session's blocks (user's own blocks are allowed)
615 $sessionId = $this->getSessionId();
616 if (!$seating->getBlockManager()->isSeatAvailable($seatId, $productId, $eventDate, $sessionId)) {
617 return ['valid' => false, 'error' => 'seat_unavailable'];
618 }
619
620 return ['valid' => true];
621 }
622 }
623