| @@ -27,8 +27,29 @@ | ||
| 27 | 27 | if ($is_sold_out || $card_status === 'sold_out') { |
| 28 | 28 | $card_status = 'sold_out'; |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | + // A sold-out departure is only bookable as a WAITLIST entry, and only | |
| 32 | + // when the server would actually accept it — the same gate | |
| 33 | + // BookingSessionController applies (WaitlistService::canJoinWaitlist). | |
| 34 | + // When waitlist is unavailable the booking is rejected at the final | |
| 35 | + // step, so the Book Now button must be blocked here rather than | |
| 36 | + // failing late (the reported bug). Probing with 1 traveler is | |
| 37 | + // sufficient: canJoinWaitlist requires seats_available < N, which for a | |
| 38 | + // sold-out card (0 seats) holds for any N >= 1. | |
| 39 | + $waitlist_available = false; | |
| 40 | + if ($is_sold_out && class_exists('\\Yatra\\Services\\WaitlistService')) { | |
| 41 | + $waitlist_probe = (object) [ | |
| 42 | + 'id' => (int) ($card['id'] ?? 0), | |
| 43 | + 'seats_available' => $seats_available, | |
| 44 | + ]; | |
| 45 | + $waitlist_available = \Yatra\Services\WaitlistService::canJoinWaitlist( | |
| 46 | + $trip_data, | |
| 47 | + $waitlist_probe, | |
| 48 | + 1 | |
| 49 | + ); | |
| 50 | + } | |
| 51 | + | |
| 31 | 52 | // Match Y-m-d even if DB returns datetime (e.g. 2026-08-13 00:00:00) |
| 32 | 53 | $yatra_card_date_norm = static function ($v): string { |
| 33 | 54 | $v = trim((string) $v); |
| 34 | 55 | return preg_match('/^(\d{4}-\d{2}-\d{2})/', $v, $m) ? $m[1] : $v; |
| @@ -185,15 +206,15 @@ | ||
| 185 | 206 | <div class="yatra-card-header-sub"><?php esc_html_e('per departure', 'yatra'); ?></div> |
| 186 | 207 | </div> |
| 187 | 208 | |
| 188 | 209 | <!-- Price (sale only; struck original + % badge sit in .yatra-card-badges-container with status) --> |
| 189 | - <div class="yatra-card-header-price"> | |
| 210 | + <div class="yatra-card-header-price" data-price-unit="<?php echo esc_attr($card['price_unit'] ?? 'per_person'); ?>"> | |
| 190 | 211 | <div class="yatra-card-price-group yatra-card-price-group--stack"> |
| 191 | 212 | <?php |
| 192 | 213 | echo '<span class="yatra-sale-price">' . yatra_format_price($display_sale_price) . '</span>'; |
| 193 | 214 | ?> |
| 194 | 215 | </div> |
| 195 | - <span class="yatra-price-label"><?php echo esc_html__('per person', 'yatra'); ?></span> | |
| 216 | + <span class="yatra-price-label"><?php echo esc_html($card['price_unit_label'] ?? __('per person', 'yatra')); ?></span> | |
| 196 | 217 | </div> |
| 197 | 218 | |
| 198 | 219 | <!-- Toggle Arrow --> |
| 199 | 220 | <div class="yatra-card-header-arrow"> |
| @@ -215,10 +236,22 @@ | ||
| 215 | 236 | <div class="yatra-card-info-content"> |
| 216 | 237 | <div class="yatra-card-info-label"><?php esc_html_e('Duration', 'yatra'); ?></div> |
| 217 | 238 | <div class="yatra-card-info-value"> |
| 218 | 239 | <?php |
| 219 | - $duration_days = (int) ($trip_data->duration_days ?? 1); | |
| 220 | - echo esc_html($duration_days . ' ' . _n('Day', 'Days', $duration_days, 'yatra')); | |
| 240 | + // Prefer the per-card span (matches this card's departure/return | |
| 241 | + // dates); fall back to the trip default for sample cards. | |
| 242 | + $duration_days = (int) ($card['duration_days'] ?? $trip_data->duration_days ?? 1); | |
| 243 | + $duration_hours = (int) ($trip_data->duration_hours ?? 0); | |
| 244 | + if ($duration_hours > 0) { | |
| 245 | + // Hour-based day tour: "8 Hours", not "1 Day". | |
| 246 | + echo esc_html(sprintf( | |
| 247 | + /* translators: %d: number of hours. */ | |
| 248 | + _n('%d Hour', '%d Hours', $duration_hours, 'yatra'), | |
| 249 | + $duration_hours | |
| 250 | + )); | |
| 251 | + } else { | |
| 252 | + echo esc_html($duration_days . ' ' . _n('Day', 'Days', $duration_days, 'yatra')); | |
| 253 | + } | |
| 221 | 254 | ?> |
| 222 | 255 | </div> |
| 223 | 256 | </div> |
| 224 | 257 | </div> |
| @@ -228,9 +261,12 @@ | ||
| 228 | 261 | <?php echo yatra_svg_icon('users', 'yatra-icon-sm'); ?> |
| 229 | 262 | </div> |
| 230 | 263 | <div class="yatra-card-info-content"> |
| 231 | 264 | <div class="yatra-card-info-label"><?php esc_html_e('Seats left', 'yatra'); ?></div> |
| 232 | - <div class="yatra-card-info-value"><?php echo esc_html(sprintf(_n('%d seat', '%d seats', $seats_available, 'yatra'), $seats_available)); ?></div> | |
| 265 | + <div class="yatra-card-info-value"><?php | |
| 266 | + /* translators: %d: number of seats remaining. */ | |
| 267 | + echo esc_html(sprintf(_n('%d seat', '%d seats', $seats_available, 'yatra'), $seats_available)); | |
| 268 | + ?></div> | |
| 233 | 269 | </div> |
| 234 | 270 | </div> |
| 235 | 271 | |
| 236 | 272 | <?php |
| @@ -251,9 +287,10 @@ | ||
| 251 | 287 | if (preg_match('/(\d+)\s*(hour|day|week)s?\s*before/i', $cancellation_policy, $matches)) { |
| 252 | 288 | $time_value = $matches[1]; |
| 253 | 289 | $time_unit = $matches[2]; |
| 254 | 290 | $cancellation_value = sprintf( |
| 255 | - __('Up to %d %s%s before', 'yatra'), | |
| 291 | + /* translators: 1: numeric amount, 2: time unit (hour/day/week), 3: plural "s" suffix if applicable. */ | |
| 292 | + __('Up to %1$d %2$s%3$s before', 'yatra'), | |
| 256 | 293 | $time_value, |
| 257 | 294 | $time_unit, |
| 258 | 295 | $time_value > 1 ? 's' : '' |
| 259 | 296 | ); |
| @@ -319,8 +356,21 @@ | ||
| 319 | 356 | $pt_category_id = $pt->category_id ?? $pt_index; |
| 320 | 357 | $pt_default = $pt_index === 0 ? 1 : 0; |
| 321 | 358 | $pt_min_qty = 0; |
| 322 | 359 | $pt_max_qty = (int) min($seats_available, $max_travelers); |
| 360 | + // Cap a per-group "block" category at its max group | |
| 361 | + // size (still bounded by seats). "per_block" mode may | |
| 362 | + // exceed it (additional group blocks), so skip. | |
| 363 | + if ($pt_is_per_group && !empty($pt->max_pax) | |
| 364 | + && (($pt->group_overflow ?? 'block') !== 'per_block')) { | |
| 365 | + $pt_max_qty = (int) min($pt_max_qty, (int) $pt->max_pax); | |
| 366 | + } | |
| 367 | + // A sold-out departure has no seats left to offer, so the | |
| 368 | + // first category must not still open at 1 traveler. Clamping | |
| 369 | + // here also disables both +/- buttons below (0 <= 0 >= 0). | |
| 370 | + if ($pt_default > $pt_max_qty) { | |
| 371 | + $pt_default = $pt_max_qty; | |
| 372 | + } | |
| 323 | 373 | |
| 324 | 374 | // Determine price with fallback chain |
| 325 | 375 | $pt_price = 0; |
| 326 | 376 | if (isset($pt->effective_price) && $pt->effective_price > 0) { |
| @@ -353,12 +403,15 @@ | ||
| 353 | 403 | $pt_age_min = isset($pt->age_min) ? (int) $pt->age_min : null; |
| 354 | 404 | $pt_age_max = isset($pt->age_max) ? (int) $pt->age_max : null; |
| 355 | 405 | if ($pt_age_min !== null || $pt_age_max !== null) { |
| 356 | 406 | if ($pt_age_min !== null && $pt_age_max !== null) { |
| 357 | - $pt_age_text = sprintf(__('(Age %d-%d)', 'yatra'), $pt_age_min, $pt_age_max); | |
| 407 | + /* translators: 1: minimum age, 2: maximum age. */ | |
| 408 | + $pt_age_text = sprintf(__('(Age %1$d-%2$d)', 'yatra'), $pt_age_min, $pt_age_max); | |
| 358 | 409 | } elseif ($pt_age_min !== null) { |
| 410 | + /* translators: %d: minimum age. */ | |
| 359 | 411 | $pt_age_text = sprintf(__('(Age %d+)', 'yatra'), $pt_age_min); |
| 360 | 412 | } else { |
| 413 | + /* translators: %d: maximum age. */ | |
| 361 | 414 | $pt_age_text = sprintf(__('(Up to age %d)', 'yatra'), $pt_age_max); |
| 362 | 415 | } |
| 363 | 416 | } |
| 364 | 417 | |
| @@ -365,12 +418,15 @@ | ||
| 365 | 418 | // Build pricing label for per_group mode |
| 366 | 419 | $pricing_label = ''; |
| 367 | 420 | if ($pt_is_per_group) { |
| 368 | 421 | if (!empty($pt->min_pax) && !empty($pt->max_pax)) { |
| 369 | - $pricing_label = sprintf(__('per group (%d-%d pax)', 'yatra'), $pt->min_pax, $pt->max_pax); | |
| 422 | + /* translators: 1: minimum pax for the group price, 2: maximum pax. */ | |
| 423 | + $pricing_label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $pt->min_pax, $pt->max_pax); | |
| 370 | 424 | } elseif (!empty($pt->max_pax)) { |
| 425 | + /* translators: %d: maximum pax for the group price. */ | |
| 371 | 426 | $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $pt->max_pax); |
| 372 | 427 | } elseif (!empty($pt->min_pax)) { |
| 428 | + /* translators: %d: minimum pax for the group price. */ | |
| 373 | 429 | $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $pt->min_pax); |
| 374 | 430 | } else { |
| 375 | 431 | $pricing_label = __('per group', 'yatra'); |
| 376 | 432 | } |
| @@ -396,8 +452,10 @@ | ||
| 396 | 452 | 'row_attrs' => [ |
| 397 | 453 | 'data-category-id' => $pt_category_id, |
| 398 | 454 | 'data-price' => $pt_price, |
| 399 | 455 | 'data-pricing-mode' => $pt_pricing_mode, |
| 456 | + 'data-group-overflow' => $pt->group_overflow ?? 'block', | |
| 457 | + 'data-max-pax' => $pt->max_pax ?? '', | |
| 400 | 458 | ], |
| 401 | 459 | 'minus_disabled' => $pt_default <= $pt_min_qty, |
| 402 | 460 | 'plus_disabled' => $pt_default >= $pt_max_qty, |
| 403 | 461 | 'minus_attrs' => [ |
| @@ -403,8 +461,9 @@ | ||
| 403 | 461 | 'minus_attrs' => [ |
| 404 | 462 | 'data-target' => $input_id, |
| 405 | 463 | 'data-item' => $item_id, |
| 406 | 464 | 'data-category' => $pt_category_id, |
| 465 | + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */ | |
| 407 | 466 | 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $pt_label), |
| 408 | 467 | ], |
| 409 | 468 | 'plus_attrs' => [ |
| 410 | 469 | 'data-target' => $input_id, |
| @@ -409,8 +468,9 @@ | ||
| 409 | 468 | 'plus_attrs' => [ |
| 410 | 469 | 'data-target' => $input_id, |
| 411 | 470 | 'data-item' => $item_id, |
| 412 | 471 | 'data-category' => $pt_category_id, |
| 472 | + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */ | |
| 413 | 473 | 'aria-label' => sprintf(__('Increase %s', 'yatra'), $pt_label), |
| 414 | 474 | ], |
| 415 | 475 | 'input_attrs' => [ |
| 416 | 476 | 'id' => $input_id, |
| @@ -422,8 +482,10 @@ | ||
| 422 | 482 | 'data-category' => $pt_category_id, |
| 423 | 483 | 'data-category-label' => $pt_label, |
| 424 | 484 | 'data-price' => $pt_price, |
| 425 | 485 | 'data-pricing-mode' => $pt_pricing_mode, |
| 486 | + 'data-group-overflow' => $pt->group_overflow ?? 'block', | |
| 487 | + 'data-max-pax' => $pt->max_pax ?? '', | |
| 426 | 488 | ], |
| 427 | 489 | ]; |
| 428 | 490 | |
| 429 | 491 | if ($pt_default > 0) { |
| @@ -459,8 +521,17 @@ | ||
| 459 | 521 | include YATRA_PLUGIN_PATH . 'templates/partials/traveler-selector.php'; |
| 460 | 522 | ?> |
| 461 | 523 | <?php else: ?> |
| 462 | 524 | <!-- Regular pricing: Show simple number of travelers --> |
| 525 | + <?php | |
| 526 | + // A sold-out departure offers no seats, so the counter must start | |
| 527 | + // at 0 (not 1) and both controls stay disabled. Only the ceiling | |
| 528 | + // was capacity-aware before, which left sold-out cards showing a | |
| 529 | + // selectable traveler. | |
| 530 | + $simple_max_qty = (int) min($seats_available, $max_travelers); | |
| 531 | + $simple_qty = $simple_max_qty > 0 ? 1 : 0; | |
| 532 | + $simple_min_qty = $simple_qty; | |
| 533 | + ?> | |
| 463 | 534 | <div class="yatra-booking-travelers-simple yatra-availability-travelers-simple" data-item="<?php echo esc_attr($item_id); ?>"> |
| 464 | 535 | <div class="yatra-booking-field-icon"> |
| 465 | 536 | <?php echo yatra_svg_icon('users', 'yatra-icon-sm'); ?> |
| 466 | 537 | </div> |
| @@ -469,18 +540,18 @@ | ||
| 469 | 540 | <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 470 | 541 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4"/> |
| 471 | 542 | </svg> |
| 472 | 543 | </button> |
| 473 | - <input type="number" | |
| 474 | - id="num-travelers-<?php echo esc_attr($item_id); ?>" | |
| 475 | - class="yatra-quantity-input-simple yatra-availability-num-travelers" | |
| 544 | + <input type="number" | |
| 545 | + id="num-travelers-<?php echo esc_attr($item_id); ?>" | |
| 546 | + class="yatra-quantity-input-simple yatra-availability-num-travelers" | |
| 476 | 547 | data-item="<?php echo esc_attr($item_id); ?>" |
| 477 | - value="1" | |
| 478 | - min="1" | |
| 479 | - max="<?php echo esc_attr(min($seats_available, $max_travelers)); ?>" | |
| 548 | + value="<?php echo esc_attr($simple_qty); ?>" | |
| 549 | + min="<?php echo esc_attr($simple_min_qty); ?>" | |
| 550 | + max="<?php echo esc_attr($simple_max_qty); ?>" | |
| 480 | 551 | readonly |
| 481 | 552 | data-price="<?php echo esc_attr($display_sale_price); ?>"> |
| 482 | - <button type="button" class="yatra-quantity-btn yatra-quantity-plus" data-target="num-travelers-<?php echo esc_attr($item_id); ?>" aria-label="<?php esc_attr_e('Increase travelers', 'yatra'); ?>"> | |
| 553 | + <button type="button" class="yatra-quantity-btn yatra-quantity-plus" data-target="num-travelers-<?php echo esc_attr($item_id); ?>" aria-label="<?php esc_attr_e('Increase travelers', 'yatra'); ?>"<?php echo $simple_qty >= $simple_max_qty ? ' disabled' : ''; ?>> | |
| 483 | 554 | <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 484 | 555 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/> |
| 485 | 556 | </svg> |
| 486 | 557 | </button> |
| @@ -535,10 +606,27 @@ | ||
| 535 | 606 | |
| 536 | 607 | // Is this a day trip? |
| 537 | 608 | $is_card_day_trip = !empty($card['is_day_trip']) || !empty($card['date_display']); |
| 538 | 609 | ?> |
| 539 | - <button type="button" | |
| 540 | - class="yatra-card-book-btn" | |
| 610 | + <?php if ($is_sold_out && !$waitlist_available): ?> | |
| 611 | + <?php // Sold out and no waitlist available: block booking here. | |
| 612 | + // The server would reject this at the final step | |
| 613 | + // (BookingSessionController → code 'sold_out'), so we | |
| 614 | + // disable the button instead of letting the customer go | |
| 615 | + // all the way through and fail. ?> | |
| 616 | + <button type="button" | |
| 617 | + class="yatra-card-book-btn yatra-card-book-btn--sold-out" | |
| 618 | + disabled | |
| 619 | + aria-disabled="true" | |
| 620 | + data-item="<?php echo esc_attr($item_id); ?>"> | |
| 621 | + <span class="yatra-card-book-btn-text"> | |
| 622 | + <span class="yatra-card-book-btn-title"><?php esc_html_e('Sold Out', 'yatra'); ?></span> | |
| 623 | + <span class="yatra-card-book-btn-subtitle"><?php esc_html_e('No seats available', 'yatra'); ?></span> | |
| 624 | + </span> | |
| 625 | + </button> | |
| 626 | + <?php else: ?> | |
| 627 | + <button type="button" | |
| 628 | + class="yatra-card-book-btn<?php echo ($is_sold_out && $waitlist_available) ? ' yatra-card-book-btn--waitlist' : ''; ?>" | |
| 541 | 629 | data-trip-id="<?php echo esc_attr($trip_id); ?>" |
| 542 | 630 | data-availability-id="<?php echo esc_attr($item_id); ?>" |
| 543 | 631 | data-date="<?php echo esc_attr($card['data_date'] ?? ''); ?>" |
| 544 | 632 | data-departure-time="<?php echo esc_attr($card_departure_time); ?>" |
| @@ -548,11 +636,17 @@ | ||
| 548 | 636 | data-price="<?php echo esc_attr($display_sale_price); ?>" |
| 549 | 637 | data-item="<?php echo esc_attr($item_id); ?>"> |
| 550 | 638 | <?php echo yatra_svg_icon('shopping-cart', 'yatra-icon-sm'); ?> |
| 551 | 639 | <span class="yatra-card-book-btn-text"> |
| 640 | + <?php if ($is_sold_out && $waitlist_available): ?> | |
| 641 | + <span class="yatra-card-book-btn-title"><?php esc_html_e('Join Waitlist', 'yatra'); ?></span> | |
| 642 | + <span class="yatra-card-book-btn-subtitle"><?php esc_html_e('Sold out — join the waitlist', 'yatra'); ?></span> | |
| 643 | + <?php else: ?> | |
| 552 | 644 | <span class="yatra-card-book-btn-title"><?php esc_html_e('Book Now', 'yatra'); ?></span> |
| 553 | 645 | <span class="yatra-card-book-btn-subtitle"><?php esc_html_e('Continue to booking', 'yatra'); ?></span> |
| 646 | + <?php endif; ?> | |
| 554 | 647 | </span> |
| 555 | 648 | </button> |
| 649 | + <?php endif; ?> | |
| 556 | 650 | </div> |
| 557 | 651 | </div> |
| 558 | 652 | </div> |