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 +141 -39 3.0.3 → 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
@@ -341,20 +348,42 @@
341 348 return (float) $pricing['effective_price_min'];
342 349 }
343 350
344 351 /**
345 - * Decode JSON safely
352 + * Decode a trip JSON / serialized field safely.
346 353 *
347 - * @param string|null $json JSON string
348 - * @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).
349 369 */
350 - private function decodeJson(?string $json): array
370 + private function decodeJson(?string $value): array
351 371 {
352 - if (empty($json)) {
372 + if (empty($value) || !is_string($value)) {
353 373 return [];
354 374 }
355 375
356 - $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);
357 386 return is_array($decoded) ? $decoded : [];
358 387 }
359 388
360 389 /**
@@ -367,13 +396,14 @@
367 396 {
368 397 // Use centralized AvailabilityResolutionService
369 398 $resolutionService = new \Yatra\Services\AvailabilityResolutionService();
370 399
371 - // 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).
372 402 $fromDate = date('Y-m-d');
373 - $toDate = date('Y-m-d', strtotime('+12 months'));
403 + $toDate = yatra_get_availability_horizon_date($fromDate);
374 404
375 - $availability = $resolutionService->getAllAvailabilityDates($trip_id, $fromDate, $toDate);
405 + $availability = $resolutionService->getAllAvailabilityDates($trip_id, $fromDate, $toDate, \Yatra\Services\SettingsService::isEnabled('show_sold_out'));
376 406
377 407 // Add calculated fields
378 408 foreach ($availability as $avail) {
379 409 // Calculate if limited availability
@@ -437,8 +467,9 @@
437 467 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null,
438 468 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null,
439 469 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null,
440 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',
441 472 'max_quantity' => isset($meta['max_quantity']) ? (int) $meta['max_quantity'] : null,
442 473 'description' => $meta['description'] ?? '',
443 474 ];
444 475 }
@@ -468,8 +499,9 @@
468 499 'age_min' => $cat ? $cat->age_min : null,
469 500 'age_max' => $cat ? $cat->age_max : null,
470 501 'min_pax' => $cat ? $cat->min_pax : null,
471 502 'max_pax' => $cat ? $cat->max_pax : null,
503 + 'group_overflow' => $cat ? $cat->group_overflow : ($pt['group_overflow'] ?? 'block'),
472 504 'max_quantity' => $cat ? $cat->max_quantity : null,
473 505 'description' => $cat ? $cat->description : ($pt['description'] ?? ''),
474 506 ];
475 507 }
@@ -1063,9 +1095,13 @@
1063 1095 $patterns = [
1064 1096 '/youtube\.com\/watch\?v=([^&]+)/',
1065 1097 '/youtube\.com\/embed\/([^?]+)/',
1066 1098 '/youtu\.be\/([^?]+)/',
1067 - '/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\/([^?&\/]+)/'
1068 1104 ];
1069 1105
1070 1106 foreach ($patterns as $pattern) {
1071 1107 if (preg_match($pattern, $url, $matches)) {
@@ -1197,13 +1233,19 @@
1197 1233 */
1198 1234 private function getSimilarTrips(object $trip): array
1199 1235 {
1200 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 + : '';
1201 1243
1202 1244 // Get similar trips based on category or difficulty
1203 1245 $similar = $this->wpdb->get_results(
1204 1246 $this->wpdb->prepare(
1205 - "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},
1206 1248 original_price, sale_price, difficulty_level,
1207 1249 short_description
1208 1250 FROM {$this->table_trips} t
1209 1251 WHERE t.id != %d
@@ -1234,9 +1276,9 @@
1234 1276 // Fallback: Get any published trips if no similar found
1235 1277 if (empty($similar)) {
1236 1278 $similar = $this->wpdb->get_results(
1237 1279 $this->wpdb->prepare(
1238 - "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},
1239 1281 original_price, sale_price, difficulty_level,
1240 1282 short_description
1241 1283 FROM {$this->table_trips}
1242 1284 WHERE id != %d
@@ -1436,9 +1478,10 @@
1436 1478 $this->wpdb->prepare(
1437 1479 "SELECT e.*,
1438 1480 i.name as item_name,
1439 1481 it.name as item_type_name,
1440 - it.icon as item_type_icon
1482 + it.icon as item_type_icon,
1483 + it.color as item_type_color
1441 1484 FROM {$table_entries} e
1442 1485 LEFT JOIN {$table_classifications} i ON e.item_id = i.id AND i.type = 'item'
1443 1486 LEFT JOIN {$table_classifications} it ON e.item_type_id = it.id AND it.type = 'item_type'
1444 1487 WHERE e.day_id = %d
@@ -1448,27 +1491,60 @@
1448 1491 );
1449 1492
1450 1493 $formatted_entries = [];
1451 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 +
1452 1513 $formatted_entries[] = [
1453 1514 'title' => $entry->title ?: $entry->item_name,
1454 1515 'description' => $entry->description ?: '',
1455 1516 'item_type' => $entry->item_type_name ?: 'Activity',
1456 - 'icon' => $entry->item_type_icon ?: 'hiking',
1517 + 'icon_picker' => $iconPicker,
1518 + 'item_type_color' => !empty($entry->item_type_color) ? (string) $entry->item_type_color : '',
1457 1519 'start_time' => $entry->start_time ?: '',
1458 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',
1459 1527 'location' => $entry->location ?: '',
1460 1528 'duration' => $entry->duration ?: '',
1461 1529 'cost' => !empty($entry->cost) ? (float) $entry->cost : null,
1462 1530 'cost_per_person' => !empty($entry->cost_per_person) ? true : false,
1463 1531 'included' => !empty($entry->included_items) ? json_decode($entry->included_items, true) : [],
1532 + 'excluded' => !empty($entry->excluded_items) ? json_decode($entry->excluded_items, true) : [],
1464 1533 'gallery' => !empty($entry->gallery) ? $this->decodeGallery($entry->gallery) : [],
1465 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 ?? ''),
1466 1541 ];
1467 1542 }
1468 1543
1469 1544 $itinerary[] = [
1470 1545 'day' => (int) $day->day_number,
1546 + /* translators: %d: itinerary day number. */
1471 1547 'day_title' => $day->title ?: sprintf(__('Day %d', 'yatra'), $day->day_number),
1472 1548 'day_description' => $day->description ?: '',
1473 1549 'entries' => $formatted_entries,
1474 1550 ];
@@ -1663,26 +1739,16 @@
1663 1739 yatra_get_template('partials/single-trip/content-testimonials', ['trip' => $trip, 'tab' => $tab]);
1664 1740 break;
1665 1741
1666 1742 case 'custom':
1667 - // Always show custom tab if enabled, even if content is empty
1668 - echo '<section class="yatra-trip-section" id="' . esc_attr($tab->id) . '">';
1669 - echo '<h2 class="yatra-trip-section-title">';
1670 - echo yatra_svg_icon('book', 'yatra-trip-section-title-icon');
1671 - echo esc_html($tab->label);
1672 - echo '</h2>';
1673 - echo '<div class="yatra-custom-content">';
1674 -
1675 - // Display custom content if it exists, otherwise show empty message
1676 - $custom_content = $tab->custom_content ?? '';
1677 - if (!empty($custom_content)) {
1678 - echo wp_kses_post($custom_content);
1679 - } else {
1680 - echo '<p class="text-gray-500 text-center py-8">' . esc_html__('No custom content available for this section.', 'yatra') . '</p>';
1681 - }
1682 -
1683 - echo '</div>';
1684 - 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 + ]);
1685 1751 break;
1686 1752 }
1687 1753 }
1688 1754
@@ -1773,12 +1839,15 @@
1773 1839 $is_per_group = ($pricing_mode === 'per_group');
1774 1840 $pricing_label = '';
1775 1841 if ($is_per_group) {
1776 1842 if (!empty($price_type->min_pax) && !empty($price_type->max_pax)) {
1777 - $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);
1778 1845 } elseif (!empty($price_type->max_pax)) {
1846 + /* translators: %d: maximum pax for the group price. */
1779 1847 $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $price_type->max_pax);
1780 1848 } elseif (!empty($price_type->min_pax)) {
1849 + /* translators: %d: minimum pax for the group price. */
1781 1850 $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $price_type->min_pax);
1782 1851 } else {
1783 1852 $pricing_label = __('per group', 'yatra');
1784 1853 }
@@ -1807,12 +1876,15 @@
1807 1876 $age_min = $price_type->age_min ?? null;
1808 1877 $age_max = $price_type->age_max ?? null;
1809 1878 if ($age_min !== null || $age_max !== null) {
1810 1879 if ($age_min !== null && $age_max !== null) {
1811 - $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);
1812 1882 } elseif ($age_min !== null) {
1883 + /* translators: %d: minimum age. */
1813 1884 $age_info = sprintf(__('(Age %d+)', 'yatra'), $age_min);
1814 1885 } else {
1886 + /* translators: %d: maximum age. */
1815 1887 $age_info = sprintf(__('(Up to age %d)', 'yatra'), $age_max);
1816 1888 }
1817 1889 }
1818 1890
@@ -1825,8 +1897,16 @@
1825 1897
1826 1898 $input_id = 'traveler_' . $price_type->category_id;
1827 1899 $max_travelers = is_object($trip) && method_exists($trip, 'getMaxTravelers') ? $trip->getMaxTravelers() : ($trip->max_travelers ?? 20);
1828 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 + }
1829 1909 $pt_value = ($index === $default_index) ? 1 : 0;
1830 1910
1831 1911 $traveler_rows[] = [
1832 1912 'label' => $price_type->category_label ?: __('Traveler', 'yatra'),
@@ -1835,17 +1915,21 @@
1835 1915 'row_attrs' => [
1836 1916 'data-category-id' => $price_type->category_id,
1837 1917 'data-price' => $price_type->effective_price,
1838 1918 'data-pricing-mode' => $pricing_mode,
1919 + 'data-group-overflow' => $price_type->group_overflow ?? 'block',
1920 + 'data-max-pax' => $price_type->max_pax ?? '',
1839 1921 ],
1840 1922 'minus_disabled' => ($index !== $default_index),
1841 1923 'plus_disabled' => false,
1842 1924 'minus_attrs' => [
1843 1925 'data-target' => $input_id,
1926 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
1844 1927 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $price_type->category_label),
1845 1928 ],
1846 1929 'plus_attrs' => [
1847 1930 'data-target' => $input_id,
1931 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
1848 1932 'aria-label' => sprintf(__('Increase %s', 'yatra'), $price_type->category_label),
1849 1933 ],
1850 1934 'input_attrs' => [
1851 1935 'id' => $input_id,
@@ -1856,8 +1940,10 @@
1856 1940 'data-category' => $price_type->category_id,
1857 1941 'data-category-label' => $price_type->category_label,
1858 1942 'data-price' => $price_type->effective_price,
1859 1943 'data-pricing-mode' => $pricing_mode,
1944 + 'data-group-overflow' => $price_type->group_overflow ?? 'block',
1945 + 'data-max-pax' => $price_type->max_pax ?? '',
1860 1946 ],
1861 1947 ];
1862 1948 }
1863 1949
@@ -1990,9 +2076,10 @@
1990 2076 foreach ($normalized_price_types as $pt_index => $pt) {
1991 2077 $pt_min = isset($pt->age_min) ? (int) $pt->age_min : 0;
1992 2078 $pt_max = isset($pt->age_max) ? (int) $pt->age_max : 99;
1993 2079 $pt_label = $pt->category_label ?? $pt->label ?? __('Traveler', 'yatra');
1994 - $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) : '';
1995 2082
1996 2083 // Use initial traveler count if provided, otherwise use default
1997 2084 $pt_category_id = $pt->category_id ?? $pt_index;
1998 2085 $pt_default = isset($initial_travelers[$pt_category_id])
@@ -2031,17 +2118,26 @@
2031 2118 $pt_min_qty = 0;
2032 2119 $pt_max_qty = (int) min($seats_available, $max_travelers);
2033 2120 $pt_pricing_mode = $pt->pricing_mode ?? 'per_person';
2034 2121 $pt_is_per_group = ($pt_pricing_mode === 'per_group');
2035 -
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 +
2036 2129 // Build pricing label
2037 2130 $pricing_label = '';
2038 2131 if ($pt_is_per_group) {
2039 2132 if (!empty($pt->min_pax) && !empty($pt->max_pax)) {
2040 - $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);
2041 2135 } elseif (!empty($pt->max_pax)) {
2136 + /* translators: %d: maximum pax for the group price. */
2042 2137 $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $pt->max_pax);
2043 2138 } elseif (!empty($pt->min_pax)) {
2139 + /* translators: %d: minimum pax for the group price. */
2044 2140 $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $pt->min_pax);
2045 2141 } else {
2046 2142 $pricing_label = __('per group', 'yatra');
2047 2143 }
@@ -2061,17 +2157,21 @@
2061 2157 'row_attrs' => [
2062 2158 'data-category-id' => $pt_category_id,
2063 2159 'data-price' => $pt_price,
2064 2160 'data-pricing-mode' => $pt_pricing_mode,
2161 + 'data-group-overflow' => $pt->group_overflow ?? 'block',
2162 + 'data-max-pax' => $pt->max_pax ?? '',
2065 2163 ],
2066 2164 'minus_disabled' => ($pt_default <= 0),
2067 2165 'plus_disabled' => false,
2068 2166 'minus_attrs' => [
2069 2167 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id,
2168 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
2070 2169 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $pt_label),
2071 2170 ],
2072 2171 'plus_attrs' => [
2073 2172 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id,
2173 + /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
2074 2174 'aria-label' => sprintf(__('Increase %s', 'yatra'), $pt_label),
2075 2175 ],
2076 2176 'input_attrs' => [
2077 2177 'data-item' => $item_id,
@@ -2077,8 +2177,10 @@
2077 2177 'data-item' => $item_id,
2078 2178 'data-category' => $pt_category_id,
2079 2179 'data-price' => $pt_price,
2080 2180 'data-pricing-mode' => $pt_pricing_mode,
2181 + 'data-group-overflow' => $pt->group_overflow ?? 'block',
2182 + 'data-max-pax' => $pt->max_pax ?? '',
2081 2183 'value' => $pt_default,
2082 2184 'min' => $pt_min_qty,
2083 2185 'max' => $pt_max_qty,
2084 2186 ],