PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Controllers/SingleTripController.php +192 -64 3.0.2.6 → 3.0.16 View file →
@@ -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 ],