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 -59 3.0.2.8 → 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 }
@@ -483,8 +499,9 @@
483 499 'age_min' => $cat ? $cat->age_min : null,
484 500 'age_max' => $cat ? $cat->age_max : null,
485 501 'min_pax' => $cat ? $cat->min_pax : null,
486 502 'max_pax' => $cat ? $cat->max_pax : null,
503 + 'group_overflow' => $cat ? $cat->group_overflow : ($pt['group_overflow'] ?? 'block'),
487 504 'max_quantity' => $cat ? $cat->max_quantity : null,
488 505 'description' => $cat ? $cat->description : ($pt['description'] ?? ''),
489 506 ];
490 507 }
@@ -1078,9 +1095,13 @@
1078 1095 $patterns = [
1079 1096 '/youtube\.com\/watch\?v=([^&]+)/',
1080 1097 '/youtube\.com\/embed\/([^?]+)/',
1081 1098 '/youtu\.be\/([^?]+)/',
1082 - '/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\/([^?&\/]+)/'
1083 1104 ];
1084 1105
1085 1106 foreach ($patterns as $pattern) {
1086 1107 if (preg_match($pattern, $url, $matches)) {
@@ -1212,13 +1233,19 @@
1212 1233 */
1213 1234 private function getSimilarTrips(object $trip): array
1214 1235 {
1215 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 + : '';
1216 1243
1217 1244 // Get similar trips based on category or difficulty
1218 1245 $similar = $this->wpdb->get_results(
1219 1246 $this->wpdb->prepare(
1220 - "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},
1221 1248 original_price, sale_price, difficulty_level,
1222 1249 short_description
1223 1250 FROM {$this->table_trips} t
1224 1251 WHERE t.id != %d
@@ -1249,9 +1276,9 @@
1249 1276 // Fallback: Get any published trips if no similar found
1250 1277 if (empty($similar)) {
1251 1278 $similar = $this->wpdb->get_results(
1252 1279 $this->wpdb->prepare(
1253 - "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},
1254 1281 original_price, sale_price, difficulty_level,
1255 1282 short_description
1256 1283 FROM {$this->table_trips}
1257 1284 WHERE id != %d
@@ -1451,9 +1478,10 @@
1451 1478 $this->wpdb->prepare(
1452 1479 "SELECT e.*,
1453 1480 i.name as item_name,
1454 1481 it.name as item_type_name,
1455 - it.icon as item_type_icon
1482 + it.icon as item_type_icon,
1483 + it.color as item_type_color
1456 1484 FROM {$table_entries} e
1457 1485 LEFT JOIN {$table_classifications} i ON e.item_id = i.id AND i.type = 'item'
1458 1486 LEFT JOIN {$table_classifications} it ON e.item_type_id = it.id AND it.type = 'item_type'
1459 1487 WHERE e.day_id = %d
@@ -1463,27 +1491,60 @@
1463 1491 );
1464 1492
1465 1493 $formatted_entries = [];
1466 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 +
1467 1513 $formatted_entries[] = [
1468 1514 'title' => $entry->title ?: $entry->item_name,
1469 1515 'description' => $entry->description ?: '',
1470 1516 'item_type' => $entry->item_type_name ?: 'Activity',
1471 - 'icon' => $entry->item_type_icon ?: 'hiking',
1517 + 'icon_picker' => $iconPicker,
1518 + 'item_type_color' => !empty($entry->item_type_color) ? (string) $entry->item_type_color : '',
1472 1519 'start_time' => $entry->start_time ?: '',
1473 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',
1474 1527 'location' => $entry->location ?: '',
1475 1528 'duration' => $entry->duration ?: '',
1476 1529 'cost' => !empty($entry->cost) ? (float) $entry->cost : null,
1477 1530 'cost_per_person' => !empty($entry->cost_per_person) ? true : false,
1478 1531 'included' => !empty($entry->included_items) ? json_decode($entry->included_items, true) : [],
1532 + 'excluded' => !empty($entry->excluded_items) ? json_decode($entry->excluded_items, true) : [],
1479 1533 'gallery' => !empty($entry->gallery) ? $this->decodeGallery($entry->gallery) : [],
1480 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 ?? ''),
1481 1541 ];
1482 1542 }
1483 1543
1484 1544 $itinerary[] = [
1485 1545 'day' => (int) $day->day_number,
1546 + /* translators: %d: itinerary day number. */
1486 1547 'day_title' => $day->title ?: sprintf(__('Day %d', 'yatra'), $day->day_number),
1487 1548 'day_description' => $day->description ?: '',
1488 1549 'entries' => $formatted_entries,
1489 1550 ];
@@ -1678,26 +1739,16 @@
1678 1739 yatra_get_template('partials/single-trip/content-testimonials', ['trip' => $trip, 'tab' => $tab]);
1679 1740 break;
1680 1741
1681 1742 case 'custom':
1682 - // Always show custom tab if enabled, even if content is empty
1683 - echo '<section class="yatra-trip-section" id="' . esc_attr($tab->id) . '">';
1684 - echo '<h2 class="yatra-trip-section-title">';
1685 - echo yatra_svg_icon('book', 'yatra-trip-section-title-icon');
1686 - echo esc_html($tab->label);
1687 - echo '</h2>';
1688 - echo '<div class="yatra-custom-content">';
1689 -
1690 - // Display custom content if it exists, otherwise show empty message
1691 - $custom_content = $tab->custom_content ?? '';
1692 - if (!empty($custom_content)) {
1693 - echo wp_kses_post($custom_content);
1694 - } else {
1695 - echo '<p class="text-gray-500 text-center py-8">' . esc_html__('No custom content available for this section.', 'yatra') . '</p>';
1696 - }
1697 -
1698 - echo '</div>';
1699 - 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 + ]);
1700 1751 break;
1701 1752 }
1702 1753 }
1703 1754
@@ -1788,12 +1839,15 @@
1788 1839 $is_per_group = ($pricing_mode === 'per_group');
1789 1840 $pricing_label = '';
1790 1841 if ($is_per_group) {
1791 1842 if (!empty($price_type->min_pax) && !empty($price_type->max_pax)) {
1792 - $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);
1793 1845 } elseif (!empty($price_type->max_pax)) {
1846 + /* translators: %d: maximum pax for the group price. */
1794 1847 $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $price_type->max_pax);
1795 1848 } elseif (!empty($price_type->min_pax)) {
1849 + /* translators: %d: minimum pax for the group price. */
1796 1850 $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $price_type->min_pax);
1797 1851 } else {
1798 1852 $pricing_label = __('per group', 'yatra');
1799 1853 }
@@ -1798,15 +1852,24 @@
1798 1852 $pricing_label = __('per group', 'yatra');
1799 1853 }
1800 1854 }
1801 1855
1802 - $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;
1803 1859 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1804 1860 $trip_id = is_object($trip) && method_exists($trip, 'getId') ? $trip->getId() : ($trip->id ?? 0);
1805 - $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, [
1806 1867 'departure_date' => null,
1807 1868 'spots_remaining' => null,
1808 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,
1809 1872 ]);
1810 1873 }
1811 1874
1812 1875 $age_info = '';
@@ -1813,12 +1876,15 @@
1813 1876 $age_min = $price_type->age_min ?? null;
1814 1877 $age_max = $price_type->age_max ?? null;
1815 1878 if ($age_min !== null || $age_max !== null) {
1816 1879 if ($age_min !== null && $age_max !== null) {
1817 - $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);
1818 1882 } elseif ($age_min !== null) {
1883 + /* translators: %d: minimum age. */
1819 1884 $age_info = sprintf(__('(Age %d+)', 'yatra'), $age_min);
1820 1885 } else {
1886 + /* translators: %d: maximum age. */
1821 1887 $age_info = sprintf(__('(Up to age %d)', 'yatra'), $age_max);
1822 1888 }
1823 1889 }
1824 1890
@@ -1831,8 +1897,16 @@
1831 1897
1832 1898 $input_id = 'traveler_' . $price_type->category_id;
1833 1899 $max_travelers = is_object($trip) && method_exists($trip, 'getMaxTravelers') ? $trip->getMaxTravelers() : ($trip->max_travelers ?? 20);
1834 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 + }
1835 1909 $pt_value = ($index === $default_index) ? 1 : 0;
1836 1910
1837 1911 $traveler_rows[] = [
1838 1912 'label' => $price_type->category_label ?: __('Traveler', 'yatra'),
@@ -1841,17 +1915,21 @@
1841 1915 'row_attrs' => [
1842 1916 'data-category-id' => $price_type->category_id,
1843 1917 'data-price' => $price_type->effective_price,
1844 1918 'data-pricing-mode' => $pricing_mode,
1919 + 'data-group-overflow' => $price_type->group_overflow ?? 'block',
1920 + 'data-max-pax' => $price_type->max_pax ?? '',
1845 1921 ],
1846 1922 'minus_disabled' => ($index !== $default_index),
1847 1923 'plus_disabled' => false,
1848 1924 'minus_attrs' => [
1849 1925 'data-target' => $input_id,
1926 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
1850 1927 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $price_type->category_label),
1851 1928 ],
1852 1929 'plus_attrs' => [
1853 1930 'data-target' => $input_id,
1931 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
1854 1932 'aria-label' => sprintf(__('Increase %s', 'yatra'), $price_type->category_label),
1855 1933 ],
1856 1934 'input_attrs' => [
1857 1935 'id' => $input_id,
@@ -1862,8 +1940,10 @@
1862 1940 'data-category' => $price_type->category_id,
1863 1941 'data-category-label' => $price_type->category_label,
1864 1942 'data-price' => $price_type->effective_price,
1865 1943 'data-pricing-mode' => $pricing_mode,
1944 + 'data-group-overflow' => $price_type->group_overflow ?? 'block',
1945 + 'data-max-pax' => $price_type->max_pax ?? '',
1866 1946 ],
1867 1947 ];
1868 1948 }
1869 1949
@@ -1996,9 +2076,10 @@
1996 2076 foreach ($normalized_price_types as $pt_index => $pt) {
1997 2077 $pt_min = isset($pt->age_min) ? (int) $pt->age_min : 0;
1998 2078 $pt_max = isset($pt->age_max) ? (int) $pt->age_max : 99;
1999 2079 $pt_label = $pt->category_label ?? $pt->label ?? __('Traveler', 'yatra');
2000 - $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) : '';
2001 2082
2002 2083 // Use initial traveler count if provided, otherwise use default
2003 2084 $pt_category_id = $pt->category_id ?? $pt_index;
2004 2085 $pt_default = isset($initial_travelers[$pt_category_id])
@@ -2017,13 +2098,20 @@
2017 2098 }
2018 2099
2019 2100 // Apply dynamic pricing to traveler category prices
2020 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 + }
2021 2107 $pt_price = apply_filters('yatra_availability_price', $pt_price, $trip_id, [
2022 2108 'departure_date' => $card['date'] ?? null,
2023 2109 'spots_remaining' => $card['spots_remaining'] ?? null,
2024 2110 'availability_id' => $item_id,
2025 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,
2026 2114 ]);
2027 2115 }
2028 2116
2029 2117 $pt_category_id = $pt->category_id ?? $pt_index;
@@ -2030,17 +2118,26 @@
2030 2118 $pt_min_qty = 0;
2031 2119 $pt_max_qty = (int) min($seats_available, $max_travelers);
2032 2120 $pt_pricing_mode = $pt->pricing_mode ?? 'per_person';
2033 2121 $pt_is_per_group = ($pt_pricing_mode === 'per_group');
2034 -
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 +
2035 2129 // Build pricing label
2036 2130 $pricing_label = '';
2037 2131 if ($pt_is_per_group) {
2038 2132 if (!empty($pt->min_pax) && !empty($pt->max_pax)) {
2039 - $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);
2040 2135 } elseif (!empty($pt->max_pax)) {
2136 + /* translators: %d: maximum pax for the group price. */
2041 2137 $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $pt->max_pax);
2042 2138 } elseif (!empty($pt->min_pax)) {
2139 + /* translators: %d: minimum pax for the group price. */
2043 2140 $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $pt->min_pax);
2044 2141 } else {
2045 2142 $pricing_label = __('per group', 'yatra');
2046 2143 }
@@ -2060,17 +2157,21 @@
2060 2157 'row_attrs' => [
2061 2158 'data-category-id' => $pt_category_id,
2062 2159 'data-price' => $pt_price,
2063 2160 'data-pricing-mode' => $pt_pricing_mode,
2161 + 'data-group-overflow' => $pt->group_overflow ?? 'block',
2162 + 'data-max-pax' => $pt->max_pax ?? '',
2064 2163 ],
2065 2164 'minus_disabled' => ($pt_default <= 0),
2066 2165 'plus_disabled' => false,
2067 2166 'minus_attrs' => [
2068 2167 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id,
2168 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
2069 2169 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $pt_label),
2070 2170 ],
2071 2171 'plus_attrs' => [
2072 2172 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id,
2173 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
2073 2174 'aria-label' => sprintf(__('Increase %s', 'yatra'), $pt_label),
2074 2175 ],
2075 2176 'input_attrs' => [
2076 2177 'data-item' => $item_id,
@@ -2076,8 +2177,10 @@
2076 2177 'data-item' => $item_id,
2077 2178 'data-category' => $pt_category_id,
2078 2179 'data-price' => $pt_price,
2079 2180 'data-pricing-mode' => $pt_pricing_mode,
2181 + 'data-group-overflow' => $pt->group_overflow ?? 'block',
2182 + 'data-max-pax' => $pt->max_pax ?? '',
2080 2183 'value' => $pt_default,
2081 2184 'min' => $pt_min_qty,
2082 2185 'max' => $pt_max_qty,
2083 2186 ],