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 |