| @@ -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 | ], |