| @@ -100,10 +100,17 @@ | ||
| 100 | 100 | $slug |
| 101 | 101 | ) |
| 102 | 102 | ); |
| 103 | 103 | |
| 104 | - // If trip exists but admin is not logged in, return null to show "not found" | |
| 105 | - if ($existingTrip && !current_user_can('yatra_edit_trips')) { | |
| 104 | + // If trip exists but viewer can't preview drafts, hide it. | |
| 105 | + // Admin fallback so site owners always see drafts even when | |
| 106 | + // the Team module isn't active and yatra_edit_trips isn't | |
| 107 | + // on the admin role. | |
| 108 | + if ( | |
| 109 | + $existingTrip | |
| 110 | + && !current_user_can('manage_options') | |
| 111 | + && !current_user_can('yatra_edit_trips') | |
| 112 | + ) { | |
| 106 | 113 | return null; |
| 107 | 114 | } |
| 108 | 115 | } |
| 109 | 116 | |
| @@ -216,13 +223,11 @@ | ||
| 216 | 223 | |
| 217 | 224 | // Get price types from database table (for traveler-based pricing) |
| 218 | 225 | $trip->price_types = $this->getPriceTypes((int) $trip->id); |
| 219 | 226 | |
| 220 | - // Determine pricing type - use database value, fallback to 'regular' | |
| 221 | - // If pricing_type is set to 'traveler_based' in DB, use that | |
| 222 | - // If pricing_type is empty but price_types exist, infer 'traveler_based' | |
| 223 | - if (empty($trip->pricing_type)) { | |
| 224 | - $trip->pricing_type = !empty($trip->price_types) ? 'traveler_based' : 'regular'; | |
| 227 | + // Normalize pricing_type with the same rules as {@see TripPricingService::resolvePricingType} | |
| 228 | + if ($trip->pricing_type === null || $trip->pricing_type === '') { | |
| 229 | + $trip->pricing_type = \Yatra\Services\TripPricingService::resolvePricingType($trip); | |
| 225 | 230 | } |
| 226 | 231 | |
| 227 | 232 | // Load itinerary from new database tables (preferred) or fallback to JSON field |
| 228 | 233 | $itinerary_from_db = $this->getItineraryDays((int) $trip->id); |
| @@ -343,20 +348,42 @@ | ||
| 343 | 348 | return (float) $pricing['effective_price_min']; |
| 344 | 349 | } |
| 345 | 350 | |
| 346 | 351 | /** |
| 347 | - * Decode JSON safely | |
| 352 | + * Decode a trip JSON / serialized field safely. | |
| 348 | 353 | * |
| 349 | - * @param string|null $json JSON string | |
| 350 | - * @return array Decoded array or empty array | |
| 354 | + * Accepts both JSON (`[...]`) and PHP-serialized (`a:N:{...}`) input because | |
| 355 | + * different code paths persist these columns differently: most fields are | |
| 356 | + * stored as JSON, but TripValidator::sanitize() uses `maybe_serialize()` for | |
| 357 | + * a handful of array fields (including `frontend_tabs`). If we only tried | |
| 358 | + * `json_decode`, the PHP-serialized payload would silently decode to null | |
| 359 | + * → empty array → admin-saved icons / tab labels disappear on the public | |
| 360 | + * trip page. Try `maybe_unserialize` first; fall back to JSON. | |
| 361 | + * | |
| 362 | + * NOTE on safety: `maybe_unserialize` is used on a trusted DB column written | |
| 363 | + * by our own validator. There's no user-controlled payload path that lands | |
| 364 | + * in this column without sanitisation. Pre-checking the `a:` / `s:` / | |
| 365 | + * `O:` prefix avoids tripping unserialize on random non-serialized data. | |
| 366 | + * | |
| 367 | + * @param string|null $value JSON string OR PHP-serialized string OR null. | |
| 368 | + * @return array Decoded array (empty on any failure). | |
| 351 | 369 | */ |
| 352 | - private function decodeJson(?string $json): array | |
| 370 | + private function decodeJson(?string $value): array | |
| 353 | 371 | { |
| 354 | - if (empty($json)) { | |
| 372 | + if (empty($value) || !is_string($value)) { | |
| 355 | 373 | return []; |
| 356 | 374 | } |
| 357 | 375 | |
| 358 | - $decoded = json_decode($json, true); | |
| 376 | + // Looks like PHP-serialized data — try maybe_unserialize first. | |
| 377 | + if (preg_match('/^(?:a|s|O):\d+:/', $value)) { | |
| 378 | + $decoded = maybe_unserialize($value); | |
| 379 | + if (is_array($decoded)) { | |
| 380 | + return $decoded; | |
| 381 | + } | |
| 382 | + } | |
| 383 | + | |
| 384 | + // Fall back to JSON. | |
| 385 | + $decoded = json_decode($value, true); | |
| 359 | 386 | return is_array($decoded) ? $decoded : []; |
| 360 | 387 | } |
| 361 | 388 | |
| 362 | 389 | /** |
| @@ -369,13 +396,14 @@ | ||
| 369 | 396 | { |
| 370 | 397 | // Use centralized AvailabilityResolutionService |
| 371 | 398 | $resolutionService = new \Yatra\Services\AvailabilityResolutionService(); |
| 372 | 399 | |
| 373 | - // Get dates for next 12 months | |
| 400 | + // From today up to the configurable booking horizon (Settings → Booking; | |
| 401 | + // 12 months unless changed — the previous hard-coded value). | |
| 374 | 402 | $fromDate = date('Y-m-d'); |
| 375 | - $toDate = date('Y-m-d', strtotime('+12 months')); | |
| 403 | + $toDate = yatra_get_availability_horizon_date($fromDate); | |
| 376 | 404 | |
| 377 | - $availability = $resolutionService->getAllAvailabilityDates($trip_id, $fromDate, $toDate); | |
| 405 | + $availability = $resolutionService->getAllAvailabilityDates($trip_id, $fromDate, $toDate, \Yatra\Services\SettingsService::isEnabled('show_sold_out')); | |
| 378 | 406 | |
| 379 | 407 | // Add calculated fields |
| 380 | 408 | foreach ($availability as $avail) { |
| 381 | 409 | // Calculate if limited availability |
| @@ -380,21 +408,8 @@ | ||
| 380 | 408 | foreach ($availability as $avail) { |
| 381 | 409 | // Calculate if limited availability |
| 382 | 410 | $avail->is_limited = ($avail->seats_available <= 5 && $avail->seats_available > 0); |
| 383 | 411 | $avail->is_sold_out = ($avail->seats_available <= 0 || $avail->status === 'sold_out'); |
| 384 | - | |
| 385 | - // Debug logging | |
| 386 | - if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 387 | - error_log(sprintf( | |
| 388 | - 'Yatra Availability [%s]: Date=%s, PricingType=%s, HasPriceTypes=%s, EffectivePrice=%s, Seats=%d', | |
| 389 | - $avail->source, | |
| 390 | - $avail->departure_date, | |
| 391 | - $avail->pricing_type, | |
| 392 | - !empty($avail->price_types) ? 'YES(' . count($avail->price_types) . ')' : 'NO', | |
| 393 | - $avail->effective_price ?? 'null', | |
| 394 | - $avail->seats_available | |
| 395 | - )); | |
| 396 | - } | |
| 397 | 412 | } |
| 398 | 413 | |
| 399 | 414 | return $availability; |
| 400 | 415 | } |
| @@ -452,8 +467,9 @@ | ||
| 452 | 467 | 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null, |
| 453 | 468 | 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null, |
| 454 | 469 | 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null, |
| 455 | 470 | 'max_pax' => isset($meta['max_pax']) ? (int) $meta['max_pax'] : null, |
| 471 | + 'group_overflow' => isset($meta['group_overflow']) && in_array($meta['group_overflow'], ['block', 'per_block'], true) ? $meta['group_overflow'] : 'block', | |
| 456 | 472 | 'max_quantity' => isset($meta['max_quantity']) ? (int) $meta['max_quantity'] : null, |
| 457 | 473 | 'description' => $meta['description'] ?? '', |
| 458 | 474 | ]; |
| 459 | 475 | } |
| @@ -477,13 +493,15 @@ | ||
| 477 | 493 | 'category_slug' => $cat ? $cat->slug : '', |
| 478 | 494 | 'original_price' => $original, |
| 479 | 495 | 'discounted_price' => $discounted, |
| 480 | 496 | 'effective_price' => $effective, |
| 497 | + 'is_default' => !empty($pt['is_default']), | |
| 481 | 498 | 'pricing_mode' => $cat ? $cat->pricing_mode : ($pt['pricing_mode'] ?? 'per_person'), |
| 482 | 499 | 'age_min' => $cat ? $cat->age_min : null, |
| 483 | 500 | 'age_max' => $cat ? $cat->age_max : null, |
| 484 | 501 | 'min_pax' => $cat ? $cat->min_pax : null, |
| 485 | 502 | 'max_pax' => $cat ? $cat->max_pax : null, |
| 503 | + 'group_overflow' => $cat ? $cat->group_overflow : ($pt['group_overflow'] ?? 'block'), | |
| 486 | 504 | 'max_quantity' => $cat ? $cat->max_quantity : null, |
| 487 | 505 | 'description' => $cat ? $cat->description : ($pt['description'] ?? ''), |
| 488 | 506 | ]; |
| 489 | 507 | } |
| @@ -1077,9 +1095,13 @@ | ||
| 1077 | 1095 | $patterns = [ |
| 1078 | 1096 | '/youtube\.com\/watch\?v=([^&]+)/', |
| 1079 | 1097 | '/youtube\.com\/embed\/([^?]+)/', |
| 1080 | 1098 | '/youtu\.be\/([^?]+)/', |
| 1081 | - '/youtube\.com\/v\/([^?]+)/' | |
| 1099 | + '/youtube\.com\/v\/([^?]+)/', | |
| 1100 | + // Shorts + live URLs (e.g. youtube.com/shorts/XFR9Ti-4RbM?si=...). | |
| 1101 | + // ID stops at ?, & or / so trailing query params are excluded. | |
| 1102 | + '/youtube\.com\/shorts\/([^?&\/]+)/', | |
| 1103 | + '/youtube\.com\/live\/([^?&\/]+)/' | |
| 1082 | 1104 | ]; |
| 1083 | 1105 | |
| 1084 | 1106 | foreach ($patterns as $pattern) { |
| 1085 | 1107 | if (preg_match($pattern, $url, $matches)) { |
| @@ -1211,13 +1233,19 @@ | ||
| 1211 | 1233 | */ |
| 1212 | 1234 | private function getSimilarTrips(object $trip): array |
| 1213 | 1235 | { |
| 1214 | 1236 | $trip_id = (int) $trip->id; |
| 1237 | + | |
| 1238 | + // Hour-based day tours (3.0.14+ column) — guarded so an install whose | |
| 1239 | + // upgrade ALTER has not run yet keeps rendering similar trips. | |
| 1240 | + $duration_hours_col = (new \Yatra\Repositories\TripRepository())->hasTripColumn('duration_hours') | |
| 1241 | + ? ', duration_hours' | |
| 1242 | + : ''; | |
| 1215 | 1243 | |
| 1216 | 1244 | // Get similar trips based on category or difficulty |
| 1217 | 1245 | $similar = $this->wpdb->get_results( |
| 1218 | 1246 | $this->wpdb->prepare( |
| 1219 | - "SELECT id, title, slug, featured_image AS featured_image_id, '' AS featured_image_url, duration_days, duration_nights, | |
| 1247 | + "SELECT id, title, slug, featured_image AS featured_image_id, '' AS featured_image_url, duration_days, duration_nights{$duration_hours_col}, | |
| 1220 | 1248 | original_price, sale_price, difficulty_level, |
| 1221 | 1249 | short_description |
| 1222 | 1250 | FROM {$this->table_trips} t |
| 1223 | 1251 | WHERE t.id != %d |
| @@ -1248,9 +1276,9 @@ | ||
| 1248 | 1276 | // Fallback: Get any published trips if no similar found |
| 1249 | 1277 | if (empty($similar)) { |
| 1250 | 1278 | $similar = $this->wpdb->get_results( |
| 1251 | 1279 | $this->wpdb->prepare( |
| 1252 | - "SELECT id, title, slug, featured_image AS featured_image_id, '' AS featured_image_url, duration_days, duration_nights, | |
| 1280 | + "SELECT id, title, slug, featured_image AS featured_image_id, '' AS featured_image_url, duration_days, duration_nights{$duration_hours_col}, | |
| 1253 | 1281 | original_price, sale_price, difficulty_level, |
| 1254 | 1282 | short_description |
| 1255 | 1283 | FROM {$this->table_trips} |
| 1256 | 1284 | WHERE id != %d |
| @@ -1450,9 +1478,10 @@ | ||
| 1450 | 1478 | $this->wpdb->prepare( |
| 1451 | 1479 | "SELECT e.*, |
| 1452 | 1480 | i.name as item_name, |
| 1453 | 1481 | it.name as item_type_name, |
| 1454 | - it.icon as item_type_icon | |
| 1482 | + it.icon as item_type_icon, | |
| 1483 | + it.color as item_type_color | |
| 1455 | 1484 | FROM {$table_entries} e |
| 1456 | 1485 | LEFT JOIN {$table_classifications} i ON e.item_id = i.id AND i.type = 'item' |
| 1457 | 1486 | LEFT JOIN {$table_classifications} it ON e.item_type_id = it.id AND it.type = 'item_type' |
| 1458 | 1487 | WHERE e.day_id = %d |
| @@ -1462,27 +1491,60 @@ | ||
| 1462 | 1491 | ); |
| 1463 | 1492 | |
| 1464 | 1493 | $formatted_entries = []; |
| 1465 | 1494 | foreach ($entries as $entry) { |
| 1495 | + $iconPicker = null; | |
| 1496 | + if (!empty($entry->item_type_icon)) { | |
| 1497 | + $rawIcon = $entry->item_type_icon; | |
| 1498 | + // Classification `icon` column may store a serialized array from the icon picker. | |
| 1499 | + // Decode it into the array shape expected by yatra_stored_picker_icon_markup(). | |
| 1500 | + $maybe = is_string($rawIcon) ? maybe_unserialize($rawIcon) : $rawIcon; | |
| 1501 | + if (is_array($maybe) && isset($maybe['type'])) { | |
| 1502 | + $iconPicker = $maybe; | |
| 1503 | + } elseif (is_string($rawIcon) && $rawIcon !== '') { | |
| 1504 | + // Backward compatibility: treat as yatra svg slug. | |
| 1505 | + $iconPicker = [ | |
| 1506 | + 'type' => 'icon', | |
| 1507 | + 'value' => (string) $rawIcon, | |
| 1508 | + 'provider' => 'yatra', | |
| 1509 | + ]; | |
| 1510 | + } | |
| 1511 | + } | |
| 1512 | + | |
| 1466 | 1513 | $formatted_entries[] = [ |
| 1467 | 1514 | 'title' => $entry->title ?: $entry->item_name, |
| 1468 | 1515 | 'description' => $entry->description ?: '', |
| 1469 | 1516 | 'item_type' => $entry->item_type_name ?: 'Activity', |
| 1470 | - 'icon' => $entry->item_type_icon ?: 'hiking', | |
| 1517 | + 'icon_picker' => $iconPicker, | |
| 1518 | + 'item_type_color' => !empty($entry->item_type_color) ? (string) $entry->item_type_color : '', | |
| 1471 | 1519 | 'start_time' => $entry->start_time ?: '', |
| 1472 | 1520 | 'end_time' => $entry->end_time ?: '', |
| 1521 | + // The public template needs time_type to know whether to render | |
| 1522 | + // exact times, the duration-only label, or "Flexible". Without | |
| 1523 | + // this, all rows fell through to the start_time branch and an | |
| 1524 | + // entry intended as "duration / flexible" still showed clock | |
| 1525 | + // values pulled from stale defaults. | |
| 1526 | + 'time_type' => $entry->time_type ?: 'exact', | |
| 1473 | 1527 | 'location' => $entry->location ?: '', |
| 1474 | 1528 | 'duration' => $entry->duration ?: '', |
| 1475 | 1529 | 'cost' => !empty($entry->cost) ? (float) $entry->cost : null, |
| 1476 | 1530 | 'cost_per_person' => !empty($entry->cost_per_person) ? true : false, |
| 1477 | 1531 | 'included' => !empty($entry->included_items) ? json_decode($entry->included_items, true) : [], |
| 1532 | + 'excluded' => !empty($entry->excluded_items) ? json_decode($entry->excluded_items, true) : [], | |
| 1478 | 1533 | 'gallery' => !empty($entry->gallery) ? $this->decodeGallery($entry->gallery) : [], |
| 1479 | 1534 | 'video_url' => $entry->video_url ?: '', |
| 1535 | + // The admin "Notes / Instructions" textarea ("Additional notes | |
| 1536 | + // or special instructions for this activity") was stored but | |
| 1537 | + // never reached the public template — the array key was | |
| 1538 | + // simply absent. Without this, operators saw their notes | |
| 1539 | + // discarded silently on the live trip page. | |
| 1540 | + 'notes' => (string) ($entry->notes ?? ''), | |
| 1480 | 1541 | ]; |
| 1481 | 1542 | } |
| 1482 | 1543 | |
| 1483 | 1544 | $itinerary[] = [ |
| 1484 | 1545 | 'day' => (int) $day->day_number, |
| 1546 | + /* translators: %d: itinerary day number. */ | |
| 1485 | 1547 | 'day_title' => $day->title ?: sprintf(__('Day %d', 'yatra'), $day->day_number), |
| 1486 | 1548 | 'day_description' => $day->description ?: '', |
| 1487 | 1549 | 'entries' => $formatted_entries, |
| 1488 | 1550 | ]; |
| @@ -1677,26 +1739,16 @@ | ||
| 1677 | 1739 | yatra_get_template('partials/single-trip/content-testimonials', ['trip' => $trip, 'tab' => $tab]); |
| 1678 | 1740 | break; |
| 1679 | 1741 | |
| 1680 | 1742 | case 'custom': |
| 1681 | - // Always show custom tab if enabled, even if content is empty | |
| 1682 | - echo '<section class="yatra-trip-section" id="' . esc_attr($tab->id) . '">'; | |
| 1683 | - echo '<h2 class="yatra-trip-section-title">'; | |
| 1684 | - echo yatra_svg_icon('book', 'yatra-trip-section-title-icon'); | |
| 1685 | - echo esc_html($tab->label); | |
| 1686 | - echo '</h2>'; | |
| 1687 | - echo '<div class="yatra-custom-content">'; | |
| 1688 | - | |
| 1689 | - // Display custom content if it exists, otherwise show empty message | |
| 1690 | - $custom_content = $tab->custom_content ?? ''; | |
| 1691 | - if (!empty($custom_content)) { | |
| 1692 | - echo wp_kses_post($custom_content); | |
| 1693 | - } else { | |
| 1694 | - echo '<p class="text-gray-500 text-center py-8">' . esc_html__('No custom content available for this section.', 'yatra') . '</p>'; | |
| 1695 | - } | |
| 1696 | - | |
| 1697 | - echo '</div>'; | |
| 1698 | - echo '</section>'; | |
| 1743 | + // Delegated to a partial so the admin-chosen icon (and label, content) | |
| 1744 | + // flow through the same yatra_render_tab_icon() pipeline as every other | |
| 1745 | + // tab type. Previously this branch hardcoded yatra_svg_icon('book') | |
| 1746 | + // which silently dropped the icon admins selected in Trip Builder. | |
| 1747 | + yatra_get_template('partials/single-trip/content-custom', [ | |
| 1748 | + 'trip' => $trip, | |
| 1749 | + 'tab' => $tab, | |
| 1750 | + ]); | |
| 1699 | 1751 | break; |
| 1700 | 1752 | } |
| 1701 | 1753 | } |
| 1702 | 1754 | |
| @@ -1767,8 +1819,18 @@ | ||
| 1767 | 1819 | $has_traveler_pricing = ($trip_pricing_type === 'traveler_based' && !empty($trip->price_types)); |
| 1768 | 1820 | $traveler_rows = []; |
| 1769 | 1821 | |
| 1770 | 1822 | if ($has_traveler_pricing) { |
| 1823 | + // Determine which price type should be selected by default (admin-selected default; else first) | |
| 1824 | + $default_index = 0; | |
| 1825 | + foreach ((array) $trip->price_types as $i => $pt_candidate) { | |
| 1826 | + $pt_candidate = is_array($pt_candidate) ? (object) $pt_candidate : $pt_candidate; | |
| 1827 | + if (!empty($pt_candidate->is_default)) { | |
| 1828 | + $default_index = (int) $i; | |
| 1829 | + break; | |
| 1830 | + } | |
| 1831 | + } | |
| 1832 | + | |
| 1771 | 1833 | // Traveler-Based Pricing: Show dynamic categories |
| 1772 | 1834 | foreach ($trip->price_types as $index => $price_type) { |
| 1773 | 1835 | // Normalize to object if array |
| 1774 | 1836 | $price_type = is_array($price_type) ? (object) $price_type : $price_type; |
| @@ -1777,12 +1839,15 @@ | ||
| 1777 | 1839 | $is_per_group = ($pricing_mode === 'per_group'); |
| 1778 | 1840 | $pricing_label = ''; |
| 1779 | 1841 | if ($is_per_group) { |
| 1780 | 1842 | if (!empty($price_type->min_pax) && !empty($price_type->max_pax)) { |
| 1781 | - $pricing_label = sprintf(__('per group (%d-%d pax)', 'yatra'), $price_type->min_pax, $price_type->max_pax); | |
| 1843 | + /* translators: 1: minimum pax for the group price, 2: maximum pax. */ | |
| 1844 | + $pricing_label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $price_type->min_pax, $price_type->max_pax); | |
| 1782 | 1845 | } elseif (!empty($price_type->max_pax)) { |
| 1846 | + /* translators: %d: maximum pax for the group price. */ | |
| 1783 | 1847 | $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $price_type->max_pax); |
| 1784 | 1848 | } elseif (!empty($price_type->min_pax)) { |
| 1849 | + /* translators: %d: minimum pax for the group price. */ | |
| 1785 | 1850 | $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $price_type->min_pax); |
| 1786 | 1851 | } else { |
| 1787 | 1852 | $pricing_label = __('per group', 'yatra'); |
| 1788 | 1853 | } |
| @@ -1787,15 +1852,24 @@ | ||
| 1787 | 1852 | $pricing_label = __('per group', 'yatra'); |
| 1788 | 1853 | } |
| 1789 | 1854 | } |
| 1790 | 1855 | |
| 1791 | - $display_price_type = $price_type->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $price_type); | |
| 1856 | + $pt_arr = (array) $price_type; | |
| 1857 | + $eff_before_dp = (float) ($price_type->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt_arr)); | |
| 1858 | + $display_price_type = $eff_before_dp; | |
| 1792 | 1859 | if (apply_filters('yatra_dynamic_pricing_enabled', false)) { |
| 1793 | 1860 | $trip_id = is_object($trip) && method_exists($trip, 'getId') ? $trip->getId() : ($trip->id ?? 0); |
| 1794 | - $display_price_type = apply_filters('yatra_trip_display_price', $display_price_type, $trip_id, [ | |
| 1861 | + $pt_orig_dp = (float) ($price_type->original_price ?? 0); | |
| 1862 | + $pt_disc_dp = (float) ($price_type->discounted_price ?? $price_type->sale_price ?? 0); | |
| 1863 | + if ($pt_disc_dp <= 0) { | |
| 1864 | + $pt_disc_dp = $eff_before_dp; | |
| 1865 | + } | |
| 1866 | + $display_price_type = apply_filters('yatra_trip_display_price', $eff_before_dp, $trip_id, [ | |
| 1795 | 1867 | 'departure_date' => null, |
| 1796 | 1868 | 'spots_remaining' => null, |
| 1797 | 1869 | 'price_type_id' => $price_type->id ?? null, |
| 1870 | + 'original_price' => $pt_orig_dp > 0 ? $pt_orig_dp : $eff_before_dp, | |
| 1871 | + 'discounted_price' => $pt_disc_dp > 0 ? $pt_disc_dp : $eff_before_dp, | |
| 1798 | 1872 | ]); |
| 1799 | 1873 | } |
| 1800 | 1874 | |
| 1801 | 1875 | $age_info = ''; |
| @@ -1802,12 +1876,15 @@ | ||
| 1802 | 1876 | $age_min = $price_type->age_min ?? null; |
| 1803 | 1877 | $age_max = $price_type->age_max ?? null; |
| 1804 | 1878 | if ($age_min !== null || $age_max !== null) { |
| 1805 | 1879 | if ($age_min !== null && $age_max !== null) { |
| 1806 | - $age_info = sprintf(__('(Age %d-%d)', 'yatra'), $age_min, $age_max); | |
| 1880 | + /* translators: 1: minimum age, 2: maximum age. */ | |
| 1881 | + $age_info = sprintf(__('(Age %1$d-%2$d)', 'yatra'), $age_min, $age_max); | |
| 1807 | 1882 | } elseif ($age_min !== null) { |
| 1883 | + /* translators: %d: minimum age. */ | |
| 1808 | 1884 | $age_info = sprintf(__('(Age %d+)', 'yatra'), $age_min); |
| 1809 | 1885 | } else { |
| 1886 | + /* translators: %d: maximum age. */ | |
| 1810 | 1887 | $age_info = sprintf(__('(Up to age %d)', 'yatra'), $age_max); |
| 1811 | 1888 | } |
| 1812 | 1889 | } |
| 1813 | 1890 | |
| @@ -1820,9 +1897,17 @@ | ||
| 1820 | 1897 | |
| 1821 | 1898 | $input_id = 'traveler_' . $price_type->category_id; |
| 1822 | 1899 | $max_travelers = is_object($trip) && method_exists($trip, 'getMaxTravelers') ? $trip->getMaxTravelers() : ($trip->max_travelers ?? 20); |
| 1823 | 1900 | $pt_max_qty = (int) ($price_type->max_quantity ?: $max_travelers); |
| 1824 | - $pt_value = ($index === 0) ? 1 : 0; | |
| 1901 | + // A per-group category in "block" overflow mode caps the party at | |
| 1902 | + // the max group size. In "per_block" mode the party may exceed it | |
| 1903 | + // (it just buys additional group blocks), so we keep the trip's | |
| 1904 | + // normal cap there. | |
| 1905 | + if ($is_per_group && !empty($price_type->max_pax) | |
| 1906 | + && (($price_type->group_overflow ?? 'block') !== 'per_block')) { | |
| 1907 | + $pt_max_qty = (int) $price_type->max_pax; | |
| 1908 | + } | |
| 1909 | + $pt_value = ($index === $default_index) ? 1 : 0; | |
| 1825 | 1910 | |
| 1826 | 1911 | $traveler_rows[] = [ |
| 1827 | 1912 | 'label' => $price_type->category_label ?: __('Traveler', 'yatra'), |
| 1828 | 1913 | 'subtitle' => $age_info, |
| @@ -1830,17 +1915,21 @@ | ||
| 1830 | 1915 | 'row_attrs' => [ |
| 1831 | 1916 | 'data-category-id' => $price_type->category_id, |
| 1832 | 1917 | 'data-price' => $price_type->effective_price, |
| 1833 | 1918 | 'data-pricing-mode' => $pricing_mode, |
| 1919 | + 'data-group-overflow' => $price_type->group_overflow ?? 'block', | |
| 1920 | + 'data-max-pax' => $price_type->max_pax ?? '', | |
| 1834 | 1921 | ], |
| 1835 | - 'minus_disabled' => ($index !== 0), | |
| 1922 | + 'minus_disabled' => ($index !== $default_index), | |
| 1836 | 1923 | 'plus_disabled' => false, |
| 1837 | 1924 | 'minus_attrs' => [ |
| 1838 | 1925 | 'data-target' => $input_id, |
| 1926 | + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */ | |
| 1839 | 1927 | 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $price_type->category_label), |
| 1840 | 1928 | ], |
| 1841 | 1929 | 'plus_attrs' => [ |
| 1842 | 1930 | 'data-target' => $input_id, |
| 1931 | + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */ | |
| 1843 | 1932 | 'aria-label' => sprintf(__('Increase %s', 'yatra'), $price_type->category_label), |
| 1844 | 1933 | ], |
| 1845 | 1934 | 'input_attrs' => [ |
| 1846 | 1935 | 'id' => $input_id, |
| @@ -1851,8 +1940,10 @@ | ||
| 1851 | 1940 | 'data-category' => $price_type->category_id, |
| 1852 | 1941 | 'data-category-label' => $price_type->category_label, |
| 1853 | 1942 | 'data-price' => $price_type->effective_price, |
| 1854 | 1943 | 'data-pricing-mode' => $pricing_mode, |
| 1944 | + 'data-group-overflow' => $price_type->group_overflow ?? 'block', | |
| 1945 | + 'data-max-pax' => $price_type->max_pax ?? '', | |
| 1855 | 1946 | ], |
| 1856 | 1947 | ]; |
| 1857 | 1948 | } |
| 1858 | 1949 | |
| @@ -1859,9 +1950,9 @@ | ||
| 1859 | 1950 | // Generate display text with all categories and their default values |
| 1860 | 1951 | $display_parts = []; |
| 1861 | 1952 | foreach ($trip->price_types as $index => $price_type) { |
| 1862 | 1953 | $category_label = $price_type->category_label ?? __('Traveler', 'yatra'); |
| 1863 | - $default_value = ($index === 0) ? 1 : 0; | |
| 1954 | + $default_value = ($index === $default_index) ? 1 : 0; | |
| 1864 | 1955 | |
| 1865 | 1956 | if ($default_value > 0) { |
| 1866 | 1957 | $display_parts[] = $category_label . ' x ' . $default_value; |
| 1867 | 1958 | } |
| @@ -1968,18 +2059,33 @@ | ||
| 1968 | 2059 | } |
| 1969 | 2060 | |
| 1970 | 2061 | // Build display text from initial travelers if provided |
| 1971 | 2062 | $display_parts = []; |
| 2063 | + | |
| 2064 | + // Default selection: if no initial travelers, pick admin-default category (else first) | |
| 2065 | + $default_category_id = null; | |
| 2066 | + foreach ($normalized_price_types as $pt_candidate) { | |
| 2067 | + if (!is_object($pt_candidate)) { | |
| 2068 | + continue; | |
| 2069 | + } | |
| 2070 | + if (!empty($pt_candidate->is_default) && !empty($pt_candidate->category_id)) { | |
| 2071 | + $default_category_id = (int) $pt_candidate->category_id; | |
| 2072 | + break; | |
| 2073 | + } | |
| 2074 | + } | |
| 1972 | 2075 | |
| 1973 | 2076 | foreach ($normalized_price_types as $pt_index => $pt) { |
| 1974 | 2077 | $pt_min = isset($pt->age_min) ? (int) $pt->age_min : 0; |
| 1975 | 2078 | $pt_max = isset($pt->age_max) ? (int) $pt->age_max : 99; |
| 1976 | 2079 | $pt_label = $pt->category_label ?? $pt->label ?? __('Traveler', 'yatra'); |
| 1977 | - $pt_age_text = ($pt_min > 0 || $pt_max < 99) ? sprintf(__('(Age %d-%d)', 'yatra'), $pt_min, $pt_max) : ''; | |
| 2080 | + /* translators: 1: minimum age, 2: maximum age. */ | |
| 2081 | + $pt_age_text = ($pt_min > 0 || $pt_max < 99) ? sprintf(__('(Age %1$d-%2$d)', 'yatra'), $pt_min, $pt_max) : ''; | |
| 1978 | 2082 | |
| 1979 | 2083 | // Use initial traveler count if provided, otherwise use default |
| 1980 | 2084 | $pt_category_id = $pt->category_id ?? $pt_index; |
| 1981 | - $pt_default = isset($initial_travelers[$pt_category_id]) ? (int) $initial_travelers[$pt_category_id] : ($pt_index === 0 ? 1 : 0); | |
| 2085 | + $pt_default = isset($initial_travelers[$pt_category_id]) | |
| 2086 | + ? (int) $initial_travelers[$pt_category_id] | |
| 2087 | + : (($default_category_id !== null && (int) $pt_category_id === (int) $default_category_id) ? 1 : (($default_category_id === null && $pt_index === 0) ? 1 : 0)); | |
| 1982 | 2088 | |
| 1983 | 2089 | $pt_price = 0; |
| 1984 | 2090 | if (isset($pt->effective_price) && $pt->effective_price > 0) { |
| 1985 | 2091 | $pt_price = (float) $pt->effective_price; |
| @@ -1992,13 +2098,20 @@ | ||
| 1992 | 2098 | } |
| 1993 | 2099 | |
| 1994 | 2100 | // Apply dynamic pricing to traveler category prices |
| 1995 | 2101 | if ($dp_enabled && $pt_price > 0) { |
| 2102 | + $pt_orig_dp = (float) ($pt->original_price ?? 0); | |
| 2103 | + $pt_disc_dp = (float) ($pt->discounted_price ?? $pt->sale_price ?? $pt->effective_price ?? 0); | |
| 2104 | + if ($pt_disc_dp <= 0) { | |
| 2105 | + $pt_disc_dp = $pt_price; | |
| 2106 | + } | |
| 1996 | 2107 | $pt_price = apply_filters('yatra_availability_price', $pt_price, $trip_id, [ |
| 1997 | 2108 | 'departure_date' => $card['date'] ?? null, |
| 1998 | 2109 | 'spots_remaining' => $card['spots_remaining'] ?? null, |
| 1999 | 2110 | 'availability_id' => $item_id, |
| 2000 | 2111 | 'price_type_id' => $pt->id ?? ($pt->price_type_id ?? null), |
| 2112 | + 'original_price' => $pt_orig_dp > 0 ? $pt_orig_dp : $pt_price, | |
| 2113 | + 'discounted_price' => $pt_disc_dp > 0 ? $pt_disc_dp : $pt_price, | |
| 2001 | 2114 | ]); |
| 2002 | 2115 | } |
| 2003 | 2116 | |
| 2004 | 2117 | $pt_category_id = $pt->category_id ?? $pt_index; |
| @@ -2005,17 +2118,26 @@ | ||
| 2005 | 2118 | $pt_min_qty = 0; |
| 2006 | 2119 | $pt_max_qty = (int) min($seats_available, $max_travelers); |
| 2007 | 2120 | $pt_pricing_mode = $pt->pricing_mode ?? 'per_person'; |
| 2008 | 2121 | $pt_is_per_group = ($pt_pricing_mode === 'per_group'); |
| 2009 | - | |
| 2122 | + // Cap a per-group "block" category at its max group size (still | |
| 2123 | + // bounded by seats). "per_block" mode may exceed it, so skip. | |
| 2124 | + if ($pt_is_per_group && !empty($pt->max_pax) | |
| 2125 | + && (($pt->group_overflow ?? 'block') !== 'per_block')) { | |
| 2126 | + $pt_max_qty = (int) min($pt_max_qty, (int) $pt->max_pax); | |
| 2127 | + } | |
| 2128 | + | |
| 2010 | 2129 | // Build pricing label |
| 2011 | 2130 | $pricing_label = ''; |
| 2012 | 2131 | if ($pt_is_per_group) { |
| 2013 | 2132 | if (!empty($pt->min_pax) && !empty($pt->max_pax)) { |
| 2014 | - $pricing_label = sprintf(__('per group (%d-%d pax)', 'yatra'), $pt->min_pax, $pt->max_pax); | |
| 2133 | + /* translators: 1: minimum pax for the group price, 2: maximum pax. */ | |
| 2134 | + $pricing_label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $pt->min_pax, $pt->max_pax); | |
| 2015 | 2135 | } elseif (!empty($pt->max_pax)) { |
| 2136 | + /* translators: %d: maximum pax for the group price. */ | |
| 2016 | 2137 | $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $pt->max_pax); |
| 2017 | 2138 | } elseif (!empty($pt->min_pax)) { |
| 2139 | + /* translators: %d: minimum pax for the group price. */ | |
| 2018 | 2140 | $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $pt->min_pax); |
| 2019 | 2141 | } else { |
| 2020 | 2142 | $pricing_label = __('per group', 'yatra'); |
| 2021 | 2143 | } |
| @@ -2035,17 +2157,21 @@ | ||
| 2035 | 2157 | 'row_attrs' => [ |
| 2036 | 2158 | 'data-category-id' => $pt_category_id, |
| 2037 | 2159 | 'data-price' => $pt_price, |
| 2038 | 2160 | 'data-pricing-mode' => $pt_pricing_mode, |
| 2161 | + 'data-group-overflow' => $pt->group_overflow ?? 'block', | |
| 2162 | + 'data-max-pax' => $pt->max_pax ?? '', | |
| 2039 | 2163 | ], |
| 2040 | - 'minus_disabled' => ($pt_index !== 0), | |
| 2164 | + 'minus_disabled' => ($pt_default <= 0), | |
| 2041 | 2165 | 'plus_disabled' => false, |
| 2042 | 2166 | 'minus_attrs' => [ |
| 2043 | 2167 | 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id, |
| 2168 | + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */ | |
| 2044 | 2169 | 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $pt_label), |
| 2045 | 2170 | ], |
| 2046 | 2171 | 'plus_attrs' => [ |
| 2047 | 2172 | 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id, |
| 2173 | + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */ | |
| 2048 | 2174 | 'aria-label' => sprintf(__('Increase %s', 'yatra'), $pt_label), |
| 2049 | 2175 | ], |
| 2050 | 2176 | 'input_attrs' => [ |
| 2051 | 2177 | 'data-item' => $item_id, |
| @@ -2051,8 +2177,10 @@ | ||
| 2051 | 2177 | 'data-item' => $item_id, |
| 2052 | 2178 | 'data-category' => $pt_category_id, |
| 2053 | 2179 | 'data-price' => $pt_price, |
| 2054 | 2180 | 'data-pricing-mode' => $pt_pricing_mode, |
| 2181 | + 'data-group-overflow' => $pt->group_overflow ?? 'block', | |
| 2182 | + 'data-max-pax' => $pt->max_pax ?? '', | |
| 2055 | 2183 | 'value' => $pt_default, |
| 2056 | 2184 | 'min' => $pt_min_qty, |
| 2057 | 2185 | 'max' => $pt_max_qty, |
| 2058 | 2186 | ], |