| @@ -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 | { |
| @@ -333,8 +387,17 @@ | ||
| 333 | 387 | }, $items); |
| 334 | 388 | $tripIds = array_values(array_filter($tripIds)); |
| 335 | 389 | |
| 336 | 390 | if (!empty($tripIds)) { |
| 391 | + // Attach bookings_count computed from bookings table (trips.bookings_count is not reliably maintained) | |
| 392 | + $bookingsCountMap = $this->service->getBookingsCountMap($tripIds); | |
| 393 | + foreach ($items as $item) { | |
| 394 | + $tId = isset($item->id) ? (int) $item->id : 0; | |
| 395 | + if ($tId > 0) { | |
| 396 | + $item->bookings_count = (int) ($bookingsCountMap[$tId] ?? 0); | |
| 397 | + } | |
| 398 | + } | |
| 399 | + | |
| 337 | 400 | // Destinations |
| 338 | 401 | $destByTrip = []; |
| 339 | 402 | foreach ($tripIds as $id) { |
| 340 | 403 | $destinations = $this->service->getTripDestinations($id); |
| @@ -608,17 +671,10 @@ | ||
| 608 | 671 | if (isset($data['testimonial_review_ids'])) { |
| 609 | 672 | $data['testimonial_review_ids'] = is_string($data['testimonial_review_ids']) ? $data['testimonial_review_ids'] : wp_json_encode($data['testimonial_review_ids']); |
| 610 | 673 | } |
| 611 | 674 | if (isset($data['default_time_slots'])) { |
| 612 | - if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 613 | - error_log('Yatra DEBUG: default_time_slots received: ' . print_r($data['default_time_slots'], true)); | |
| 614 | - } | |
| 615 | 675 | $data['default_time_slots'] = is_string($data['default_time_slots']) ? $data['default_time_slots'] : wp_json_encode($data['default_time_slots']); |
| 616 | - if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 617 | - error_log('Yatra DEBUG: default_time_slots after encoding: ' . $data['default_time_slots']); | |
| 618 | - } | |
| 619 | - } elseif (defined('WP_DEBUG') && WP_DEBUG) { | |
| 620 | - error_log('Yatra DEBUG: default_time_slots NOT in request data'); | |
| 676 | + | |
| 621 | 677 | } |
| 622 | 678 | |
| 623 | 679 | // Handle featured_priority field (already in $data for update) |
| 624 | 680 | // Remove legacy/removed columns not present in trips table |
| @@ -886,10 +942,15 @@ | ||
| 886 | 942 | */ |
| 887 | 943 | public function restore_revision(WP_REST_Request $request) |
| 888 | 944 | { |
| 889 | 945 | try { |
| 890 | - // Check permissions | |
| 891 | - 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 | + ) { | |
| 892 | 953 | return $this->error_response(__('You do not have permission to restore revisions', 'yatra'), 403); |
| 893 | 954 | } |
| 894 | 955 | |
| 895 | 956 | $id = (int) $request->get_param('id'); |
| @@ -996,8 +1057,9 @@ | ||
| 996 | 1057 | 'transportation_included', |
| 997 | 1058 | 'international_flights_included', |
| 998 | 1059 | 'domestic_flights_included', |
| 999 | 1060 | 'is_featured', |
| 1061 | + 'has_default_time_slots', | |
| 1000 | 1062 | ]; |
| 1001 | 1063 | |
| 1002 | 1064 | foreach ($booleanFields as $field) { |
| 1003 | 1065 | if (isset($data[$field])) { |
| @@ -1267,8 +1329,20 @@ | ||
| 1267 | 1329 | $images = is_array($decoded) ? $decoded : []; |
| 1268 | 1330 | } |
| 1269 | 1331 | } |
| 1270 | 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 | + | |
| 1271 | 1345 | return [ |
| 1272 | 1346 | 'id' => isset($entry->id) ? (int) $entry->id : null, |
| 1273 | 1347 | 'day_id' => isset($entry->day_id) ? (int) $entry->day_id : null, |
| 1274 | 1348 | 'time' => $entry->time ?? '', |
| @@ -1277,8 +1351,16 @@ | ||
| 1277 | 1351 | 'time_type' => $entry->time_type ?? 'exact', |
| 1278 | 1352 | 'title' => $entry->title ?? '', |
| 1279 | 1353 | 'description' => $entry->description ?? '', |
| 1280 | 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, | |
| 1281 | 1363 | 'duration' => $entry->duration ?? '', |
| 1282 | 1364 | 'cost' => isset($entry->cost) ? (float) $entry->cost : null, |
| 1283 | 1365 | 'cost_per_person' => isset($entry->cost_per_person) ? (bool) $entry->cost_per_person : false, |
| 1284 | 1366 | 'notes' => $entry->notes ?? '', |
| @@ -1284,8 +1366,11 @@ | ||
| 1284 | 1366 | 'notes' => $entry->notes ?? '', |
| 1285 | 1367 | 'item_type_id' => isset($entry->item_type_id) ? (int) $entry->item_type_id : null, |
| 1286 | 1368 | 'item_id' => isset($entry->item_id) ? (int) $entry->item_id : null, |
| 1287 | 1369 | 'status' => $entry->status ?? 'active', |
| 1370 | + 'order' => isset($entry->order) ? (int) $entry->order : 0, | |
| 1371 | + 'gallery' => $gallery, | |
| 1372 | + 'video_url' => $entry->video_url ?? '', | |
| 1288 | 1373 | 'created_at' => $entry->created_at ?? '', |
| 1289 | 1374 | 'updated_at' => $entry->updated_at ?? '', |
| 1290 | 1375 | 'included_items' => $includedItems, |
| 1291 | 1376 | 'excluded_items' => $excludedItems, |
| @@ -1430,18 +1515,16 @@ | ||
| 1430 | 1515 | |
| 1431 | 1516 | // Fetch availability dates using centralized resolution service |
| 1432 | 1517 | $resolutionService = new \Yatra\Services\AvailabilityResolutionService(); |
| 1433 | 1518 | |
| 1434 | - // 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). | |
| 1435 | 1522 | $fromDate = date('Y-m-d'); |
| 1436 | - $toDate = date('Y-m-d', strtotime('+12 months')); | |
| 1523 | + $toDate = yatra_get_availability_horizon_date($fromDate); | |
| 1437 | 1524 | |
| 1438 | - $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate); | |
| 1525 | + $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate, \Yatra\Services\SettingsService::isEnabled('show_sold_out')); | |
| 1439 | 1526 | |
| 1440 | - if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 1441 | - error_log('Yatra Availability Debug: Trip ID ' . $id . ' has ' . count($availability_dates) . ' availability dates from centralized service'); | |
| 1442 | - } | |
| 1443 | - | |
| 1444 | 1527 | // Determine if this is a day trip |
| 1445 | 1528 | $is_single_day = ($trip->duration_days ?? 1) <= 1; |
| 1446 | 1529 | |
| 1447 | 1530 | // Auto-select month and date |
| @@ -1512,8 +1595,11 @@ | ||
| 1512 | 1595 | 'discounted_price' => isset($trip->discounted_price) ? (float) $trip->discounted_price : 0, |
| 1513 | 1596 | 'sale_price' => isset($trip->sale_price) ? (float) $trip->sale_price : 0, |
| 1514 | 1597 | 'currency' => SettingsService::getCurrency(), |
| 1515 | 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, | |
| 1516 | 1602 | 'max_travelers' => isset($trip->max_travelers) ? (int) $trip->max_travelers : 20, |
| 1517 | 1603 | 'min_travelers' => isset($trip->min_travelers) ? (int) $trip->min_travelers : 1, |
| 1518 | 1604 | 'pricing_type' => $trip->pricing_type ?? 'regular', |
| 1519 | 1605 | 'price_types' => $trip->price_types ?? [], // Include price_types for traveler-based pricing |
| @@ -1653,8 +1739,36 @@ | ||
| 1653 | 1739 | |
| 1654 | 1740 | // Build cards from real availability data or use sample data |
| 1655 | 1741 | $availability_cards = []; |
| 1656 | 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 | + } | |
| 1657 | 1771 | |
| 1658 | 1772 | // Determine if this is a day trip (duration <= 1 day) |
| 1659 | 1773 | $is_single_day = ($trip_data->duration_days ?? 1) <= 1; |
| 1660 | 1774 | |
| @@ -1693,9 +1807,9 @@ | ||
| 1693 | 1807 | $add_category_ids($trip_data->price_types); |
| 1694 | 1808 | } |
| 1695 | 1809 | |
| 1696 | 1810 | if ($has_availability) { |
| 1697 | - foreach ($trip_data->availability_dates as $avail_for_cats) { | |
| 1811 | + foreach ($availability_dates_for_render as $avail_for_cats) { | |
| 1698 | 1812 | if (!empty($avail_for_cats->price_types)) { |
| 1699 | 1813 | $add_category_ids($avail_for_cats->price_types); |
| 1700 | 1814 | } |
| 1701 | 1815 | if (!empty($avail_for_cats->traveler_pricing)) { |
| @@ -1726,8 +1840,9 @@ | ||
| 1726 | 1840 | 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null, |
| 1727 | 1841 | 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null, |
| 1728 | 1842 | 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null, |
| 1729 | 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', | |
| 1730 | 1845 | ]; |
| 1731 | 1846 | } |
| 1732 | 1847 | } |
| 1733 | 1848 | |
| @@ -1777,8 +1892,9 @@ | ||
| 1777 | 1892 | if (!isset($pt['age_min'])) $pt['age_min'] = $meta['age_min']; |
| 1778 | 1893 | if (!isset($pt['age_max'])) $pt['age_max'] = $meta['age_max']; |
| 1779 | 1894 | if (!isset($pt['min_pax'])) $pt['min_pax'] = $meta['min_pax']; |
| 1780 | 1895 | if (!isset($pt['max_pax'])) $pt['max_pax'] = $meta['max_pax']; |
| 1896 | + $pt['group_overflow'] = $meta['group_overflow'] ?? 'block'; | |
| 1781 | 1897 | } |
| 1782 | 1898 | |
| 1783 | 1899 | // Payable amount (honors price / sale_price / discounted_price like TripPricingService) |
| 1784 | 1900 | if (!isset($pt['effective_price'])) { |
| @@ -1804,13 +1920,19 @@ | ||
| 1804 | 1920 | |
| 1805 | 1921 | if (!empty($trip_data->price_types)) { |
| 1806 | 1922 | $trip_data->price_types = $enrich_price_types($trip_data->price_types); |
| 1807 | 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 | + ]); | |
| 1808 | 1930 | |
| 1809 | 1931 | if ($has_availability) { |
| 1810 | 1932 | $current_time = time(); |
| 1811 | - | |
| 1812 | - foreach ($trip_data->availability_dates as $avail) { | |
| 1933 | + | |
| 1934 | + foreach ($availability_dates_for_render as $avail) { | |
| 1813 | 1935 | if (empty($avail->departure_date)) { |
| 1814 | 1936 | // Skip entries without a valid departure date |
| 1815 | 1937 | continue; |
| 1816 | 1938 | } |
| @@ -1844,36 +1966,53 @@ | ||
| 1844 | 1966 | // Store base prices before dynamic pricing |
| 1845 | 1967 | $base_original_price = $original_price; |
| 1846 | 1968 | $base_sale_price = $sale_price; |
| 1847 | 1969 | |
| 1848 | - // 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. | |
| 1849 | 1972 | if (apply_filters('yatra_dynamic_pricing_enabled', false)) { |
| 1850 | 1973 | $dp_context = [ |
| 1851 | 1974 | 'departure_date' => $avail->departure_date ?? null, |
| 1852 | 1975 | 'spots_remaining' => $seats, |
| 1853 | 1976 | 'availability_id' => $avail->id ?? null, |
| 1977 | + 'original_price' => $base_original_price, | |
| 1978 | + 'discounted_price' => $base_sale_price, | |
| 1854 | 1979 | ]; |
| 1855 | - $original_price = apply_filters('yatra_availability_price', $original_price, $trip_data->id, $dp_context); | |
| 1856 | - $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); | |
| 1857 | 1981 | } |
| 1858 | 1982 | |
| 1859 | - // Calculate discount/surge pricing badge | |
| 1860 | - $discount_percent = $cardPricing['discount_percentage']; | |
| 1861 | - $discount_text = ''; | |
| 1862 | - | |
| 1863 | - if ($discount_percent > 0) { | |
| 1864 | - $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 = ''; | |
| 1865 | 1995 | } |
| 1866 | - // Check if dynamic pricing increased the price (surge) | |
| 1867 | - elseif ($base_sale_price > 0 && $sale_price > $base_sale_price) { | |
| 1868 | - $surge_percent = round((($sale_price - $base_sale_price) / $base_sale_price) * 100); | |
| 1869 | - $discount_text = $surge_percent > 0 ? sprintf(__('+%d%%', 'yatra'), $surge_percent) : ''; | |
| 1870 | - } | |
| 1871 | - | |
| 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 | + | |
| 1872 | 2011 | // Use month-based filters for both day trips and multi-day trips for better navigation |
| 1873 | 2012 | // This prevents overwhelming users with too many individual date filters |
| 1874 | 2013 | $month_key = strtolower(date('M-Y', $departure_date)); |
| 1875 | - $month_filters[$month_key] = date('M Y', $departure_date); | |
| 2014 | + $month_filters[$month_key] = date_i18n('M Y', $departure_date); | |
| 1876 | 2015 | |
| 1877 | 2016 | $from_location = !empty($avail->from_location) ? $avail->from_location : ($trip_data->starting_location ?? ''); |
| 1878 | 2017 | $to_location = !empty($avail->to_location) ? $avail->to_location : ($trip_data->ending_location ?? $from_location); |
| 1879 | 2018 | |
| @@ -1880,53 +2019,53 @@ | ||
| 1880 | 2019 | // For day trips, format time; for multi-day trips, format date |
| 1881 | 2020 | $departure_time = !empty($avail->departure_time) ? $avail->departure_time : null; |
| 1882 | 2021 | $arrival_time = !empty($avail->arrival_time) ? $avail->arrival_time : null; |
| 1883 | 2022 | |
| 1884 | - // Format display strings based on trip type | |
| 2023 | + // Format display strings based on trip type (respect Yatra Settings date/time formats) | |
| 2024 | + $yatra_date_format = \Yatra\Services\SettingsService::getString('date_format', 'Y-m-d'); | |
| 2025 | + $yatra_time_format = \Yatra\Services\SettingsService::getString('time_format', 'H:i'); | |
| 2026 | + | |
| 1885 | 2027 | if ($is_single_day && $departure_time) { |
| 1886 | 2028 | // Day trip: Show time as main value, date as sub-label |
| 1887 | - $from_display = date_i18n('g:i A', strtotime($departure_time)); // e.g., "9:00 AM" | |
| 1888 | - $to_display = $arrival_time ? date_i18n('g:i A', strtotime($arrival_time)) : ''; // e.g., "5:00 PM" | |
| 1889 | - $date_display = date_i18n('l, j M Y', $departure_date); // e.g., "Saturday, 30 Nov 2025" | |
| 2029 | + $from_display = date_i18n($yatra_time_format, strtotime($departure_time)); // e.g., "14:30" or "2:30 PM" | |
| 2030 | + $to_display = $arrival_time ? date_i18n($yatra_time_format, strtotime($arrival_time)) : ''; | |
| 2031 | + // Show day-trip header date using configured format | |
| 2032 | + $date_display = date_i18n($yatra_date_format, $departure_date); | |
| 1890 | 2033 | $from_label = __('Start', 'yatra'); |
| 1891 | 2034 | $to_label = __('End', 'yatra'); |
| 1892 | 2035 | } else { |
| 1893 | 2036 | // Multi-day trip: Show dates |
| 1894 | - $from_display = date_i18n('j M Y', $departure_date); | |
| 1895 | - $to_display = date_i18n('j M Y', $return_date); | |
| 2037 | + $from_display = date_i18n($yatra_date_format, $departure_date); | |
| 2038 | + $to_display = date_i18n($yatra_date_format, $return_date); | |
| 1896 | 2039 | $date_display = ''; // Not needed for multi-day |
| 1897 | 2040 | $from_label = __('Departure', 'yatra'); |
| 1898 | 2041 | $to_label = __('Return', 'yatra'); |
| 1899 | 2042 | } |
| 1900 | 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 | + | |
| 1901 | 2058 | // Use month-based keys for filtering for both day trips and multi-day trips |
| 1902 | 2059 | $filter_key = strtolower(date('M-Y', $departure_date)); |
| 1903 | 2060 | |
| 1904 | - // pricing_type MODEL comes from trip level (regular vs traveler_based) | |
| 1905 | - // Note: $avail->pricing_type enum is about price state, not pricing model | |
| 1906 | - $card_pricing_type = $trip_data->pricing_type ?? 'regular'; | |
| 1907 | - if (!empty($avail->price_types) && is_array($avail->price_types) && count($avail->price_types) > 0) { | |
| 1908 | - $card_pricing_type = 'traveler_based'; | |
| 1909 | - } | |
| 1910 | - | |
| 1911 | - // 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']; | |
| 1912 | 2064 | $card_traveler_pricing = []; |
| 1913 | - if (!empty($avail->price_types)) { | |
| 1914 | - $card_traveler_pricing = is_array($avail->price_types) ? $avail->price_types : []; | |
| 1915 | - | |
| 1916 | - // Enrich with category labels if needed | |
| 1917 | - if (!empty($card_traveler_pricing)) { | |
| 1918 | - $card_traveler_pricing = $enrich_price_types($card_traveler_pricing); | |
| 1919 | - } | |
| 1920 | - | |
| 1921 | - // Debug logging | |
| 1922 | - if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 1923 | - error_log('Yatra Card traveler_pricing count: ' . count($card_traveler_pricing)); | |
| 1924 | - if (!empty($card_traveler_pricing[0])) { | |
| 1925 | - $first = is_array($card_traveler_pricing[0]) ? $card_traveler_pricing[0] : (array) $card_traveler_pricing[0]; | |
| 1926 | - error_log('Yatra Card first category: ' . print_r($first, true)); | |
| 1927 | - } | |
| 1928 | - } | |
| 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); | |
| 1929 | 2068 | } |
| 1930 | 2069 | |
| 1931 | 2070 | $availability_cards[] = [ |
| 1932 | 2071 | 'id' => $avail->id, |
| @@ -1936,8 +2075,9 @@ | ||
| 1936 | 2075 | 'to_label' => $to_label, |
| 1937 | 2076 | 'to_date' => $to_display, |
| 1938 | 2077 | 'to_location' => $to_location, |
| 1939 | 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 | |
| 1940 | 2080 | 'date' => $avail->departure_date, // Raw date for dynamic pricing |
| 1941 | 2081 | 'spots_remaining' => $seats, // For dynamic pricing |
| 1942 | 2082 | 'seats' => $seats > 10 ? '10+' : (string) $seats, |
| 1943 | 2083 | 'seats_available' => $seats, |
| @@ -1960,11 +2100,13 @@ | ||
| 1960 | 2100 | 'is_sold_out' => $is_sold_out, |
| 1961 | 2101 | // Card-specific pricing |
| 1962 | 2102 | 'pricing_type' => $card_pricing_type, |
| 1963 | 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'), | |
| 1964 | 2106 | 'is_recurring' => !empty($avail->is_recurring), |
| 1965 | 2107 | 'rule_id' => $avail->rule_id ?? null, |
| 1966 | - ]; | |
| 2108 | + ] + $dp_card_fields; | |
| 1967 | 2109 | } |
| 1968 | 2110 | } |
| 1969 | 2111 | |
| 1970 | 2112 | // Use sample data only if no real availability |
| @@ -1977,33 +2119,44 @@ | ||
| 1977 | 2119 | // Store base prices before dynamic pricing |
| 1978 | 2120 | $base_sample_original = $sample_original; |
| 1979 | 2121 | $base_sample_sale = $sample_sale; |
| 1980 | 2122 | |
| 1981 | - // Apply dynamic pricing to sample card | |
| 2123 | + // Apply dynamic pricing to sample card (sale line only; list price unchanged for display) | |
| 1982 | 2124 | if (apply_filters('yatra_dynamic_pricing_enabled', false)) { |
| 1983 | - $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, [ | |
| 1984 | 2126 | 'departure_date' => $sample_date, |
| 1985 | 2127 | 'spots_remaining' => $sample_seats, |
| 1986 | 2128 | 'availability_id' => 'sample-1', |
| 2129 | + 'original_price' => $base_sample_original, | |
| 2130 | + 'discounted_price' => $base_sample_sale, | |
| 1987 | 2131 | ]); |
| 1988 | - $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 | + [ | |
| 1989 | 2149 | 'departure_date' => $sample_date, |
| 1990 | 2150 | 'spots_remaining' => $sample_seats, |
| 1991 | 2151 | 'availability_id' => 'sample-1', |
| 1992 | - ]); | |
| 1993 | - } | |
| 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 | + ); | |
| 1994 | 2158 | |
| 1995 | - // Calculate discount/surge pricing badge for sample card | |
| 1996 | - $sample_discount_text = ''; | |
| 1997 | - if ($base_sample_original > 0 && $base_sample_sale < $base_sample_original) { | |
| 1998 | - $discount_percent = round((($base_sample_original - $base_sample_sale) / $base_sample_original) * 100); | |
| 1999 | - $sample_discount_text = $discount_percent > 0 ? sprintf(__('%d%% OFF', 'yatra'), $discount_percent) : ''; | |
| 2000 | - } | |
| 2001 | - elseif ($base_sample_sale > 0 && $sample_sale > $base_sample_sale) { | |
| 2002 | - $surge_percent = round((($sample_sale - $base_sample_sale) / $base_sample_sale) * 100); | |
| 2003 | - $sample_discount_text = $surge_percent > 0 ? sprintf(__('+%d%%', 'yatra'), $surge_percent) : ''; | |
| 2004 | - } | |
| 2005 | - | |
| 2006 | 2159 | $availability_cards = [ |
| 2007 | 2160 | [ |
| 2008 | 2161 | 'id' => 'sample-1', |
| 2009 | 2162 | 'from_label' => __('Departure', 'yatra'), |
| @@ -2031,11 +2184,11 @@ | ||
| 2031 | 2184 | 'pricing_type' => $trip_data->pricing_type ?? 'regular', |
| 2032 | 2185 | 'traveler_pricing' => $trip_data->price_types ?? [], |
| 2033 | 2186 | 'is_recurring' => false, |
| 2034 | 2187 | 'rule_id' => null, |
| 2035 | - ], | |
| 2188 | + ] + $sample_dp_fields, | |
| 2036 | 2189 | ]; |
| 2037 | - $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')); | |
| 2038 | 2191 | } |
| 2039 | 2192 | |
| 2040 | 2193 | $sorted_cards = $this->sortAvailabilityCards($availability_cards, $sort_key); |
| 2041 | 2194 | |
| @@ -2088,8 +2241,96 @@ | ||
| 2088 | 2241 | |
| 2089 | 2242 | return $slice; |
| 2090 | 2243 | } |
| 2091 | 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 | + | |
| 2092 | 2333 | private function sortAvailabilityCards(array $cards, string $sort_key): array |
| 2093 | 2334 | { |
| 2094 | 2335 | $sort_key = sanitize_text_field($sort_key); |
| 2095 | 2336 | |
| @@ -2212,11 +2453,18 @@ | ||
| 2212 | 2453 | } |
| 2213 | 2454 | |
| 2214 | 2455 | // Apply dynamic pricing |
| 2215 | 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 | + } | |
| 2216 | 2462 | $price = apply_filters('yatra_availability_price', $price, $trip_id, [ |
| 2217 | 2463 | 'departure_date' => $date, |
| 2218 | 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, | |
| 2219 | 2467 | ]); |
| 2220 | 2468 | } |
| 2221 | 2469 | |
| 2222 | 2470 | $pt->effective_price = $price; |