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 +162 -46 3.0.2.9 → 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
@@ -439,8 +467,9 @@
439 467 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null,
440 468 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null,
441 469 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null,
442 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',
443 472 'max_quantity' => isset($meta['max_quantity']) ? (int) $meta['max_quantity'] : null,
444 473 'description' => $meta['description'] ?? '',
445 474 ];
446 475 }
@@ -470,8 +499,9 @@
470 499 'age_min' => $cat ? $cat->age_min : null,
471 500 'age_max' => $cat ? $cat->age_max : null,
472 501 'min_pax' => $cat ? $cat->min_pax : null,
473 502 'max_pax' => $cat ? $cat->max_pax : null,
503 + 'group_overflow' => $cat ? $cat->group_overflow : ($pt['group_overflow'] ?? 'block'),
474 504 'max_quantity' => $cat ? $cat->max_quantity : null,
475 505 'description' => $cat ? $cat->description : ($pt['description'] ?? ''),
476 506 ];
477 507 }
@@ -1065,9 +1095,13 @@
1065 1095 $patterns = [
1066 1096 '/youtube\.com\/watch\?v=([^&]+)/',
1067 1097 '/youtube\.com\/embed\/([^?]+)/',
1068 1098 '/youtu\.be\/([^?]+)/',
1069 - '/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\/([^?&\/]+)/'
1070 1104 ];
1071 1105
1072 1106 foreach ($patterns as $pattern) {
1073 1107 if (preg_match($pattern, $url, $matches)) {
@@ -1199,13 +1233,19 @@
1199 1233 */
1200 1234 private function getSimilarTrips(object $trip): array
1201 1235 {
1202 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 + : '';
1203 1243
1204 1244 // Get similar trips based on category or difficulty
1205 1245 $similar = $this->wpdb->get_results(
1206 1246 $this->wpdb->prepare(
1207 - "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},
1208 1248 original_price, sale_price, difficulty_level,
1209 1249 short_description
1210 1250 FROM {$this->table_trips} t
1211 1251 WHERE t.id != %d
@@ -1236,9 +1276,9 @@
1236 1276 // Fallback: Get any published trips if no similar found
1237 1277 if (empty($similar)) {
1238 1278 $similar = $this->wpdb->get_results(
1239 1279 $this->wpdb->prepare(
1240 - "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},
1241 1281 original_price, sale_price, difficulty_level,
1242 1282 short_description
1243 1283 FROM {$this->table_trips}
1244 1284 WHERE id != %d
@@ -1438,9 +1478,10 @@
1438 1478 $this->wpdb->prepare(
1439 1479 "SELECT e.*,
1440 1480 i.name as item_name,
1441 1481 it.name as item_type_name,
1442 - it.icon as item_type_icon
1482 + it.icon as item_type_icon,
1483 + it.color as item_type_color
1443 1484 FROM {$table_entries} e
1444 1485 LEFT JOIN {$table_classifications} i ON e.item_id = i.id AND i.type = 'item'
1445 1486 LEFT JOIN {$table_classifications} it ON e.item_type_id = it.id AND it.type = 'item_type'
1446 1487 WHERE e.day_id = %d
@@ -1450,27 +1491,60 @@
1450 1491 );
1451 1492
1452 1493 $formatted_entries = [];
1453 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 +
1454 1513 $formatted_entries[] = [
1455 1514 'title' => $entry->title ?: $entry->item_name,
1456 1515 'description' => $entry->description ?: '',
1457 1516 'item_type' => $entry->item_type_name ?: 'Activity',
1458 - 'icon' => $entry->item_type_icon ?: 'hiking',
1517 + 'icon_picker' => $iconPicker,
1518 + 'item_type_color' => !empty($entry->item_type_color) ? (string) $entry->item_type_color : '',
1459 1519 'start_time' => $entry->start_time ?: '',
1460 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',
1461 1527 'location' => $entry->location ?: '',
1462 1528 'duration' => $entry->duration ?: '',
1463 1529 'cost' => !empty($entry->cost) ? (float) $entry->cost : null,
1464 1530 'cost_per_person' => !empty($entry->cost_per_person) ? true : false,
1465 1531 'included' => !empty($entry->included_items) ? json_decode($entry->included_items, true) : [],
1532 + 'excluded' => !empty($entry->excluded_items) ? json_decode($entry->excluded_items, true) : [],
1466 1533 'gallery' => !empty($entry->gallery) ? $this->decodeGallery($entry->gallery) : [],
1467 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 ?? ''),
1468 1541 ];
1469 1542 }
1470 1543
1471 1544 $itinerary[] = [
1472 1545 'day' => (int) $day->day_number,
1546 + /* translators: %d: itinerary day number. */
1473 1547 'day_title' => $day->title ?: sprintf(__('Day %d', 'yatra'), $day->day_number),
1474 1548 'day_description' => $day->description ?: '',
1475 1549 'entries' => $formatted_entries,
1476 1550 ];
@@ -1665,26 +1739,16 @@
1665 1739 yatra_get_template('partials/single-trip/content-testimonials', ['trip' => $trip, 'tab' => $tab]);
1666 1740 break;
1667 1741
1668 1742 case 'custom':
1669 - // Always show custom tab if enabled, even if content is empty
1670 - echo '<section class="yatra-trip-section" id="' . esc_attr($tab->id) . '">';
1671 - echo '<h2 class="yatra-trip-section-title">';
1672 - echo yatra_svg_icon('book', 'yatra-trip-section-title-icon');
1673 - echo esc_html($tab->label);
1674 - echo '</h2>';
1675 - echo '<div class="yatra-custom-content">';
1676 -
1677 - // Display custom content if it exists, otherwise show empty message
1678 - $custom_content = $tab->custom_content ?? '';
1679 - if (!empty($custom_content)) {
1680 - echo wp_kses_post($custom_content);
1681 - } else {
1682 - echo '<p class="text-gray-500 text-center py-8">' . esc_html__('No custom content available for this section.', 'yatra') . '</p>';
1683 - }
1684 -
1685 - echo '</div>';
1686 - 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 + ]);
1687 1751 break;
1688 1752 }
1689 1753 }
1690 1754
@@ -1775,12 +1839,15 @@
1775 1839 $is_per_group = ($pricing_mode === 'per_group');
1776 1840 $pricing_label = '';
1777 1841 if ($is_per_group) {
1778 1842 if (!empty($price_type->min_pax) && !empty($price_type->max_pax)) {
1779 - $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);
1780 1845 } elseif (!empty($price_type->max_pax)) {
1846 + /* translators: %d: maximum pax for the group price. */
1781 1847 $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $price_type->max_pax);
1782 1848 } elseif (!empty($price_type->min_pax)) {
1849 + /* translators: %d: minimum pax for the group price. */
1783 1850 $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $price_type->min_pax);
1784 1851 } else {
1785 1852 $pricing_label = __('per group', 'yatra');
1786 1853 }
@@ -1785,15 +1852,24 @@
1785 1852 $pricing_label = __('per group', 'yatra');
1786 1853 }
1787 1854 }
1788 1855
1789 - $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;
1790 1859 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1791 1860 $trip_id = is_object($trip) && method_exists($trip, 'getId') ? $trip->getId() : ($trip->id ?? 0);
1792 - $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, [
1793 1867 'departure_date' => null,
1794 1868 'spots_remaining' => null,
1795 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,
1796 1872 ]);
1797 1873 }
1798 1874
1799 1875 $age_info = '';
@@ -1800,12 +1876,15 @@
1800 1876 $age_min = $price_type->age_min ?? null;
1801 1877 $age_max = $price_type->age_max ?? null;
1802 1878 if ($age_min !== null || $age_max !== null) {
1803 1879 if ($age_min !== null && $age_max !== null) {
1804 - $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);
1805 1882 } elseif ($age_min !== null) {
1883 + /* translators: %d: minimum age. */
1806 1884 $age_info = sprintf(__('(Age %d+)', 'yatra'), $age_min);
1807 1885 } else {
1886 + /* translators: %d: maximum age. */
1808 1887 $age_info = sprintf(__('(Up to age %d)', 'yatra'), $age_max);
1809 1888 }
1810 1889 }
1811 1890
@@ -1818,8 +1897,16 @@
1818 1897
1819 1898 $input_id = 'traveler_' . $price_type->category_id;
1820 1899 $max_travelers = is_object($trip) && method_exists($trip, 'getMaxTravelers') ? $trip->getMaxTravelers() : ($trip->max_travelers ?? 20);
1821 1900 $pt_max_qty = (int) ($price_type->max_quantity ?: $max_travelers);
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 + }
1822 1909 $pt_value = ($index === $default_index) ? 1 : 0;
1823 1910
1824 1911 $traveler_rows[] = [
1825 1912 'label' => $price_type->category_label ?: __('Traveler', 'yatra'),
@@ -1828,17 +1915,21 @@
1828 1915 'row_attrs' => [
1829 1916 'data-category-id' => $price_type->category_id,
1830 1917 'data-price' => $price_type->effective_price,
1831 1918 'data-pricing-mode' => $pricing_mode,
1919 + 'data-group-overflow' => $price_type->group_overflow ?? 'block',
1920 + 'data-max-pax' => $price_type->max_pax ?? '',
1832 1921 ],
1833 1922 'minus_disabled' => ($index !== $default_index),
1834 1923 'plus_disabled' => false,
1835 1924 'minus_attrs' => [
1836 1925 'data-target' => $input_id,
1926 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
1837 1927 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $price_type->category_label),
1838 1928 ],
1839 1929 'plus_attrs' => [
1840 1930 'data-target' => $input_id,
1931 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
1841 1932 'aria-label' => sprintf(__('Increase %s', 'yatra'), $price_type->category_label),
1842 1933 ],
1843 1934 'input_attrs' => [
1844 1935 'id' => $input_id,
@@ -1849,8 +1940,10 @@
1849 1940 'data-category' => $price_type->category_id,
1850 1941 'data-category-label' => $price_type->category_label,
1851 1942 'data-price' => $price_type->effective_price,
1852 1943 'data-pricing-mode' => $pricing_mode,
1944 + 'data-group-overflow' => $price_type->group_overflow ?? 'block',
1945 + 'data-max-pax' => $price_type->max_pax ?? '',
1853 1946 ],
1854 1947 ];
1855 1948 }
1856 1949
@@ -1983,9 +2076,10 @@
1983 2076 foreach ($normalized_price_types as $pt_index => $pt) {
1984 2077 $pt_min = isset($pt->age_min) ? (int) $pt->age_min : 0;
1985 2078 $pt_max = isset($pt->age_max) ? (int) $pt->age_max : 99;
1986 2079 $pt_label = $pt->category_label ?? $pt->label ?? __('Traveler', 'yatra');
1987 - $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) : '';
1988 2082
1989 2083 // Use initial traveler count if provided, otherwise use default
1990 2084 $pt_category_id = $pt->category_id ?? $pt_index;
1991 2085 $pt_default = isset($initial_travelers[$pt_category_id])
@@ -2004,13 +2098,20 @@
2004 2098 }
2005 2099
2006 2100 // Apply dynamic pricing to traveler category prices
2007 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 + }
2008 2107 $pt_price = apply_filters('yatra_availability_price', $pt_price, $trip_id, [
2009 2108 'departure_date' => $card['date'] ?? null,
2010 2109 'spots_remaining' => $card['spots_remaining'] ?? null,
2011 2110 'availability_id' => $item_id,
2012 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,
2013 2114 ]);
2014 2115 }
2015 2116
2016 2117 $pt_category_id = $pt->category_id ?? $pt_index;
@@ -2017,17 +2118,26 @@
2017 2118 $pt_min_qty = 0;
2018 2119 $pt_max_qty = (int) min($seats_available, $max_travelers);
2019 2120 $pt_pricing_mode = $pt->pricing_mode ?? 'per_person';
2020 2121 $pt_is_per_group = ($pt_pricing_mode === 'per_group');
2021 -
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 +
2022 2129 // Build pricing label
2023 2130 $pricing_label = '';
2024 2131 if ($pt_is_per_group) {
2025 2132 if (!empty($pt->min_pax) && !empty($pt->max_pax)) {
2026 - $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);
2027 2135 } elseif (!empty($pt->max_pax)) {
2136 + /* translators: %d: maximum pax for the group price. */
2028 2137 $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $pt->max_pax);
2029 2138 } elseif (!empty($pt->min_pax)) {
2139 + /* translators: %d: minimum pax for the group price. */
2030 2140 $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $pt->min_pax);
2031 2141 } else {
2032 2142 $pricing_label = __('per group', 'yatra');
2033 2143 }
@@ -2047,17 +2157,21 @@
2047 2157 'row_attrs' => [
2048 2158 'data-category-id' => $pt_category_id,
2049 2159 'data-price' => $pt_price,
2050 2160 'data-pricing-mode' => $pt_pricing_mode,
2161 + 'data-group-overflow' => $pt->group_overflow ?? 'block',
2162 + 'data-max-pax' => $pt->max_pax ?? '',
2051 2163 ],
2052 2164 'minus_disabled' => ($pt_default <= 0),
2053 2165 'plus_disabled' => false,
2054 2166 'minus_attrs' => [
2055 2167 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id,
2168 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
2056 2169 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $pt_label),
2057 2170 ],
2058 2171 'plus_attrs' => [
2059 2172 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id,
2173 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
2060 2174 'aria-label' => sprintf(__('Increase %s', 'yatra'), $pt_label),
2061 2175 ],
2062 2176 'input_attrs' => [
2063 2177 'data-item' => $item_id,
@@ -2063,8 +2177,10 @@
2063 2177 'data-item' => $item_id,
2064 2178 'data-category' => $pt_category_id,
2065 2179 'data-price' => $pt_price,
2066 2180 'data-pricing-mode' => $pt_pricing_mode,
2181 + 'data-group-overflow' => $pt->group_overflow ?? 'block',
2182 + 'data-max-pax' => $pt->max_pax ?? '',
2067 2183 'value' => $pt_default,
2068 2184 'min' => $pt_min_qty,
2069 2185 'max' => $pt_max_qty,
2070 2186 ],