| @@ -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 | { |
| @@ -888,10 +942,15 @@ | ||
| 888 | 942 | */ |
| 889 | 943 | public function restore_revision(WP_REST_Request $request) |
| 890 | 944 | { |
| 891 | 945 | try { |
| 892 | - // Check permissions | |
| 893 | - 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 | + ) { | |
| 894 | 953 | return $this->error_response(__('You do not have permission to restore revisions', 'yatra'), 403); |
| 895 | 954 | } |
| 896 | 955 | |
| 897 | 956 | $id = (int) $request->get_param('id'); |
| @@ -998,8 +1057,9 @@ | ||
| 998 | 1057 | 'transportation_included', |
| 999 | 1058 | 'international_flights_included', |
| 1000 | 1059 | 'domestic_flights_included', |
| 1001 | 1060 | 'is_featured', |
| 1061 | + 'has_default_time_slots', | |
| 1002 | 1062 | ]; |
| 1003 | 1063 | |
| 1004 | 1064 | foreach ($booleanFields as $field) { |
| 1005 | 1065 | if (isset($data[$field])) { |
| @@ -1269,8 +1329,20 @@ | ||
| 1269 | 1329 | $images = is_array($decoded) ? $decoded : []; |
| 1270 | 1330 | } |
| 1271 | 1331 | } |
| 1272 | 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 | + | |
| 1273 | 1345 | return [ |
| 1274 | 1346 | 'id' => isset($entry->id) ? (int) $entry->id : null, |
| 1275 | 1347 | 'day_id' => isset($entry->day_id) ? (int) $entry->day_id : null, |
| 1276 | 1348 | 'time' => $entry->time ?? '', |
| @@ -1279,8 +1351,16 @@ | ||
| 1279 | 1351 | 'time_type' => $entry->time_type ?? 'exact', |
| 1280 | 1352 | 'title' => $entry->title ?? '', |
| 1281 | 1353 | 'description' => $entry->description ?? '', |
| 1282 | 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, | |
| 1283 | 1363 | 'duration' => $entry->duration ?? '', |
| 1284 | 1364 | 'cost' => isset($entry->cost) ? (float) $entry->cost : null, |
| 1285 | 1365 | 'cost_per_person' => isset($entry->cost_per_person) ? (bool) $entry->cost_per_person : false, |
| 1286 | 1366 | 'notes' => $entry->notes ?? '', |
| @@ -1286,8 +1366,11 @@ | ||
| 1286 | 1366 | 'notes' => $entry->notes ?? '', |
| 1287 | 1367 | 'item_type_id' => isset($entry->item_type_id) ? (int) $entry->item_type_id : null, |
| 1288 | 1368 | 'item_id' => isset($entry->item_id) ? (int) $entry->item_id : null, |
| 1289 | 1369 | 'status' => $entry->status ?? 'active', |
| 1370 | + 'order' => isset($entry->order) ? (int) $entry->order : 0, | |
| 1371 | + 'gallery' => $gallery, | |
| 1372 | + 'video_url' => $entry->video_url ?? '', | |
| 1290 | 1373 | 'created_at' => $entry->created_at ?? '', |
| 1291 | 1374 | 'updated_at' => $entry->updated_at ?? '', |
| 1292 | 1375 | 'included_items' => $includedItems, |
| 1293 | 1376 | 'excluded_items' => $excludedItems, |
| @@ -1432,13 +1515,15 @@ | ||
| 1432 | 1515 | |
| 1433 | 1516 | // Fetch availability dates using centralized resolution service |
| 1434 | 1517 | $resolutionService = new \Yatra\Services\AvailabilityResolutionService(); |
| 1435 | 1518 | |
| 1436 | - // 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). | |
| 1437 | 1522 | $fromDate = date('Y-m-d'); |
| 1438 | - $toDate = date('Y-m-d', strtotime('+12 months')); | |
| 1523 | + $toDate = yatra_get_availability_horizon_date($fromDate); | |
| 1439 | 1524 | |
| 1440 | - $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate); | |
| 1525 | + $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate, \Yatra\Services\SettingsService::isEnabled('show_sold_out')); | |
| 1441 | 1526 | |
| 1442 | 1527 | // Determine if this is a day trip |
| 1443 | 1528 | $is_single_day = ($trip->duration_days ?? 1) <= 1; |
| 1444 | 1529 | |
| @@ -1510,8 +1595,11 @@ | ||
| 1510 | 1595 | 'discounted_price' => isset($trip->discounted_price) ? (float) $trip->discounted_price : 0, |
| 1511 | 1596 | 'sale_price' => isset($trip->sale_price) ? (float) $trip->sale_price : 0, |
| 1512 | 1597 | 'currency' => SettingsService::getCurrency(), |
| 1513 | 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, | |
| 1514 | 1602 | 'max_travelers' => isset($trip->max_travelers) ? (int) $trip->max_travelers : 20, |
| 1515 | 1603 | 'min_travelers' => isset($trip->min_travelers) ? (int) $trip->min_travelers : 1, |
| 1516 | 1604 | 'pricing_type' => $trip->pricing_type ?? 'regular', |
| 1517 | 1605 | 'price_types' => $trip->price_types ?? [], // Include price_types for traveler-based pricing |
| @@ -1651,8 +1739,36 @@ | ||
| 1651 | 1739 | |
| 1652 | 1740 | // Build cards from real availability data or use sample data |
| 1653 | 1741 | $availability_cards = []; |
| 1654 | 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 | + } | |
| 1655 | 1771 | |
| 1656 | 1772 | // Determine if this is a day trip (duration <= 1 day) |
| 1657 | 1773 | $is_single_day = ($trip_data->duration_days ?? 1) <= 1; |
| 1658 | 1774 | |
| @@ -1691,9 +1807,9 @@ | ||
| 1691 | 1807 | $add_category_ids($trip_data->price_types); |
| 1692 | 1808 | } |
| 1693 | 1809 | |
| 1694 | 1810 | if ($has_availability) { |
| 1695 | - foreach ($trip_data->availability_dates as $avail_for_cats) { | |
| 1811 | + foreach ($availability_dates_for_render as $avail_for_cats) { | |
| 1696 | 1812 | if (!empty($avail_for_cats->price_types)) { |
| 1697 | 1813 | $add_category_ids($avail_for_cats->price_types); |
| 1698 | 1814 | } |
| 1699 | 1815 | if (!empty($avail_for_cats->traveler_pricing)) { |
| @@ -1724,8 +1840,9 @@ | ||
| 1724 | 1840 | 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null, |
| 1725 | 1841 | 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null, |
| 1726 | 1842 | 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null, |
| 1727 | 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', | |
| 1728 | 1845 | ]; |
| 1729 | 1846 | } |
| 1730 | 1847 | } |
| 1731 | 1848 | |
| @@ -1775,8 +1892,9 @@ | ||
| 1775 | 1892 | if (!isset($pt['age_min'])) $pt['age_min'] = $meta['age_min']; |
| 1776 | 1893 | if (!isset($pt['age_max'])) $pt['age_max'] = $meta['age_max']; |
| 1777 | 1894 | if (!isset($pt['min_pax'])) $pt['min_pax'] = $meta['min_pax']; |
| 1778 | 1895 | if (!isset($pt['max_pax'])) $pt['max_pax'] = $meta['max_pax']; |
| 1896 | + $pt['group_overflow'] = $meta['group_overflow'] ?? 'block'; | |
| 1779 | 1897 | } |
| 1780 | 1898 | |
| 1781 | 1899 | // Payable amount (honors price / sale_price / discounted_price like TripPricingService) |
| 1782 | 1900 | if (!isset($pt['effective_price'])) { |
| @@ -1812,9 +1930,9 @@ | ||
| 1812 | 1930 | |
| 1813 | 1931 | if ($has_availability) { |
| 1814 | 1932 | $current_time = time(); |
| 1815 | 1933 | |
| 1816 | - foreach ($trip_data->availability_dates as $avail) { | |
| 1934 | + foreach ($availability_dates_for_render as $avail) { | |
| 1817 | 1935 | if (empty($avail->departure_date)) { |
| 1818 | 1936 | // Skip entries without a valid departure date |
| 1819 | 1937 | continue; |
| 1820 | 1938 | } |
| @@ -1892,9 +2010,9 @@ | ||
| 1892 | 2010 | |
| 1893 | 2011 | // Use month-based filters for both day trips and multi-day trips for better navigation |
| 1894 | 2012 | // This prevents overwhelming users with too many individual date filters |
| 1895 | 2013 | $month_key = strtolower(date('M-Y', $departure_date)); |
| 1896 | - $month_filters[$month_key] = date('M Y', $departure_date); | |
| 2014 | + $month_filters[$month_key] = date_i18n('M Y', $departure_date); | |
| 1897 | 2015 | |
| 1898 | 2016 | $from_location = !empty($avail->from_location) ? $avail->from_location : ($trip_data->starting_location ?? ''); |
| 1899 | 2017 | $to_location = !empty($avail->to_location) ? $avail->to_location : ($trip_data->ending_location ?? $from_location); |
| 1900 | 2018 | |
| @@ -1922,8 +2040,22 @@ | ||
| 1922 | 2040 | $from_label = __('Departure', 'yatra'); |
| 1923 | 2041 | $to_label = __('Return', 'yatra'); |
| 1924 | 2042 | } |
| 1925 | 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 | + | |
| 1926 | 2058 | // Use month-based keys for filtering for both day trips and multi-day trips |
| 1927 | 2059 | $filter_key = strtolower(date('M-Y', $departure_date)); |
| 1928 | 2060 | |
| 1929 | 2061 | // Must match {@see TripPricingService::resolveCardPricing}: trip-level mode wins; do not |
| @@ -1943,8 +2075,9 @@ | ||
| 1943 | 2075 | 'to_label' => $to_label, |
| 1944 | 2076 | 'to_date' => $to_display, |
| 1945 | 2077 | 'to_location' => $to_location, |
| 1946 | 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 | |
| 1947 | 2080 | 'date' => $avail->departure_date, // Raw date for dynamic pricing |
| 1948 | 2081 | 'spots_remaining' => $seats, // For dynamic pricing |
| 1949 | 2082 | 'seats' => $seats > 10 ? '10+' : (string) $seats, |
| 1950 | 2083 | 'seats_available' => $seats, |
| @@ -1967,8 +2100,10 @@ | ||
| 1967 | 2100 | 'is_sold_out' => $is_sold_out, |
| 1968 | 2101 | // Card-specific pricing |
| 1969 | 2102 | 'pricing_type' => $card_pricing_type, |
| 1970 | 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'), | |
| 1971 | 2106 | 'is_recurring' => !empty($avail->is_recurring), |
| 1972 | 2107 | 'rule_id' => $avail->rule_id ?? null, |
| 1973 | 2108 | ] + $dp_card_fields; |
| 1974 | 2109 | } |
| @@ -2051,9 +2186,9 @@ | ||
| 2051 | 2186 | 'is_recurring' => false, |
| 2052 | 2187 | 'rule_id' => null, |
| 2053 | 2188 | ] + $sample_dp_fields, |
| 2054 | 2189 | ]; |
| 2055 | - $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')); | |
| 2056 | 2191 | } |
| 2057 | 2192 | |
| 2058 | 2193 | $sorted_cards = $this->sortAvailabilityCards($availability_cards, $sort_key); |
| 2059 | 2194 | |
| @@ -2131,8 +2266,9 @@ | ||
| 2131 | 2266 | |
| 2132 | 2267 | if ($dynamic_pricing_enabled && $B > $eps && $F > $B + $eps) { |
| 2133 | 2268 | $p = (int) round((($F - $B) / $B) * 100); |
| 2134 | 2269 | |
| 2270 | + /* translators: %d: dynamic pricing increase percentage. */ | |
| 2135 | 2271 | return $p > 0 ? sprintf(__('+%d%%', 'yatra'), $p) : ''; |
| 2136 | 2272 | } |
| 2137 | 2273 | |
| 2138 | 2274 | if ($O > $eps && $F < $O - $eps) { |
| @@ -2137,8 +2273,9 @@ | ||
| 2137 | 2273 | |
| 2138 | 2274 | if ($O > $eps && $F < $O - $eps) { |
| 2139 | 2275 | $p = (int) round((($O - $F) / $O) * 100); |
| 2140 | 2276 | |
| 2277 | + /* translators: %d: discount percentage. */ | |
| 2141 | 2278 | return $p > 0 ? sprintf(__('%d%% OFF', 'yatra'), $p) : ''; |
| 2142 | 2279 | } |
| 2143 | 2280 | |
| 2144 | 2281 | if ($O <= $eps && $B > $eps && $F < $B - $eps) { |
| @@ -2143,8 +2280,9 @@ | ||
| 2143 | 2280 | |
| 2144 | 2281 | if ($O <= $eps && $B > $eps && $F < $B - $eps) { |
| 2145 | 2282 | $p = (int) round((($B - $F) / $B) * 100); |
| 2146 | 2283 | |
| 2284 | + /* translators: %d: discount percentage. */ | |
| 2147 | 2285 | return $p > 0 ? sprintf(__('%d%% OFF', 'yatra'), $p) : ''; |
| 2148 | 2286 | } |
| 2149 | 2287 | |
| 2150 | 2288 | return ''; |