| @@ -65,8 +65,13 @@ | ||
| 65 | 65 | 'price_prefix' => '', |
| 66 | 66 | 'pricing_type' => $pricing_type, |
| 67 | 67 | 'price_types' => $price_types, |
| 68 | 68 | 'has_traveler_pricing' => $has_traveler_pricing, |
| 69 | + // How the displayed amount is charged: 'per_person' or 'per_group' | |
| 70 | + // (+ the pax range), taken from the SAME category the amount comes | |
| 71 | + // from, so the label can never contradict the price. | |
| 72 | + 'price_unit' => 'per_person', | |
| 73 | + 'price_unit_label' => self::priceUnitLabel(null), | |
| 69 | 74 | 'currency' => SettingsService::getCurrency(), |
| 70 | 75 | ]; |
| 71 | 76 | |
| 72 | 77 | if ($has_traveler_pricing) { |
| @@ -74,10 +79,12 @@ | ||
| 74 | 79 | // - If a default category is marked, use that for initial display (listings + single trip page-load). |
| 75 | 80 | // - Otherwise fall back to minimum effective price across categories (current behavior). |
| 76 | 81 | $default_price = 0.0; |
| 77 | 82 | $default_original = 0.0; |
| 83 | + $default_pt = null; | |
| 78 | 84 | $min_price = PHP_FLOAT_MAX; |
| 79 | 85 | $min_original = 0.0; |
| 86 | + $min_pt = null; | |
| 80 | 87 | $max_discount = 0; |
| 81 | 88 | |
| 82 | 89 | foreach ($price_types as $pt) { |
| 83 | 90 | $pt = (array) $pt; |
| @@ -86,13 +93,15 @@ | ||
| 86 | 93 | |
| 87 | 94 | if (!empty($pt['is_default']) && $default_price <= 0 && $discounted > 0) { |
| 88 | 95 | $default_price = $discounted; |
| 89 | 96 | $default_original = $original; |
| 97 | + $default_pt = $pt; | |
| 90 | 98 | } |
| 91 | 99 | |
| 92 | 100 | if ($discounted > 0 && $discounted < $min_price) { |
| 93 | 101 | $min_price = $discounted; |
| 94 | 102 | $min_original = $original; |
| 103 | + $min_pt = $pt; | |
| 95 | 104 | } |
| 96 | 105 | |
| 97 | 106 | // Track max discount across categories |
| 98 | 107 | if ($original > 0 && $discounted > 0 && $discounted < $original) { |
| @@ -104,10 +113,14 @@ | ||
| 104 | 113 | } |
| 105 | 114 | |
| 106 | 115 | $chosen_price = $default_price > 0 ? $default_price : ($min_price < PHP_FLOAT_MAX ? $min_price : 0.0); |
| 107 | 116 | $chosen_original = $default_price > 0 ? $default_original : $min_original; |
| 117 | + $chosen_pt = $default_price > 0 ? $default_pt : $min_pt; | |
| 108 | 118 | |
| 109 | 119 | if ($chosen_price > 0) { |
| 120 | + $unit = self::priceUnit($chosen_pt); | |
| 121 | + $result['price_unit'] = $unit['unit']; | |
| 122 | + $result['price_unit_label'] = $unit['label']; | |
| 110 | 123 | $result['effective_price_min'] = $chosen_price; |
| 111 | 124 | $result['min_category_original_price'] = $chosen_original; |
| 112 | 125 | $result['max_discount_percentage'] = $max_discount; |
| 113 | 126 | $result['current_price'] = $chosen_price; |
| @@ -139,8 +152,16 @@ | ||
| 139 | 152 | $avail_min = self::findMinPriceFromAvailability($availabilityDates, $has_traveler_pricing); |
| 140 | 153 | if ($avail_min > 0 && ($result['effective_price_min'] <= 0 || $avail_min < $result['effective_price_min'])) { |
| 141 | 154 | $result['effective_price_min'] = $avail_min; |
| 142 | 155 | $result['current_price'] = $avail_min; |
| 156 | + if ($has_traveler_pricing) { | |
| 157 | + $avail_pt = self::findAvailabilityPriceTypeAt($availabilityDates, $avail_min); | |
| 158 | + if ($avail_pt !== null) { | |
| 159 | + $unit = self::priceUnit($avail_pt); | |
| 160 | + $result['price_unit'] = $unit['unit']; | |
| 161 | + $result['price_unit_label'] = $unit['label']; | |
| 162 | + } | |
| 163 | + } | |
| 143 | 164 | } |
| 144 | 165 | } elseif ($has_traveler_pricing) { |
| 145 | 166 | $result['price_prefix'] = __('From ', 'yatra'); |
| 146 | 167 | } |
| @@ -177,8 +198,10 @@ | ||
| 177 | 198 | 'has_discount' => false, |
| 178 | 199 | 'discount_percentage' => 0, |
| 179 | 200 | 'pricing_type' => $pricing_type, |
| 180 | 201 | 'price_types' => $pricing_type === 'traveler_based' ? $avail_price_types : [], |
| 202 | + 'price_unit' => 'per_person', | |
| 203 | + 'price_unit_label' => self::priceUnitLabel(null), | |
| 181 | 204 | ]; |
| 182 | 205 | |
| 183 | 206 | if ($pricing_type === 'traveler_based' && !empty($avail_price_types)) { |
| 184 | 207 | // Traveler-based: use first category's price as display |
| @@ -184,8 +207,11 @@ | ||
| 184 | 207 | // Traveler-based: use first category's price as display |
| 185 | 208 | $first = (array) $avail_price_types[0]; |
| 186 | 209 | $result['sale_price'] = self::resolveCategoryEffectivePrice($first); |
| 187 | 210 | $result['original_price'] = (float) ($first['original_price'] ?? $result['sale_price']); |
| 211 | + $unit = self::priceUnit($first); | |
| 212 | + $result['price_unit'] = $unit['unit']; | |
| 213 | + $result['price_unit_label'] = $unit['label']; | |
| 188 | 214 | } elseif (isset($avail->effective_price) && (float) $avail->effective_price > 0) { |
| 189 | 215 | // Regular: use pre-calculated effective price |
| 190 | 216 | $result['sale_price'] = (float) $avail->effective_price; |
| 191 | 217 | $result['original_price'] = isset($avail->original_price) && (float) $avail->original_price > 0 |
| @@ -525,8 +551,102 @@ | ||
| 525 | 551 | ); |
| 526 | 552 | } |
| 527 | 553 | |
| 528 | 554 | return (array) apply_filters('yatra_resolve_discount_info', $result, $originalPrice, $currentPrice); |
| 555 | + } | |
| 556 | + | |
| 557 | + /** | |
| 558 | + * How a category's price is charged, for display next to an amount. | |
| 559 | + * | |
| 560 | + * A traveller category is priced 'per_person' (× headcount) or 'per_group' | |
| 561 | + * (flat for min_pax–max_pax people). The trip's stored price_types JSON does | |
| 562 | + * not carry the mode, so it is read back from the category classification | |
| 563 | + * (applyCategoryPricingMeta) exactly as checkout does. Pass the category | |
| 564 | + * entry whose price is being displayed; null / a regular-pricing trip → | |
| 565 | + * per person. | |
| 566 | + * | |
| 567 | + * @param array|object|null $pt | |
| 568 | + * @return array{unit:string,label:string,min_pax:?int,max_pax:?int} | |
| 569 | + */ | |
| 570 | + public static function priceUnit($pt): array | |
| 571 | + { | |
| 572 | + $unit = ['unit' => 'per_person', 'label' => '', 'min_pax' => null, 'max_pax' => null]; | |
| 573 | + | |
| 574 | + if ($pt !== null) { | |
| 575 | + $pt = (array) $pt; | |
| 576 | + if (!isset($pt['pricing_mode']) && isset($pt['category_id'])) { | |
| 577 | + $backfilled = self::applyCategoryPricingMeta([$pt]); | |
| 578 | + $pt = (array) ($backfilled[0] ?? $pt); | |
| 579 | + } | |
| 580 | + if (($pt['pricing_mode'] ?? 'per_person') === 'per_group') { | |
| 581 | + $unit['unit'] = 'per_group'; | |
| 582 | + $unit['min_pax'] = isset($pt['min_pax']) && $pt['min_pax'] !== '' && $pt['min_pax'] !== null ? (int) $pt['min_pax'] : null; | |
| 583 | + $unit['max_pax'] = isset($pt['max_pax']) && $pt['max_pax'] !== '' && $pt['max_pax'] !== null ? (int) $pt['max_pax'] : null; | |
| 584 | + } | |
| 585 | + } | |
| 586 | + | |
| 587 | + if ($unit['unit'] === 'per_group') { | |
| 588 | + if ($unit['min_pax'] > 0 && $unit['max_pax'] > 0) { | |
| 589 | + /* translators: 1: minimum pax for the group price, 2: maximum pax. */ | |
| 590 | + $label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $unit['min_pax'], $unit['max_pax']); | |
| 591 | + } elseif ($unit['max_pax'] > 0) { | |
| 592 | + /* translators: %d: maximum pax for the group price. */ | |
| 593 | + $label = sprintf(__('per group (up to %d pax)', 'yatra'), $unit['max_pax']); | |
| 594 | + } elseif ($unit['min_pax'] > 0) { | |
| 595 | + /* translators: %d: minimum pax for the group price. */ | |
| 596 | + $label = sprintf(__('per group (%d+ pax)', 'yatra'), $unit['min_pax']); | |
| 597 | + } else { | |
| 598 | + $label = __('per group', 'yatra'); | |
| 599 | + } | |
| 600 | + } else { | |
| 601 | + $label = __('per person', 'yatra'); | |
| 602 | + } | |
| 603 | + | |
| 604 | + /** | |
| 605 | + * Wording of the unit shown next to a price ("per person", "per group (1-2 pax)"). | |
| 606 | + * | |
| 607 | + * @param string $label Translated label. | |
| 608 | + * @param string $unit 'per_person' or 'per_group'. | |
| 609 | + * @param array|null $pt The traveller-category price entry, if any. | |
| 610 | + */ | |
| 611 | + $unit['label'] = (string) apply_filters('yatra_price_unit_label', $label, $unit['unit'], $pt); | |
| 612 | + | |
| 613 | + return $unit; | |
| 614 | + } | |
| 615 | + | |
| 616 | + /** | |
| 617 | + * Shorthand for priceUnit()['label']. | |
| 618 | + * | |
| 619 | + * @param array|object|null $pt | |
| 620 | + */ | |
| 621 | + public static function priceUnitLabel($pt): string | |
| 622 | + { | |
| 623 | + return self::priceUnit($pt)['label']; | |
| 624 | + } | |
| 625 | + | |
| 626 | + /** | |
| 627 | + * The availability price-type entry whose effective price equals $price | |
| 628 | + * (the value findMinPriceFromAvailability() picked), so the unit label can | |
| 629 | + * follow that category. Null when the minimum came from the availability | |
| 630 | + * row itself rather than a category. | |
| 631 | + * | |
| 632 | + * @param array<int, object> $availabilityDates | |
| 633 | + */ | |
| 634 | + private static function findAvailabilityPriceTypeAt(array $availabilityDates, float $price): ?array | |
| 635 | + { | |
| 636 | + foreach ($availabilityDates as $avail) { | |
| 637 | + if (empty($avail->price_types) || !is_array($avail->price_types)) { | |
| 638 | + continue; | |
| 639 | + } | |
| 640 | + foreach ($avail->price_types as $pt) { | |
| 641 | + $pt = (array) $pt; | |
| 642 | + if (abs(self::resolveCategoryEffectivePrice($pt) - $price) < 0.005) { | |
| 643 | + return $pt; | |
| 644 | + } | |
| 645 | + } | |
| 646 | + } | |
| 647 | + | |
| 648 | + return null; | |
| 529 | 649 | } |
| 530 | 650 | |
| 531 | 651 | /** |
| 532 | 652 | * Find minimum price across availability dates. |