| @@ -233,8 +233,17 @@ | ||
| 233 | 233 | public array $tags = []; |
| 234 | 234 | public array $included_items = []; |
| 235 | 235 | public array $excluded_items = []; |
| 236 | 236 | public array $gallery_images = []; |
| 237 | + // Media arrays computed in SingleTripController::prepareTrip(). These MUST be | |
| 238 | + // declared so Trip::fromStdClass() (which only copies declared properties via | |
| 239 | + // property_exists) carries them onto the model — otherwise the single-trip | |
| 240 | + // hero media switcher reads empty arrays and the Video / 360° / Documents | |
| 241 | + // buttons never render even when video_url / virtual_tour_url are set. | |
| 242 | + public array $videos = []; | |
| 243 | + public array $youtube_videos = []; | |
| 244 | + public array $virtual_tours = []; | |
| 245 | + public array $documents = []; | |
| 237 | 246 | public array $price_types = []; |
| 238 | 247 | public array $itinerary_days = []; |
| 239 | 248 | public array $faqs = []; |
| 240 | 249 | public array $frontend_tabs = []; |
| @@ -654,8 +663,21 @@ | ||
| 654 | 663 | ]; |
| 655 | 664 | } |
| 656 | 665 | |
| 657 | 666 | /** |
| 667 | + * Returns true when the trip is set to enquiry-only (booking calendar disabled). | |
| 668 | + * Stored as custom_fields['disable_booking'] = true. Requires Pro to be active. | |
| 669 | + */ | |
| 670 | + public function isBookingDisabled(): bool | |
| 671 | + { | |
| 672 | + if (!apply_filters('yatra_is_pro_active', false)) { | |
| 673 | + return false; | |
| 674 | + } | |
| 675 | + | |
| 676 | + return !empty($this->custom_fields['disable_booking']); | |
| 677 | + } | |
| 678 | + | |
| 679 | + /** | |
| 658 | 680 | * Parse JSON field from database |
| 659 | 681 | */ |
| 660 | 682 | private static function parseJsonField(?string $value, array $default = []): array |
| 661 | 683 | { |
| @@ -869,11 +891,14 @@ | ||
| 869 | 891 | $current_price_raw = 0; |
| 870 | 892 | $original_price_raw = 0; |
| 871 | 893 | |
| 872 | 894 | if ($is_traveler_based) { |
| 873 | - // For traveler-based pricing, find minimum price from all categories | |
| 895 | + // For traveler-based pricing, prefer admin-selected default category if present; | |
| 896 | + // otherwise fall back to minimum across categories (legacy behavior). | |
| 897 | + $default_price = 0.0; | |
| 898 | + $default_original_price = 0.0; | |
| 874 | 899 | $min_price = PHP_FLOAT_MAX; |
| 875 | - $min_original_price = 0; | |
| 900 | + $min_original_price = 0.0; | |
| 876 | 901 | |
| 877 | 902 | // Check multiple possible data structures for price categories |
| 878 | 903 | $price_categories = []; |
| 879 | 904 | |
| @@ -892,12 +917,14 @@ | ||
| 892 | 917 | if (is_object($category)) { |
| 893 | 918 | $regular_price = (float) ($category->original_price ?? $category->price ?? 0); |
| 894 | 919 | // Priority: discounted_price > sale_price (legacy) > discount_price (legacy) |
| 895 | 920 | $discounted_price = (float) ($category->discounted_price ?? $category->sale_price ?? $category->discount_price ?? 0); |
| 921 | + $is_default = !empty($category->is_default); | |
| 896 | 922 | } elseif (is_array($category)) { |
| 897 | 923 | $regular_price = (float) ($category['original_price'] ?? $category['price'] ?? 0); |
| 898 | 924 | // Priority: discounted_price > sale_price (legacy) > discount_price (legacy) |
| 899 | 925 | $discounted_price = (float) ($category['discounted_price'] ?? $category['sale_price'] ?? $category['discount_price'] ?? 0); |
| 926 | + $is_default = !empty($category['is_default']); | |
| 900 | 927 | } else { |
| 901 | 928 | continue; |
| 902 | 929 | } |
| 903 | 930 | |
| @@ -902,8 +929,16 @@ | ||
| 902 | 929 | } |
| 903 | 930 | |
| 904 | 931 | // Use discounted price if available, otherwise regular price |
| 905 | 932 | $category_price = ($discounted_price > 0 && $discounted_price < $regular_price) ? $discounted_price : $regular_price; |
| 933 | + | |
| 934 | + // Capture default category price (first valid default only) | |
| 935 | + if ($is_default && $default_price <= 0 && $category_price > 0) { | |
| 936 | + $default_price = $category_price; | |
| 937 | + if ($discounted_price > 0 && $discounted_price < $regular_price) { | |
| 938 | + $default_original_price = $regular_price; | |
| 939 | + } | |
| 940 | + } | |
| 906 | 941 | |
| 907 | 942 | if ($category_price > 0 && $category_price < $min_price) { |
| 908 | 943 | $min_price = $category_price; |
| 909 | 944 | // Store original price from the same category for strikethrough |
| @@ -929,16 +964,25 @@ | ||
| 929 | 964 | $min_price = (float) $this->starting_price; |
| 930 | 965 | } |
| 931 | 966 | } |
| 932 | 967 | |
| 933 | - if ($min_price !== PHP_FLOAT_MAX && $min_price > 0) { | |
| 934 | - $current_price_raw = $min_price; | |
| 968 | + $chosen_price = $default_price > 0 ? $default_price : $min_price; | |
| 969 | + $chosen_original = $default_price > 0 ? $default_original_price : $min_original_price; | |
| 970 | + $chosen_has_discount = $default_price > 0 | |
| 971 | + ? ($default_original_price > 0 && $default_original_price > $default_price) | |
| 972 | + : $has_discount; | |
| 973 | + | |
| 974 | + if ($chosen_price !== PHP_FLOAT_MAX && $chosen_price > 0) { | |
| 975 | + $current_price_raw = $chosen_price; | |
| 935 | 976 | $current_price = yatra_format_price($current_price_raw); |
| 936 | 977 | $price_prefix = __('From ', 'yatra'); // Always show "From" for traveler-based |
| 937 | 978 | |
| 938 | - if ($has_discount && $min_original_price > 0) { | |
| 939 | - $original_price_raw = $min_original_price; | |
| 979 | + if ($chosen_has_discount && $chosen_original > 0) { | |
| 980 | + $original_price_raw = $chosen_original; | |
| 940 | 981 | $original_price = yatra_format_price($original_price_raw); |
| 982 | + $has_discount = true; | |
| 983 | + } else { | |
| 984 | + $has_discount = false; | |
| 941 | 985 | } |
| 942 | 986 | } |
| 943 | 987 | } else { |
| 944 | 988 | // Regular pricing - NO "From" text |
| @@ -982,80 +1026,127 @@ | ||
| 982 | 1026 | ]; |
| 983 | 1027 | } |
| 984 | 1028 | |
| 985 | 1029 | /** |
| 986 | - * Get difficulty information | |
| 1030 | + * Resolve difficulty label + icon for display (shared by single trip, similar trips, etc.). | |
| 1031 | + * | |
| 1032 | + * @param string|null $difficultyLevel Raw trip.difficulty_level (usually classification row id). | |
| 1033 | + * @param string|null $difficultyName Optional joined name from list queries. | |
| 1034 | + * @param mixed|null $difficultyIcon Optional serialized icon payload from list queries. | |
| 1035 | + * @return array{level: string, icon: string, icon_picker: array<string, mixed>|null, has_difficulty: bool} | |
| 987 | 1036 | */ |
| 988 | - public function getDifficulty(): array | |
| 989 | - { | |
| 1037 | + public static function resolveDifficultyDisplay( | |
| 1038 | + ?string $difficultyLevel, | |
| 1039 | + ?string $difficultyName = null, | |
| 1040 | + $difficultyIcon = null | |
| 1041 | + ): array { | |
| 990 | 1042 | $difficulty = ''; |
| 991 | 1043 | $difficulty_icon = ''; |
| 992 | - | |
| 993 | - // Always prioritize fetching from difficulty_levels table if we have a difficulty_level ID | |
| 994 | - // Check if difficulty_level is set and is a valid positive integer (not 0, null, or empty) | |
| 995 | - if (!empty($this->difficulty_level) && is_numeric($this->difficulty_level) && (int) $this->difficulty_level > 0) { | |
| 1044 | + /** @var array<string, mixed>|null $icon_picker */ | |
| 1045 | + $icon_picker = null; | |
| 1046 | + | |
| 1047 | + if (!empty($difficultyLevel) && is_numeric($difficultyLevel) && (int) $difficultyLevel > 0) { | |
| 996 | 1048 | global $wpdb; |
| 997 | - | |
| 1049 | + | |
| 998 | 1050 | $difficulty_data = $wpdb->get_row($wpdb->prepare( |
| 999 | - "SELECT * FROM " . ClassificationsTable::getTableName() . " WHERE id = %d AND type = %s", | |
| 1000 | - (int) $this->difficulty_level, | |
| 1051 | + 'SELECT * FROM ' . ClassificationsTable::getTableName() . ' WHERE id = %d AND type = %s', | |
| 1052 | + (int) $difficultyLevel, | |
| 1001 | 1053 | ClassificationTypes::DIFFICULTY |
| 1002 | 1054 | )); |
| 1003 | - | |
| 1055 | + | |
| 1004 | 1056 | if ($difficulty_data) { |
| 1005 | - $difficulty = $difficulty_data->name; | |
| 1057 | + $difficulty = (string) $difficulty_data->name; | |
| 1006 | 1058 | if (!empty($difficulty_data->icon)) { |
| 1007 | 1059 | $icon_data = maybe_unserialize($difficulty_data->icon); |
| 1008 | - if (is_array($icon_data) && isset($icon_data['type']) && $icon_data['type'] === 'icon' && !empty($icon_data['value'])) { | |
| 1009 | - $difficulty_icon = $icon_data['value']; | |
| 1060 | + if (is_array($icon_data) && isset($icon_data['type'])) { | |
| 1061 | + $icon_picker = $icon_data; | |
| 1062 | + if ($icon_data['type'] === 'icon' && !empty($icon_data['value'])) { | |
| 1063 | + $difficulty_icon = (string) $icon_data['value']; | |
| 1064 | + } | |
| 1010 | 1065 | } elseif (is_string($difficulty_data->icon)) { |
| 1011 | 1066 | $difficulty_icon = $difficulty_data->icon; |
| 1067 | + $icon_picker = [ | |
| 1068 | + 'type' => 'icon', | |
| 1069 | + 'value' => $difficulty_icon, | |
| 1070 | + 'provider' => 'yatra', | |
| 1071 | + ]; | |
| 1012 | 1072 | } |
| 1013 | 1073 | } |
| 1014 | 1074 | } |
| 1015 | 1075 | } |
| 1016 | - | |
| 1017 | - // Fallback: Use pre-populated difficulty_name from main query (AppServiceProvider) | |
| 1018 | - if (empty($difficulty) && !empty($this->difficulty_name)) { | |
| 1019 | - $difficulty = $this->difficulty_name; | |
| 1076 | + | |
| 1077 | + if ($difficulty === '' && !empty($difficultyName)) { | |
| 1078 | + $difficulty = $difficultyName; | |
| 1020 | 1079 | } |
| 1021 | - | |
| 1022 | - // Fallback: Use pre-populated difficulty_icon from main query (AppServiceProvider) | |
| 1023 | - if (empty($difficulty_icon) && !empty($this->difficulty_icon)) { | |
| 1024 | - $icon_data = maybe_unserialize($this->difficulty_icon); | |
| 1025 | - if (is_array($icon_data) && isset($icon_data['type']) && $icon_data['type'] === 'icon' && !empty($icon_data['value'])) { | |
| 1026 | - $difficulty_icon = $icon_data['value']; | |
| 1027 | - } elseif (is_string($this->difficulty_icon)) { | |
| 1028 | - $difficulty_icon = $this->difficulty_icon; | |
| 1080 | + | |
| 1081 | + // Legacy / non-id values stored in difficulty_level (e.g. slug text). | |
| 1082 | + if ($difficulty === '' && $difficultyLevel !== null && $difficultyLevel !== '' && !is_numeric($difficultyLevel)) { | |
| 1083 | + $difficulty = ucfirst($difficultyLevel); | |
| 1084 | + } | |
| 1085 | + | |
| 1086 | + if ($icon_picker === null && $difficultyIcon !== null && $difficultyIcon !== '') { | |
| 1087 | + $icon_data = is_string($difficultyIcon) ? maybe_unserialize($difficultyIcon) : $difficultyIcon; | |
| 1088 | + if (is_array($icon_data) && isset($icon_data['type'])) { | |
| 1089 | + $icon_picker = $icon_data; | |
| 1090 | + if ($icon_data['type'] === 'icon' && !empty($icon_data['value'])) { | |
| 1091 | + $difficulty_icon = (string) $icon_data['value']; | |
| 1092 | + } | |
| 1093 | + } elseif (is_string($difficultyIcon)) { | |
| 1094 | + $difficulty_icon = $difficultyIcon; | |
| 1095 | + $icon_picker = [ | |
| 1096 | + 'type' => 'icon', | |
| 1097 | + 'value' => $difficulty_icon, | |
| 1098 | + 'provider' => 'yatra', | |
| 1099 | + ]; | |
| 1029 | 1100 | } |
| 1030 | 1101 | } |
| 1031 | - | |
| 1032 | - // If we have difficulty but no icon, provide a default icon | |
| 1033 | - if (!empty($difficulty) && empty($difficulty_icon)) { | |
| 1102 | + | |
| 1103 | + if ($difficulty !== '' && $icon_picker === null && $difficulty_icon === '') { | |
| 1034 | 1104 | $difficulty_icon = 'mountain'; |
| 1105 | + $icon_picker = [ | |
| 1106 | + 'type' => 'icon', | |
| 1107 | + 'value' => 'mountain', | |
| 1108 | + 'provider' => 'yatra', | |
| 1109 | + ]; | |
| 1035 | 1110 | } |
| 1036 | - | |
| 1111 | + | |
| 1037 | 1112 | return [ |
| 1038 | 1113 | 'level' => $difficulty, |
| 1039 | 1114 | 'icon' => $difficulty_icon, |
| 1040 | - 'has_difficulty' => !empty($difficulty) | |
| 1115 | + 'icon_picker' => $icon_picker, | |
| 1116 | + 'has_difficulty' => $difficulty !== '', | |
| 1041 | 1117 | ]; |
| 1042 | 1118 | } |
| 1043 | 1119 | |
| 1044 | 1120 | /** |
| 1121 | + * Get difficulty information | |
| 1122 | + */ | |
| 1123 | + public function getDifficulty(): array | |
| 1124 | + { | |
| 1125 | + return self::resolveDifficultyDisplay( | |
| 1126 | + $this->difficulty_level, | |
| 1127 | + $this->difficulty_name, | |
| 1128 | + $this->difficulty_icon | |
| 1129 | + ); | |
| 1130 | + } | |
| 1131 | + | |
| 1132 | + /** | |
| 1045 | 1133 | * Get trip duration information |
| 1046 | 1134 | */ |
| 1047 | 1135 | public function getDuration(): array |
| 1048 | 1136 | { |
| 1049 | 1137 | $duration = ''; |
| 1050 | - if (!empty($this->duration_days)) { | |
| 1138 | + if ($this->isHoursBased()) { | |
| 1139 | + $duration = yatra_format_duration(0, null, $this->getDurationHours()); | |
| 1140 | + } elseif (!empty($this->duration_days)) { | |
| 1051 | 1141 | $duration = yatra_format_duration($this->duration_days, $this->duration_nights ?? null); |
| 1052 | 1142 | } |
| 1053 | - | |
| 1143 | + | |
| 1054 | 1144 | return [ |
| 1055 | 1145 | 'formatted' => $duration, |
| 1056 | 1146 | 'days' => $this->duration_days ?? 0, |
| 1057 | 1147 | 'nights' => $this->duration_nights ?? 0, |
| 1148 | + 'hours' => $this->getDurationHours(), | |
| 1058 | 1149 | 'has_duration' => !empty($duration) |
| 1059 | 1150 | ]; |
| 1060 | 1151 | } |
| 1061 | 1152 | |
| @@ -1183,9 +1274,10 @@ | ||
| 1183 | 1274 | */ |
| 1184 | 1275 | public function getEffectivePrice(): float |
| 1185 | 1276 | { |
| 1186 | 1277 | if ($this->pricing_type === 'traveler_based') { |
| 1187 | - return $this->min_category_original_price ?? 0.0; | |
| 1278 | + // Use centralized logic (default category if set, else minimum). | |
| 1279 | + return (float) \Yatra\Services\TripPricingService::getEffectivePrice((object) $this); | |
| 1188 | 1280 | } |
| 1189 | 1281 | |
| 1190 | 1282 | // Use discounted price if available and valid |
| 1191 | 1283 | if (!empty($this->discounted_price) && $this->discounted_price < $this->original_price) { |
| @@ -1474,10 +1566,33 @@ | ||
| 1474 | 1566 | return $this->duration_nights ?? 0; |
| 1475 | 1567 | } |
| 1476 | 1568 | |
| 1477 | 1569 | /** |
| 1478 | - * Get difficulty level | |
| 1570 | + * Duration in hours for hour-based (single-day) tours. 0 when the tour is | |
| 1571 | + * day-based (the default) — see {@see self::isHoursBased()}. | |
| 1479 | 1572 | */ |
| 1573 | + public function getDurationHours(): int | |
| 1574 | + { | |
| 1575 | + return (int) ($this->duration_hours ?? 0); | |
| 1576 | + } | |
| 1577 | + | |
| 1578 | + /** | |
| 1579 | + * Whether this tour's duration is expressed in hours rather than days. | |
| 1580 | + * | |
| 1581 | + * True only when a positive `duration_hours` is set — so every existing | |
| 1582 | + * (day-based) trip returns false and keeps its current behaviour. Used by | |
| 1583 | + * the front-end duration display and the Google Calendar module to build a | |
| 1584 | + * same-day timed event instead of a multi-day span. | |
| 1585 | + */ | |
| 1586 | + public function isHoursBased(): bool | |
| 1587 | + { | |
| 1588 | + return $this->getDurationHours() > 0; | |
| 1589 | + } | |
| 1590 | + | |
| 1591 | + /** | |
| 1592 | + * Raw difficulty classification ID on the trip row (not a human-readable label). | |
| 1593 | + * For display, use {@see self::getDifficulty()} which resolves the name from the classifications table. | |
| 1594 | + */ | |
| 1480 | 1595 | public function getDifficultyLevel(): string |
| 1481 | 1596 | { |
| 1482 | 1597 | return $this->difficulty_level ?? ''; |
| 1483 | 1598 | } |
| @@ -1502,8 +1617,12 @@ | ||
| 1502 | 1617 | * Get original price |
| 1503 | 1618 | */ |
| 1504 | 1619 | public function getOriginalPrice(): float |
| 1505 | 1620 | { |
| 1621 | + if ($this->pricing_type === 'traveler_based') { | |
| 1622 | + $pricing = \Yatra\Services\TripPricingService::resolveDisplayPricing((object) $this); | |
| 1623 | + return (float) ($pricing['min_category_original_price'] ?? 0); | |
| 1624 | + } | |
| 1506 | 1625 | return (float) ($this->original_price ?? 0); |
| 1507 | 1626 | } |
| 1508 | 1627 | |
| 1509 | 1628 | /** |
| @@ -1510,8 +1629,12 @@ | ||
| 1510 | 1629 | * Get sale price |
| 1511 | 1630 | */ |
| 1512 | 1631 | public function getSalePrice(): float |
| 1513 | 1632 | { |
| 1633 | + if ($this->pricing_type === 'traveler_based') { | |
| 1634 | + $pricing = \Yatra\Services\TripPricingService::resolveDisplayPricing((object) $this); | |
| 1635 | + return (float) ($pricing['effective_price_min'] ?? 0); | |
| 1636 | + } | |
| 1514 | 1637 | return (float) ($this->sale_price ?? 0); |
| 1515 | 1638 | } |
| 1516 | 1639 | |
| 1517 | 1640 | /** |