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/TripController.php +336 -101 3.0.2.7 → 3.0.16 View file →
@@ -61,23 +61,23 @@
61 61 register_rest_route($namespace, '/' . $base, [
62 62 [
63 63 'methods' => \WP_REST_Server::READABLE,
64 64 'callback' => [$this, 'get_items'],
65 - 'permission_callback' => [$this, 'check_read_permission'],
65 + 'permission_callback' => [$this, 'check_view_permission'],
66 66 ],
67 67 [
68 68 'methods' => \WP_REST_Server::CREATABLE,
69 69 'callback' => [$this, 'create_item'],
70 - 'permission_callback' => [$this, 'check_permission'],
70 + 'permission_callback' => [$this, 'check_create_permission'],
71 71 ],
72 72 ]);
73 73
74 - // Duplicate trip: POST /trips/{id}/duplicate
74 + // Duplicate is a create — produces a new trip row.
75 75 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/duplicate', [
76 76 [
77 77 'methods' => \WP_REST_Server::CREATABLE,
78 78 'callback' => [$this, 'duplicate_item'],
79 - 'permission_callback' => [$this, 'check_permission'],
79 + 'permission_callback' => [$this, 'check_create_permission'],
80 80 ],
81 81 ]);
82 82
83 83 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)', [
@@ -83,46 +83,54 @@
83 83 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)', [
84 84 [
85 85 'methods' => \WP_REST_Server::READABLE,
86 86 'callback' => [$this, 'get_item'],
87 - 'permission_callback' => [$this, 'check_read_permission'],
87 + 'permission_callback' => [$this, 'check_view_permission'],
88 88 ],
89 89 [
90 + // EDITABLE covers both content edits AND publish/unpublish
91 + // state changes (the React form sends both via PUT).
92 + // We accept either edit OR publish cap — handlers should
93 + // refuse to change `status` when the user holds only the
94 + // edit cap, but the route gate lets both through.
90 95 'methods' => \WP_REST_Server::EDITABLE,
91 96 'callback' => [$this, 'update_item'],
92 - 'permission_callback' => [$this, 'check_permission'],
97 + 'permission_callback' => [$this, 'check_edit_or_publish_permission'],
93 98 ],
94 99 [
100 + // Soft-delete (trash) → edit cap. Trash is reversible
101 + // and is the day-to-day "remove from catalogue" action.
95 102 'methods' => \WP_REST_Server::DELETABLE,
96 103 'callback' => [$this, 'delete_item'],
97 - 'permission_callback' => [$this, 'check_permission'],
104 + 'permission_callback' => [$this, 'check_edit_permission'],
98 105 ],
99 106 ]);
100 107
101 - // Permanent delete endpoint
108 + // Permanent delete — bypasses trash. High-sensitivity action,
109 + // gated on the dedicated delete cap.
102 110 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/permanent-delete', [
103 111 [
104 112 'methods' => \WP_REST_Server::DELETABLE,
105 113 'callback' => [$this, 'permanent_delete_item'],
106 - 'permission_callback' => [$this, 'check_permission'],
114 + 'permission_callback' => [$this, 'check_delete_permission'],
107 115 ],
108 116 ]);
109 117
110 - // Search endpoint
118 + // Search endpoint — view cap.
111 119 register_rest_route($namespace, '/' . $base . '/search', [
112 120 [
113 121 'methods' => \WP_REST_Server::READABLE,
114 122 'callback' => [$this, 'search_items'],
115 - 'permission_callback' => [$this, 'check_read_permission'],
123 + 'permission_callback' => [$this, 'check_view_permission'],
116 124 ],
117 125 ]);
118 126
119 - // Revisions endpoints
127 + // Revisions list — view cap.
120 128 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/revisions', [
121 129 [
122 130 'methods' => \WP_REST_Server::READABLE,
123 131 'callback' => [$this, 'get_revisions'],
124 - 'permission_callback' => [$this, 'check_read_permission'],
132 + 'permission_callback' => [$this, 'check_view_permission'],
125 133 ],
126 134 ]);
127 135
128 136 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/revisions/(?P<revision_id>[\d]+)', [
@@ -128,14 +136,15 @@
128 136 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/revisions/(?P<revision_id>[\d]+)', [
129 137 [
130 138 'methods' => \WP_REST_Server::READABLE,
131 139 'callback' => [$this, 'get_revision'],
132 - 'permission_callback' => [$this, 'check_read_permission'],
140 + 'permission_callback' => [$this, 'check_view_permission'],
133 141 ],
134 142 [
143 + // Restoring a revision overwrites the live trip → edit cap.
135 144 'methods' => \WP_REST_Server::EDITABLE,
136 145 'callback' => [$this, 'restore_revision'],
137 - 'permission_callback' => [$this, 'check_permission'],
146 + 'permission_callback' => [$this, 'check_edit_permission'],
138 147 ],
139 148 ]);
140 149
141 150 // Availability template endpoint (public, no auth required)
@@ -165,34 +174,35 @@
165 174 'permission_callback' => '__return_true', // Public endpoint
166 175 ],
167 176 ]);
168 177
169 - // Status statistics for admin views
178 + // Status statistics for admin views — view cap.
170 179 register_rest_route($namespace, '/' . $base . '/stats', [
171 180 [
172 181 'methods' => \WP_REST_Server::READABLE,
173 182 'callback' => [$this, 'getStats'],
174 - 'permission_callback' => [$this, 'check_permission'],
183 + 'permission_callback' => [$this, 'check_view_permission'],
175 184 ],
176 185 ]);
177 186
178 - // Trip attributes endpoints (admin only — never expose unauthenticated read/write)
187 + // Test endpoint — view cap (read-only diagnostic).
179 188 register_rest_route($namespace, '/' . $base . '/test', [
180 189 'methods' => \WP_REST_Server::READABLE,
181 190 'callback' => [$this, 'test_endpoint'],
182 - 'permission_callback' => [$this, 'check_permission'],
191 + 'permission_callback' => [$this, 'check_view_permission'],
183 192 ]);
184 193
194 + // Trip-attribute assignments — trip-taxonomy edits go here.
185 195 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/attributes', [
186 196 [
187 197 'methods' => \WP_REST_Server::READABLE,
188 198 'callback' => [$this, 'get_trip_attributes'],
189 - 'permission_callback' => [$this, 'check_read_permission'],
199 + 'permission_callback' => [$this, 'check_view_permission'],
190 200 ],
191 201 [
192 202 'methods' => \WP_REST_Server::CREATABLE,
193 203 'callback' => [$this, 'update_trip_attributes'],
194 - 'permission_callback' => [$this, 'check_permission'],
204 + 'permission_callback' => [$this, 'check_taxonomy_permission'],
195 205 ],
196 206 ]);
197 207
198 208 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/attributes/(?P<attribute_id>[\d]+)', [
@@ -198,14 +208,58 @@
198 208 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/attributes/(?P<attribute_id>[\d]+)', [
199 209 [
200 210 'methods' => \WP_REST_Server::DELETABLE,
201 211 'callback' => [$this, 'delete_trip_attribute'],
202 - 'permission_callback' => [$this, 'check_permission'],
212 + 'permission_callback' => [$this, 'check_taxonomy_permission'],
203 213 ],
204 214 ]);
205 215 }
206 216
207 217 /**
218 + * Granular cap checks for every Trip endpoint. Overrides the
219 + * BaseController defaults (which gate everything on `manage_options`
220 + * and locked out every yatra_* role from the trips REST surface).
221 + * WP admins pass via the Team module's admin-fallback filter.
222 + */
223 + public function check_view_permission(?WP_REST_Request $request = null): bool
224 + {
225 + return current_user_can('yatra_view_trips');
226 + }
227 +
228 + public function check_create_permission(?WP_REST_Request $request = null): bool
229 + {
230 + return current_user_can('yatra_create_trips');
231 + }
232 +
233 + public function check_edit_permission(?WP_REST_Request $request = null): bool
234 + {
235 + return current_user_can('yatra_edit_trips');
236 + }
237 +
238 + /**
239 + * EDITABLE / PUT routes that may carry either a content edit or a
240 + * status change pass when the caller holds EITHER cap. The actual
241 + * handler should refuse to change `status` when only `edit` is
242 + * held — that's a future hardening, but the route gate already
243 + * keeps non-trip-staff out.
244 + */
245 + public function check_edit_or_publish_permission(?WP_REST_Request $request = null): bool
246 + {
247 + return current_user_can('yatra_edit_trips')
248 + || current_user_can('yatra_publish_trips');
249 + }
250 +
251 + public function check_delete_permission(?WP_REST_Request $request = null): bool
252 + {
253 + return current_user_can('yatra_delete_trips');
254 + }
255 +
256 + public function check_taxonomy_permission(?WP_REST_Request $request = null): bool
257 + {
258 + return current_user_can('yatra_manage_trip_taxonomies');
259 + }
260 +
261 + /**
208 262 * Get statistics for admin trip views (status counts)
209 263 */
210 264 public function getStats(WP_REST_Request $request)
211 265 {
@@ -617,17 +671,10 @@
617 671 if (isset($data['testimonial_review_ids'])) {
618 672 $data['testimonial_review_ids'] = is_string($data['testimonial_review_ids']) ? $data['testimonial_review_ids'] : wp_json_encode($data['testimonial_review_ids']);
619 673 }
620 674 if (isset($data['default_time_slots'])) {
621 - if (defined('WP_DEBUG') && WP_DEBUG) {
622 - error_log('Yatra DEBUG: default_time_slots received: ' . print_r($data['default_time_slots'], true));
623 - }
624 675 $data['default_time_slots'] = is_string($data['default_time_slots']) ? $data['default_time_slots'] : wp_json_encode($data['default_time_slots']);
625 - if (defined('WP_DEBUG') && WP_DEBUG) {
626 - error_log('Yatra DEBUG: default_time_slots after encoding: ' . $data['default_time_slots']);
627 - }
628 - } elseif (defined('WP_DEBUG') && WP_DEBUG) {
629 - error_log('Yatra DEBUG: default_time_slots NOT in request data');
676 +
630 677 }
631 678
632 679 // Handle featured_priority field (already in $data for update)
633 680 // Remove legacy/removed columns not present in trips table
@@ -895,10 +942,15 @@
895 942 */
896 943 public function restore_revision(WP_REST_Request $request)
897 944 {
898 945 try {
899 - // Check permissions
900 - if (!current_user_can('yatra_edit_trips')) {
946 + // Check permissions — admin fallback ensures site owners
947 + // always pass even when the Team module isn't active and
948 + // the yatra_edit_trips cap isn't on the admin role.
949 + if (
950 + !current_user_can('manage_options')
951 + && !current_user_can('yatra_edit_trips')
952 + ) {
901 953 return $this->error_response(__('You do not have permission to restore revisions', 'yatra'), 403);
902 954 }
903 955
904 956 $id = (int) $request->get_param('id');
@@ -1005,8 +1057,9 @@
1005 1057 'transportation_included',
1006 1058 'international_flights_included',
1007 1059 'domestic_flights_included',
1008 1060 'is_featured',
1061 + 'has_default_time_slots',
1009 1062 ];
1010 1063
1011 1064 foreach ($booleanFields as $field) {
1012 1065 if (isset($data[$field])) {
@@ -1276,8 +1329,20 @@
1276 1329 $images = is_array($decoded) ? $decoded : [];
1277 1330 }
1278 1331 }
1279 1332
1333 + // Decode gallery JSON column on the entry so the React form
1334 + // can re-populate the gallery picker without an extra fetch.
1335 + $gallery = [];
1336 + if (isset($entry->gallery)) {
1337 + if (is_array($entry->gallery)) {
1338 + $gallery = $entry->gallery;
1339 + } elseif (is_string($entry->gallery) && $entry->gallery !== '') {
1340 + $decoded = json_decode($entry->gallery, true);
1341 + $gallery = is_array($decoded) ? $decoded : [];
1342 + }
1343 + }
1344 +
1280 1345 return [
1281 1346 'id' => isset($entry->id) ? (int) $entry->id : null,
1282 1347 'day_id' => isset($entry->day_id) ? (int) $entry->day_id : null,
1283 1348 'time' => $entry->time ?? '',
@@ -1286,8 +1351,16 @@
1286 1351 'time_type' => $entry->time_type ?? 'exact',
1287 1352 'title' => $entry->title ?? '',
1288 1353 'description' => $entry->description ?? '',
1289 1354 'location' => $entry->location ?? '',
1355 + // The entries table has lat/lng/gallery/video_url + an `order`
1356 + // smallint column — but until this serializer included them, the
1357 + // /trips/{id} response never carried them. The React activity
1358 + // load mapper sorts by `entry.order`; without it, every entry
1359 + // arrived with order=null, the sort fell through to id-order,
1360 + // and drag-sort reorders never appeared to persist on reload.
1361 + 'location_latitude' => isset($entry->location_latitude) ? $entry->location_latitude : null,
1362 + 'location_longitude' => isset($entry->location_longitude) ? $entry->location_longitude : null,
1290 1363 'duration' => $entry->duration ?? '',
1291 1364 'cost' => isset($entry->cost) ? (float) $entry->cost : null,
1292 1365 'cost_per_person' => isset($entry->cost_per_person) ? (bool) $entry->cost_per_person : false,
1293 1366 'notes' => $entry->notes ?? '',
@@ -1293,8 +1366,11 @@
1293 1366 'notes' => $entry->notes ?? '',
1294 1367 'item_type_id' => isset($entry->item_type_id) ? (int) $entry->item_type_id : null,
1295 1368 'item_id' => isset($entry->item_id) ? (int) $entry->item_id : null,
1296 1369 'status' => $entry->status ?? 'active',
1370 + 'order' => isset($entry->order) ? (int) $entry->order : 0,
1371 + 'gallery' => $gallery,
1372 + 'video_url' => $entry->video_url ?? '',
1297 1373 'created_at' => $entry->created_at ?? '',
1298 1374 'updated_at' => $entry->updated_at ?? '',
1299 1375 'included_items' => $includedItems,
1300 1376 'excluded_items' => $excludedItems,
@@ -1439,18 +1515,16 @@
1439 1515
1440 1516 // Fetch availability dates using centralized resolution service
1441 1517 $resolutionService = new \Yatra\Services\AvailabilityResolutionService();
1442 1518
1443 - // Always show all dates from today onwards (selected_date is only for highlighting)
1519 + // Always show all dates from today onwards (selected_date is only for
1520 + // highlighting), up to the configurable booking horizon (Settings →
1521 + // Booking; 12 months unless changed — the previous hard-coded value).
1444 1522 $fromDate = date('Y-m-d');
1445 - $toDate = date('Y-m-d', strtotime('+12 months'));
1523 + $toDate = yatra_get_availability_horizon_date($fromDate);
1446 1524
1447 - $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate);
1525 + $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate, \Yatra\Services\SettingsService::isEnabled('show_sold_out'));
1448 1526
1449 - if (defined('WP_DEBUG') && WP_DEBUG) {
1450 - error_log('Yatra Availability Debug: Trip ID ' . $id . ' has ' . count($availability_dates) . ' availability dates from centralized service');
1451 - }
1452 -
1453 1527 // Determine if this is a day trip
1454 1528 $is_single_day = ($trip->duration_days ?? 1) <= 1;
1455 1529
1456 1530 // Auto-select month and date
@@ -1521,8 +1595,11 @@
1521 1595 'discounted_price' => isset($trip->discounted_price) ? (float) $trip->discounted_price : 0,
1522 1596 'sale_price' => isset($trip->sale_price) ? (float) $trip->sale_price : 0,
1523 1597 'currency' => SettingsService::getCurrency(),
1524 1598 'duration_days' => isset($trip->duration_days) ? (int) $trip->duration_days : 1,
1599 + // Hour-based day tours show "8 Hours" instead of "1 Day" on the
1600 + // availability cards. 0 for every existing (day-based) trip.
1601 + 'duration_hours' => isset($trip->duration_hours) ? (int) $trip->duration_hours : 0,
1525 1602 'max_travelers' => isset($trip->max_travelers) ? (int) $trip->max_travelers : 20,
1526 1603 'min_travelers' => isset($trip->min_travelers) ? (int) $trip->min_travelers : 1,
1527 1604 'pricing_type' => $trip->pricing_type ?? 'regular',
1528 1605 'price_types' => $trip->price_types ?? [], // Include price_types for traveler-based pricing
@@ -1662,8 +1739,36 @@
1662 1739
1663 1740 // Build cards from real availability data or use sample data
1664 1741 $availability_cards = [];
1665 1742 $month_filters = [];
1743 +
1744 + // Availability priority (same as the resolver):
1745 + // 1) manual availability dates, 2) recurring rules, 3) trip defaults.
1746 + // For UI counts + filters we want the list to reflect that priority (not a mixed set).
1747 + $availability_dates_for_render = $has_availability ? $trip_data->availability_dates : [];
1748 + if ($has_availability) {
1749 + $by_source = [
1750 + 'availability_date' => [],
1751 + 'recurring_rule' => [],
1752 + 'trip_default' => [],
1753 + ];
1754 + foreach ($trip_data->availability_dates as $a) {
1755 + if (!is_object($a)) {
1756 + continue;
1757 + }
1758 + $src = strtolower(trim((string) ($a->source ?? '')));
1759 + if (isset($by_source[$src])) {
1760 + $by_source[$src][] = $a;
1761 + }
1762 + }
1763 + if (!empty($by_source['availability_date'])) {
1764 + $availability_dates_for_render = $by_source['availability_date'];
1765 + } elseif (!empty($by_source['recurring_rule'])) {
1766 + $availability_dates_for_render = $by_source['recurring_rule'];
1767 + } elseif (!empty($by_source['trip_default'])) {
1768 + $availability_dates_for_render = $by_source['trip_default'];
1769 + }
1770 + }
1666 1771
1667 1772 // Determine if this is a day trip (duration <= 1 day)
1668 1773 $is_single_day = ($trip_data->duration_days ?? 1) <= 1;
1669 1774
@@ -1702,9 +1807,9 @@
1702 1807 $add_category_ids($trip_data->price_types);
1703 1808 }
1704 1809
1705 1810 if ($has_availability) {
1706 - foreach ($trip_data->availability_dates as $avail_for_cats) {
1811 + foreach ($availability_dates_for_render as $avail_for_cats) {
1707 1812 if (!empty($avail_for_cats->price_types)) {
1708 1813 $add_category_ids($avail_for_cats->price_types);
1709 1814 }
1710 1815 if (!empty($avail_for_cats->traveler_pricing)) {
@@ -1735,8 +1840,9 @@
1735 1840 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null,
1736 1841 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null,
1737 1842 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null,
1738 1843 'max_pax' => isset($meta['max_pax']) ? (int) $meta['max_pax'] : null,
1844 + 'group_overflow' => isset($meta['group_overflow']) && in_array($meta['group_overflow'], ['block', 'per_block'], true) ? $meta['group_overflow'] : 'block',
1739 1845 ];
1740 1846 }
1741 1847 }
1742 1848
@@ -1786,8 +1892,9 @@
1786 1892 if (!isset($pt['age_min'])) $pt['age_min'] = $meta['age_min'];
1787 1893 if (!isset($pt['age_max'])) $pt['age_max'] = $meta['age_max'];
1788 1894 if (!isset($pt['min_pax'])) $pt['min_pax'] = $meta['min_pax'];
1789 1895 if (!isset($pt['max_pax'])) $pt['max_pax'] = $meta['max_pax'];
1896 + $pt['group_overflow'] = $meta['group_overflow'] ?? 'block';
1790 1897 }
1791 1898
1792 1899 // Payable amount (honors price / sale_price / discounted_price like TripPricingService)
1793 1900 if (!isset($pt['effective_price'])) {
@@ -1813,13 +1920,19 @@
1813 1920
1814 1921 if (!empty($trip_data->price_types)) {
1815 1922 $trip_data->price_types = $enrich_price_types($trip_data->price_types);
1816 1923 }
1924 +
1925 + $dp_display_settings = apply_filters('yatra_get_dynamic_pricing_display_settings', [
1926 + 'show_original_price' => true,
1927 + 'show_savings_badge' => true,
1928 + 'show_urgency_messages' => false,
1929 + ]);
1817 1930
1818 1931 if ($has_availability) {
1819 1932 $current_time = time();
1820 -
1821 - foreach ($trip_data->availability_dates as $avail) {
1933 +
1934 + foreach ($availability_dates_for_render as $avail) {
1822 1935 if (empty($avail->departure_date)) {
1823 1936 // Skip entries without a valid departure date
1824 1937 continue;
1825 1938 }
@@ -1853,36 +1966,53 @@
1853 1966 // Store base prices before dynamic pricing
1854 1967 $base_original_price = $original_price;
1855 1968 $base_sale_price = $sale_price;
1856 1969
1857 - // Apply dynamic pricing if enabled (Pro DynamicPricingModule hooks here)
1970 + // Apply dynamic pricing if enabled (Pro DynamicPricingModule hooks here).
1971 + // Single pass on the effective sale price; list/original stays for strikethrough. Context supplies both for "regular vs discounted" rule base.
1858 1972 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1859 1973 $dp_context = [
1860 1974 'departure_date' => $avail->departure_date ?? null,
1861 1975 'spots_remaining' => $seats,
1862 1976 'availability_id' => $avail->id ?? null,
1977 + 'original_price' => $base_original_price,
1978 + 'discounted_price' => $base_sale_price,
1863 1979 ];
1864 - $original_price = apply_filters('yatra_availability_price', $original_price, $trip_data->id, $dp_context);
1865 - $sale_price = apply_filters('yatra_availability_price', $sale_price, $trip_data->id, $dp_context);
1980 + $sale_price = apply_filters('yatra_availability_price', $base_sale_price, $trip_data->id, $dp_context);
1866 1981 }
1867 1982
1868 - // Calculate discount/surge pricing badge
1869 - $discount_percent = $cardPricing['discount_percentage'];
1870 - $discount_text = '';
1871 -
1872 - if ($discount_percent > 0) {
1873 - $discount_text = sprintf(__('%d%% OFF', 'yatra'), $discount_percent);
1983 + // Savings badge: surge vs pre-DP sale first when DP raises price; else total % off vs list
1984 + // (covers regular + traveler-based + date-level pricing; DP stacked on sale is reflected in final vs list).
1985 + $discount_text = $this->computeAvailabilitySavingsBadgeText(
1986 + $base_original_price,
1987 + $base_sale_price,
1988 + $sale_price,
1989 + (bool) apply_filters('yatra_dynamic_pricing_enabled', false)
1990 + );
1991 +
1992 + // Dynamic Pricing → Display: hide savings / surge % badge on card when disabled.
1993 + if (is_array($dp_display_settings) && !filter_var($dp_display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN)) {
1994 + $discount_text = '';
1874 1995 }
1875 - // Check if dynamic pricing increased the price (surge)
1876 - elseif ($base_sale_price > 0 && $sale_price > $base_sale_price) {
1877 - $surge_percent = round((($sale_price - $base_sale_price) / $base_sale_price) * 100);
1878 - $discount_text = $surge_percent > 0 ? sprintf(__('+%d%%', 'yatra'), $surge_percent) : '';
1879 - }
1880 -
1996 +
1997 + $dp_card_fields = $this->buildAvailabilityDynamicPricingCardFields(
1998 + $dp_display_settings,
1999 + (int) $trip_data->id,
2000 + [
2001 + 'departure_date' => $avail->departure_date ?? null,
2002 + 'spots_remaining' => $seats,
2003 + 'availability_id' => $avail->id ?? null,
2004 + 'base_sale_price' => $base_sale_price,
2005 + 'base_original_price' => $base_original_price,
2006 + 'sale_price' => $sale_price,
2007 + 'original_price' => $original_price,
2008 + ]
2009 + );
2010 +
1881 2011 // Use month-based filters for both day trips and multi-day trips for better navigation
1882 2012 // This prevents overwhelming users with too many individual date filters
1883 2013 $month_key = strtolower(date('M-Y', $departure_date));
1884 - $month_filters[$month_key] = date('M Y', $departure_date);
2014 + $month_filters[$month_key] = date_i18n('M Y', $departure_date);
1885 2015
1886 2016 $from_location = !empty($avail->from_location) ? $avail->from_location : ($trip_data->starting_location ?? '');
1887 2017 $to_location = !empty($avail->to_location) ? $avail->to_location : ($trip_data->ending_location ?? $from_location);
1888 2018
@@ -1910,36 +2040,32 @@
1910 2040 $from_label = __('Departure', 'yatra');
1911 2041 $to_label = __('Return', 'yatra');
1912 2042 }
1913 2043
2044 + // Per-card duration: derive from THIS card's departure→return span
2045 + // so the displayed "X Days" always matches the departure/return
2046 + // dates shown on the same card. When no custom arrival is stored,
2047 + // $return_date is departure + (duration_days - 1), so the span
2048 + // equals the trip's duration_days (no visible change). Only when an
2049 + // operator stored an arrival that disagrees with the trip default
2050 + // does this diverge — and then the customer sees a self-consistent
2051 + // card (e.g. "10 Days" over a Jun 25 → Jul 04 span) instead of a
2052 + // "9 Days" badge contradicting the dates. round() (not floor())
2053 + // absorbs any ±1h DST drift between two local-midnight timestamps.
2054 + $card_duration_days = $is_single_day
2055 + ? max(1, (int) ($trip_data->duration_days ?? 1))
2056 + : max(1, (int) round(($return_date - $departure_date) / DAY_IN_SECONDS) + 1);
2057 +
1914 2058 // Use month-based keys for filtering for both day trips and multi-day trips
1915 2059 $filter_key = strtolower(date('M-Y', $departure_date));
1916 2060
1917 - // pricing_type MODEL comes from trip level (regular vs traveler_based)
1918 - // Note: $avail->pricing_type enum is about price state, not pricing model
1919 - $card_pricing_type = $trip_data->pricing_type ?? 'regular';
1920 - if (!empty($avail->price_types) && is_array($avail->price_types) && count($avail->price_types) > 0) {
1921 - $card_pricing_type = 'traveler_based';
1922 - }
1923 -
1924 - // price_types come from centralized service (already resolved with priority: Rules → Dates → Trip)
2061 + // Must match {@see TripPricingService::resolveCardPricing}: trip-level mode wins; do not
2062 + // treat inherited stale price_types on a date as traveler-based when the trip is regular.
2063 + $card_pricing_type = $cardPricing['pricing_type'];
1925 2064 $card_traveler_pricing = [];
1926 - if (!empty($avail->price_types)) {
1927 - $card_traveler_pricing = is_array($avail->price_types) ? $avail->price_types : [];
1928 -
1929 - // Enrich with category labels if needed
1930 - if (!empty($card_traveler_pricing)) {
1931 - $card_traveler_pricing = $enrich_price_types($card_traveler_pricing);
1932 - }
1933 -
1934 - // Debug logging
1935 - if (defined('WP_DEBUG') && WP_DEBUG) {
1936 - error_log('Yatra Card traveler_pricing count: ' . count($card_traveler_pricing));
1937 - if (!empty($card_traveler_pricing[0])) {
1938 - $first = is_array($card_traveler_pricing[0]) ? $card_traveler_pricing[0] : (array) $card_traveler_pricing[0];
1939 - error_log('Yatra Card first category: ' . print_r($first, true));
1940 - }
1941 - }
2065 + $pts_for_card = $cardPricing['price_types'] ?? [];
2066 + if (!empty($pts_for_card) && is_array($pts_for_card)) {
2067 + $card_traveler_pricing = $enrich_price_types($pts_for_card);
1942 2068 }
1943 2069
1944 2070 $availability_cards[] = [
1945 2071 'id' => $avail->id,
@@ -1949,8 +2075,9 @@
1949 2075 'to_label' => $to_label,
1950 2076 'to_date' => $to_display,
1951 2077 'to_location' => $to_location,
1952 2078 'date_display' => $date_display, // For day trips: "Saturday, 30 Nov 2025"
2079 + 'duration_days' => $card_duration_days, // Inclusive span of THIS card's dates
1953 2080 'date' => $avail->departure_date, // Raw date for dynamic pricing
1954 2081 'spots_remaining' => $seats, // For dynamic pricing
1955 2082 'seats' => $seats > 10 ? '10+' : (string) $seats,
1956 2083 'seats_available' => $seats,
@@ -1973,11 +2100,13 @@
1973 2100 'is_sold_out' => $is_sold_out,
1974 2101 // Card-specific pricing
1975 2102 'pricing_type' => $card_pricing_type,
1976 2103 'traveler_pricing' => $card_traveler_pricing,
2104 + 'price_unit' => $cardPricing['price_unit'] ?? 'per_person',
2105 + 'price_unit_label' => $cardPricing['price_unit_label'] ?? __('per person', 'yatra'),
1977 2106 'is_recurring' => !empty($avail->is_recurring),
1978 2107 'rule_id' => $avail->rule_id ?? null,
1979 - ];
2108 + ] + $dp_card_fields;
1980 2109 }
1981 2110 }
1982 2111
1983 2112 // Use sample data only if no real availability
@@ -1990,33 +2119,44 @@
1990 2119 // Store base prices before dynamic pricing
1991 2120 $base_sample_original = $sample_original;
1992 2121 $base_sample_sale = $sample_sale;
1993 2122
1994 - // Apply dynamic pricing to sample card
2123 + // Apply dynamic pricing to sample card (sale line only; list price unchanged for display)
1995 2124 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1996 - $sample_original = apply_filters('yatra_availability_price', $sample_original, $trip_data->id, [
2125 + $sample_sale = apply_filters('yatra_availability_price', $base_sample_sale, $trip_data->id, [
1997 2126 'departure_date' => $sample_date,
1998 2127 'spots_remaining' => $sample_seats,
1999 2128 'availability_id' => 'sample-1',
2129 + 'original_price' => $base_sample_original,
2130 + 'discounted_price' => $base_sample_sale,
2000 2131 ]);
2001 - $sample_sale = apply_filters('yatra_availability_price', $sample_sale, $trip_data->id, [
2132 + }
2133 +
2134 + $sample_discount_text = $this->computeAvailabilitySavingsBadgeText(
2135 + $base_sample_original,
2136 + $base_sample_sale,
2137 + $sample_sale,
2138 + (bool) apply_filters('yatra_dynamic_pricing_enabled', false)
2139 + );
2140 +
2141 + if (is_array($dp_display_settings) && !filter_var($dp_display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN)) {
2142 + $sample_discount_text = '';
2143 + }
2144 +
2145 + $sample_dp_fields = $this->buildAvailabilityDynamicPricingCardFields(
2146 + $dp_display_settings,
2147 + (int) $trip_data->id,
2148 + [
2002 2149 'departure_date' => $sample_date,
2003 2150 'spots_remaining' => $sample_seats,
2004 2151 'availability_id' => 'sample-1',
2005 - ]);
2006 - }
2152 + 'base_sale_price' => $base_sample_sale,
2153 + 'base_original_price' => $base_sample_original,
2154 + 'sale_price' => $sample_sale,
2155 + 'original_price' => $sample_original,
2156 + ]
2157 + );
2007 2158
2008 - // Calculate discount/surge pricing badge for sample card
2009 - $sample_discount_text = '';
2010 - if ($base_sample_original > 0 && $base_sample_sale < $base_sample_original) {
2011 - $discount_percent = round((($base_sample_original - $base_sample_sale) / $base_sample_original) * 100);
2012 - $sample_discount_text = $discount_percent > 0 ? sprintf(__('%d%% OFF', 'yatra'), $discount_percent) : '';
2013 - }
2014 - elseif ($base_sample_sale > 0 && $sample_sale > $base_sample_sale) {
2015 - $surge_percent = round((($sample_sale - $base_sample_sale) / $base_sample_sale) * 100);
2016 - $sample_discount_text = $surge_percent > 0 ? sprintf(__('+%d%%', 'yatra'), $surge_percent) : '';
2017 - }
2018 -
2019 2159 $availability_cards = [
2020 2160 [
2021 2161 'id' => 'sample-1',
2022 2162 'from_label' => __('Departure', 'yatra'),
@@ -2044,11 +2184,11 @@
2044 2184 'pricing_type' => $trip_data->pricing_type ?? 'regular',
2045 2185 'traveler_pricing' => $trip_data->price_types ?? [],
2046 2186 'is_recurring' => false,
2047 2187 'rule_id' => null,
2048 - ],
2188 + ] + $sample_dp_fields,
2049 2189 ];
2050 - $month_filters[strtolower(date('M-Y', strtotime('+7 days')))] = date('M Y', strtotime('+7 days'));
2190 + $month_filters[strtolower(date('M-Y', strtotime('+7 days')))] = date_i18n('M Y', strtotime('+7 days'));
2051 2191 }
2052 2192
2053 2193 $sorted_cards = $this->sortAvailabilityCards($availability_cards, $sort_key);
2054 2194
@@ -2101,8 +2241,96 @@
2101 2241
2102 2242 return $slice;
2103 2243 }
2104 2244
2245 + /**
2246 + * "% OFF" / "+%" badge for availability cards after dynamic pricing is applied to the sale line.
2247 + *
2248 + * - If dynamic pricing is on and the final price is above the pre-DP sale, show surge vs that sale (priority).
2249 + * - Otherwise, if list/original on the card is above the final price, show total % off vs list (trip/date
2250 + * discount + any extra DP discount in one number — never understates vs showing only the old catalog %).
2251 + * - If there is no list price but DP reduced the promo-only anchor, show % off vs that anchor.
2252 + *
2253 + * Works for regular, traveler-based (uses same header O/B/F from {@see TripPricingService::resolveCardPricing}),
2254 + * and availability date pricing (already in O/B from the card resolver).
2255 + */
2256 + private function computeAvailabilitySavingsBadgeText(
2257 + float $base_original_price,
2258 + float $base_sale_price,
2259 + float $final_sale_price,
2260 + bool $dynamic_pricing_enabled
2261 + ): string {
2262 + $O = max(0.0, $base_original_price);
2263 + $B = max(0.0, $base_sale_price);
2264 + $F = max(0.0, $final_sale_price);
2265 + $eps = 0.005;
2266 +
2267 + if ($dynamic_pricing_enabled && $B > $eps && $F > $B + $eps) {
2268 + $p = (int) round((($F - $B) / $B) * 100);
2269 +
2270 + /* translators: %d: dynamic pricing increase percentage. */
2271 + return $p > 0 ? sprintf(__('+%d%%', 'yatra'), $p) : '';
2272 + }
2273 +
2274 + if ($O > $eps && $F < $O - $eps) {
2275 + $p = (int) round((($O - $F) / $O) * 100);
2276 +
2277 + /* translators: %d: discount percentage. */
2278 + return $p > 0 ? sprintf(__('%d%% OFF', 'yatra'), $p) : '';
2279 + }
2280 +
2281 + if ($O <= $eps && $B > $eps && $F < $B - $eps) {
2282 + $p = (int) round((($B - $F) / $B) * 100);
2283 +
2284 + /* translators: %d: discount percentage. */
2285 + return $p > 0 ? sprintf(__('%d%% OFF', 'yatra'), $p) : '';
2286 + }
2287 +
2288 + return '';
2289 + }
2290 +
2291 + /**
2292 + * Per-departure-card dynamic pricing display flags + urgency lines (Pro fills via filter).
2293 + *
2294 + * @param array<string, mixed> $display_settings From yatra_get_dynamic_pricing_display_settings
2295 + * @param array<string, mixed> $context departure_date, spots_remaining, prices, availability_id, …
2296 + * @return array{dynamic_pricing_display: array<string, bool>, dynamic_pricing_urgency_messages: array<int, string>}
2297 + */
2298 + private function buildAvailabilityDynamicPricingCardFields(array $display_settings, int $trip_id, array $context): array
2299 + {
2300 + $display = [
2301 + 'show_original_price' => filter_var($display_settings['show_original_price'] ?? true, FILTER_VALIDATE_BOOLEAN),
2302 + 'show_savings_badge' => filter_var($display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN),
2303 + 'show_urgency_messages' => filter_var($display_settings['show_urgency_messages'] ?? false, FILTER_VALIDATE_BOOLEAN),
2304 + ];
2305 +
2306 + $meta = apply_filters(
2307 + 'yatra_availability_card_dynamic_pricing_meta',
2308 + ['urgency_messages' => []],
2309 + array_merge($context, [
2310 + 'trip_id' => $trip_id,
2311 + 'display' => $display,
2312 + 'dp_display_settings' => $display_settings,
2313 + ])
2314 + );
2315 +
2316 + $urgency = [];
2317 + if (is_array($meta) && !empty($meta['urgency_messages']) && is_array($meta['urgency_messages'])) {
2318 + foreach ($meta['urgency_messages'] as $m) {
2319 + $line = sanitize_text_field((string) $m);
2320 + if ($line !== '') {
2321 + $urgency[] = $line;
2322 + }
2323 + }
2324 + $urgency = array_values(array_unique($urgency));
2325 + }
2326 +
2327 + return [
2328 + 'dynamic_pricing_display' => $display,
2329 + 'dynamic_pricing_urgency_messages' => $urgency,
2330 + ];
2331 + }
2332 +
2105 2333 private function sortAvailabilityCards(array $cards, string $sort_key): array
2106 2334 {
2107 2335 $sort_key = sanitize_text_field($sort_key);
2108 2336
@@ -2225,11 +2453,18 @@
2225 2453 }
2226 2454
2227 2455 // Apply dynamic pricing
2228 2456 if ($dp_enabled && $price > 0) {
2457 + $pt_orig = (float) ($pt->original_price ?? 0);
2458 + $pt_disc = (float) ($pt->sale_price ?? $pt->discounted_price ?? $pt->effective_price ?? $price);
2459 + if ($pt_disc <= 0) {
2460 + $pt_disc = $price;
2461 + }
2229 2462 $price = apply_filters('yatra_availability_price', $price, $trip_id, [
2230 2463 'departure_date' => $date,
2231 2464 'price_type_id' => $pt->id ?? null,
2465 + 'original_price' => $pt_orig > 0 ? $pt_orig : $price,
2466 + 'discounted_price' => $pt_disc > 0 ? $pt_disc : $price,
2232 2467 ]);
2233 2468 }
2234 2469
2235 2470 $pt->effective_price = $price;