PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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 2.0.10 2.0.11 All 82 releases
yatra / app / Controllers / TripController.php

TripController.php in Yatra – Travel Booking & Tour Operator Software 3.0.4, at app/Controllers/TripController.php

2,577 lines 108.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_Error;
10 use Yatra\Repositories\RecurringAvailabilityRepository;
11 use Yatra\Services\RecurringAvailabilityService;
12 use Yatra\Database\Tables\TripsTable;
13 use Yatra\Services\TripService;
14 use Yatra\Repositories\TripRevisionRepository;
15 use Yatra\Repositories\ItemTypeRepository;
16 use Yatra\Repositories\ItemRepository;
17 use Yatra\Repositories\TravelerCategoryRepository;
18 use Yatra\Models\Trip;
19 use Yatra\Validators\TripValidator;
20 use Yatra\Exceptions\TripNotFoundException;
21 use Yatra\Services\SettingsService;
22 use Yatra\Exceptions\ValidationException;
23 use Yatra\Database\Tables\TripAvailabilityDatesTable;
24 use Yatra\Services\TripPricingService;
25
26 /**
27 * Trip REST API Controller
28 * Comprehensive API endpoints for trip management
29 *
30 * Expert-level controller design:
31 * - Full field support
32 * - Relationship handling
33 * - Proper data transformation
34 * - Error handling
35 */
36 class TripController extends BaseController
37 {
38
39 /**
40 * @var TripService
41 */
42 private TripService $service;
43
44 /**
45 * Constructor
46 */
47 public function __construct()
48 {
49 $this->service = new TripService();
50 }
51
52 /**
53 * Register routes
54 */
55 public function register_routes(): void
56 {
57 $namespace = 'yatra/v1';
58 $base = 'trips';
59
60
61 register_rest_route($namespace, '/' . $base, [
62 [
63 'methods' => \WP_REST_Server::READABLE,
64 'callback' => [$this, 'get_items'],
65 'permission_callback' => [$this, 'check_read_permission'],
66 ],
67 [
68 'methods' => \WP_REST_Server::CREATABLE,
69 'callback' => [$this, 'create_item'],
70 'permission_callback' => [$this, 'check_permission'],
71 ],
72 ]);
73
74 // Duplicate trip: POST /trips/{id}/duplicate
75 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/duplicate', [
76 [
77 'methods' => \WP_REST_Server::CREATABLE,
78 'callback' => [$this, 'duplicate_item'],
79 'permission_callback' => [$this, 'check_permission'],
80 ],
81 ]);
82
83 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)', [
84 [
85 'methods' => \WP_REST_Server::READABLE,
86 'callback' => [$this, 'get_item'],
87 'permission_callback' => [$this, 'check_read_permission'],
88 ],
89 [
90 'methods' => \WP_REST_Server::EDITABLE,
91 'callback' => [$this, 'update_item'],
92 'permission_callback' => [$this, 'check_permission'],
93 ],
94 [
95 'methods' => \WP_REST_Server::DELETABLE,
96 'callback' => [$this, 'delete_item'],
97 'permission_callback' => [$this, 'check_permission'],
98 ],
99 ]);
100
101 // Permanent delete endpoint
102 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/permanent-delete', [
103 [
104 'methods' => \WP_REST_Server::DELETABLE,
105 'callback' => [$this, 'permanent_delete_item'],
106 'permission_callback' => [$this, 'check_permission'],
107 ],
108 ]);
109
110 // Search endpoint
111 register_rest_route($namespace, '/' . $base . '/search', [
112 [
113 'methods' => \WP_REST_Server::READABLE,
114 'callback' => [$this, 'search_items'],
115 'permission_callback' => [$this, 'check_read_permission'],
116 ],
117 ]);
118
119 // Revisions endpoints
120 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/revisions', [
121 [
122 'methods' => \WP_REST_Server::READABLE,
123 'callback' => [$this, 'get_revisions'],
124 'permission_callback' => [$this, 'check_read_permission'],
125 ],
126 ]);
127
128 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/revisions/(?P<revision_id>[\d]+)', [
129 [
130 'methods' => \WP_REST_Server::READABLE,
131 'callback' => [$this, 'get_revision'],
132 'permission_callback' => [$this, 'check_read_permission'],
133 ],
134 [
135 'methods' => \WP_REST_Server::EDITABLE,
136 'callback' => [$this, 'restore_revision'],
137 'permission_callback' => [$this, 'check_permission'],
138 ],
139 ]);
140
141 // Availability template endpoint (public, no auth required)
142 // Register BEFORE the generic /trips/{id} route to ensure it matches first
143 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/availability-template', [
144 [
145 'methods' => \WP_REST_Server::READABLE,
146 'callback' => [$this, 'get_availability_template'],
147 'permission_callback' => '__return_true', // Public endpoint
148 ],
149 ]);
150
151 // Date-specific pricing endpoint (public, no auth required)
152 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/date-pricing', [
153 [
154 'methods' => \WP_REST_Server::READABLE,
155 'callback' => [$this, 'get_date_pricing'],
156 'permission_callback' => '__return_true', // Public endpoint
157 ],
158 ]);
159
160 // Public endpoint for frontend trip listings
161 register_rest_route($namespace, '/' . $base . '/public', [
162 [
163 'methods' => \WP_REST_Server::READABLE,
164 'callback' => [$this, 'get_public_trips'],
165 'permission_callback' => '__return_true', // Public endpoint
166 ],
167 ]);
168
169 // Status statistics for admin views
170 register_rest_route($namespace, '/' . $base . '/stats', [
171 [
172 'methods' => \WP_REST_Server::READABLE,
173 'callback' => [$this, 'getStats'],
174 'permission_callback' => [$this, 'check_permission'],
175 ],
176 ]);
177
178 // Trip attributes endpoints (admin only — never expose unauthenticated read/write)
179 register_rest_route($namespace, '/' . $base . '/test', [
180 'methods' => \WP_REST_Server::READABLE,
181 'callback' => [$this, 'test_endpoint'],
182 'permission_callback' => [$this, 'check_permission'],
183 ]);
184
185 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/attributes', [
186 [
187 'methods' => \WP_REST_Server::READABLE,
188 'callback' => [$this, 'get_trip_attributes'],
189 'permission_callback' => [$this, 'check_read_permission'],
190 ],
191 [
192 'methods' => \WP_REST_Server::CREATABLE,
193 'callback' => [$this, 'update_trip_attributes'],
194 'permission_callback' => [$this, 'check_permission'],
195 ],
196 ]);
197
198 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/attributes/(?P<attribute_id>[\d]+)', [
199 [
200 'methods' => \WP_REST_Server::DELETABLE,
201 'callback' => [$this, 'delete_trip_attribute'],
202 'permission_callback' => [$this, 'check_permission'],
203 ],
204 ]);
205 }
206
207 /**
208 * Get statistics for admin trip views (status counts)
209 */
210 public function getStats(WP_REST_Request $request)
211 {
212 try {
213 $stats = $this->service->getStatusCounts();
214 return $this->success_response($stats);
215 } catch (\Exception $e) {
216 return $this->error_response($e->getMessage(), 500);
217 }
218 }
219
220 /**
221 * Duplicate trip
222 *
223 * Endpoint: POST /trips/{id}/duplicate
224 */
225 public function duplicate_item(WP_REST_Request $request)
226 {
227 try {
228 $id = (int) $request->get_param('id');
229
230 if ($id <= 0) {
231 return $this->error_response(__('Invalid trip ID', 'yatra'), 400);
232 }
233
234 $newId = $this->service->duplicate($id);
235
236 return $this->success_response([
237 'message' => __('Trip duplicated as draft', 'yatra'),
238 'id' => $newId,
239 ]);
240 } catch (\InvalidArgumentException $e) {
241 return $this->error_response($e->getMessage(), 400);
242 } catch (\Exception $e) {
243 return $this->error_response($e->getMessage(), 500);
244 }
245 }
246
247 /**
248 * Get items
249 */
250 public function get_items(WP_REST_Request $request)
251 {
252 try {
253 // For admin listing, show more items by default to see all trips
254 $default_limit = 20; // Increased from 10 to show all trips
255 $orderbyRaw = $request->get_param('orderby') ?: 'id';
256 // UI sends "price"; the trips table has sale_price (no "price" column).
257 if ($orderbyRaw === 'price') {
258 $orderbyRaw = 'sale_price';
259 }
260
261 $args = [
262 'limit' => (int) ($request->get_param('per_page') ?: $default_limit),
263 'offset' => ((int) ($request->get_param('page') ?: 1) - 1) * (int) ($request->get_param('per_page') ?: $default_limit),
264 'order_by' => $orderbyRaw,
265 'order' => strtoupper($request->get_param('order') ?: 'DESC'),
266 ];
267
268
269 // Add status filter
270 $status = $request->get_param('status');
271 if ($status && $status !== 'all') {
272 $args['where']['status'] = $status;
273
274 }
275
276 // Add search
277 $search = $request->get_param('search');
278 if ($search) {
279 $items = $this->service->search($search, $args);
280 $total = count($items);
281
282 } else {
283 // For admin listing, include all trips regardless of status or soft delete
284 $args['include_deleted'] = true;
285 // Must not use BaseService::getAll() — it caches list results while count() does not, which broke the admin grid.
286 $items = $this->service->getAllForAdminList($args);
287 $total = $this->service->count($args);
288
289 }
290
291 // Ensure traveler-based pricing trips have a usable base price in list view
292 if (!empty($items)) {
293
294 foreach ($items as $item) {
295 // Skip if we already have a flat price set
296 $flatSale = isset($item->sale_price) ? (float) $item->sale_price : 0.0;
297 $flatDisc = isset($item->discounted_price) ? (float) $item->discounted_price : 0.0;
298 $flatOrig = isset($item->original_price) ? (float) $item->original_price : 0.0;
299 $hasFlatAny = ($flatSale > 0) || ($flatDisc > 0) || ($flatOrig > 0);
300
301 if ($hasFlatAny) {
302 continue;
303 }
304
305 // Only compute for traveler-based pricing trips
306 $pricingType = $item->pricing_type ?? '';
307 if ($pricingType !== 'traveler_based') {
308 continue;
309 }
310
311 // Find the lowest and highest non-zero price across discount/original in price types
312 $tripService = new \Yatra\Services\TripService();
313 $priceRange = $tripService->getTripPriceRange((int) $item->id);
314
315 if ($priceRange['min_price'] > 0 || $priceRange['max_price'] > 0) {
316 $minPrice = $priceRange['min_price'];
317 $maxPrice = $priceRange['max_price'];
318
319 if ($minPrice > 0) {
320 // Use min price as effective sale_price for list display
321 $item->sale_price = $minPrice;
322 $item->traveler_min_price = $minPrice;
323 }
324 if ($maxPrice > 0) {
325 $item->traveler_max_price = $maxPrice;
326 }
327 }
328 }
329
330 // Hydrate lightweight relationships for list view (destinations, activities, categories)
331 $tripIds = array_map(static function ($item) {
332 return isset($item->id) ? (int) $item->id : 0;
333 }, $items);
334 $tripIds = array_values(array_filter($tripIds));
335
336 if (!empty($tripIds)) {
337 // Attach bookings_count computed from bookings table (trips.bookings_count is not reliably maintained)
338 $bookingsCountMap = $this->service->getBookingsCountMap($tripIds);
339 foreach ($items as $item) {
340 $tId = isset($item->id) ? (int) $item->id : 0;
341 if ($tId > 0) {
342 $item->bookings_count = (int) ($bookingsCountMap[$tId] ?? 0);
343 }
344 }
345
346 // Destinations
347 $destByTrip = [];
348 foreach ($tripIds as $id) {
349 $destinations = $this->service->getTripDestinations($id);
350 $destByTrip[$id] = [];
351
352 foreach ($destinations as $destination) {
353 $destByTrip[$id][] = (object) [
354 'id' => (int) ($destination->classification_id ?? 0),
355 'name' => $destination->name ?? '',
356 'slug' => $destination->slug ?? '',
357 'debug_classification_id' => $destination->classification_id,
358 'debug_trip_id' => $destination->trip_id,
359 ];
360 }
361 }
362
363 // Use TripService to get activities
364 $actRows = [];
365 foreach ($tripIds as $id) {
366 $activities = $this->service->getTripActivities($id);
367 $actRows = array_merge($actRows, $activities);
368 }
369
370 $actByTrip = [];
371 foreach ($actRows as $row) {
372 $tId = (int) $row->trip_id;
373 if (!isset($actByTrip[$tId])) {
374 $actByTrip[$tId] = [];
375 }
376 $actByTrip[$tId][] = (object) [
377 'id' => (int) $row->id,
378 'name' => $row->name,
379 'slug' => $row->slug,
380 ];
381 }
382
383 // Use TripService to get categories
384 $catByTrip = [];
385 foreach ($tripIds as $id) {
386 $categories = $this->service->getTripCategories($id);
387 $catByTrip[$id] = [];
388
389 foreach ($categories as $category) {
390 $catByTrip[$id][] = (object) [
391 'id' => (int) ($category->classification_id ?? 0),
392 'name' => $category->category_name ?? '',
393 'slug' => $category->category_slug ?? '',
394 ];
395 }
396 }
397
398 // Attach grouped relations back to items so prepare_item_for_response can format them
399 foreach ($items as $item) {
400 $id = isset($item->id) ? (int) $item->id : 0;
401 if ($id <= 0) {
402 continue;
403 }
404 if (isset($destByTrip[$id])) {
405 $item->destinations = $destByTrip[$id];
406 }
407 if (isset($actByTrip[$id])) {
408 $item->activity_types = $actByTrip[$id];
409 }
410 if (isset($catByTrip[$id])) {
411 $item->trip_category = $catByTrip[$id];
412 }
413 }
414 }
415 }
416
417 // Check if itinerary meta is requested (for Itinerary page)
418 $include_meta = $request->get_param('include_itinerary_meta');
419 $meta = [];
420
421 if ($include_meta) {
422 // Get available item types for itinerary mapping
423 $itemTypeRepo = new ItemTypeRepository();
424 $itemTypes = $itemTypeRepo->all(['where' => ['status' => 'publish']]);
425 $meta['available_item_types'] = array_map(function ($type) {
426 $iconData = maybe_unserialize($type->icon ?? '');
427 $iconValue = '';
428 if (is_array($iconData) && isset($iconData['value'])) {
429 $iconValue = $iconData['value'];
430 } elseif (is_string($type->icon)) {
431 $iconValue = $type->icon;
432 }
433
434 return [
435 'id' => (int) $type->id,
436 'name' => esc_html($type->name),
437 'icon' => $iconValue,
438 'color' => $type->color ?? 'gray',
439 ];
440 }, $itemTypes);
441
442 // Get available items for itinerary mapping
443 $itemRepo = new ItemRepository();
444 $allItems = $itemRepo->all(['where' => ['status' => 'publish']]);
445
446 foreach ($allItems as $item) {
447 }
448
449 $meta['available_items'] = array_map(function ($item) {
450 // Items use parent_id to link to their item type (not type_id)
451 $mappedItem = [
452 'id' => (int) $item->id,
453 'name' => esc_html($item->name),
454 'type_id' => (int) ($item->parent_id ?? 0), // parent_id is the item type ID
455 ];
456 return $mappedItem;
457 }, $allItems);
458
459 }
460
461 $response = [
462 'data' => $this->prepare_collection_for_response($items, $request),
463 'total' => $total,
464 'page' => (int) ($request->get_param('page') ?: 1),
465 'per_page' => $args['limit'], // Use the actual limit from args
466 ];
467
468
469
470 if (!empty($meta)) {
471 $response['meta'] = $meta;
472 }
473
474 return $this->success_response($response);
475 } catch (\Exception $e) {
476 return $this->error_response($e->getMessage(), 500);
477 }
478 }
479
480 /**
481 * Get single item
482 */
483 public function get_item(WP_REST_Request $request)
484 {
485 try {
486 $id = (int) $request->get_param('id');
487
488 if ($id <= 0) {
489 throw new ValidationException('Invalid trip ID', ['id' => ['Trip ID must be a positive integer']]);
490 }
491
492 // For editing, include deleted items so admins can edit trips in trash
493 $item = $this->service->getWithRelations($id, true);
494
495 if (!$item) {
496 return $this->error_response('Trip not found', 404);
497 }
498
499 return $this->success_response($this->prepare_item_for_response($item, $request));
500 } catch (\Exception $e) {
501 return $this->handle_exception($e);
502 }
503 }
504
505 /**
506 * Create item
507 */
508 public function create_item(WP_REST_Request $request)
509 {
510 try {
511 $rawData = $request->get_json_params();
512 $rawData = apply_filters('yatra_trip_create_raw_data', $rawData, $request);
513
514 // Map old field names to new table schema
515 if (isset($rawData['booking_deadline'])) {
516 $rawData['booking_deadline_hours'] = is_numeric($rawData['booking_deadline']) ? (int) $rawData['booking_deadline'] : 24;
517 unset($rawData['booking_deadline']);
518 }
519
520 // Validate and sanitize input data
521 TripValidator::validateCreate($rawData);
522 $data = TripValidator::sanitize($rawData);
523
524 // Ensure JSON fields stay in main data (not relationships)
525 if (isset($rawData['included_items'])) {
526 $data['included_items'] = wp_json_encode($rawData['included_items']);
527 }
528 if (isset($rawData['excluded_items'])) {
529 $data['excluded_items'] = wp_json_encode($rawData['excluded_items']);
530 }
531 if (isset($rawData['frontend_tabs'])) {
532 $data['frontend_tabs'] = wp_json_encode($rawData['frontend_tabs']);
533 }
534 if (isset($rawData['default_time_slots'])) {
535 $data['default_time_slots'] = wp_json_encode($rawData['default_time_slots']);
536 }
537
538 // Handle featured_priority field
539 if (isset($rawData['featured_priority'])) {
540 $data['featured_priority'] = $rawData['featured_priority'];
541 }
542 // Remove legacy/removed columns not present in trips table
543 foreach (['currency', 'testimonials', 'countries', 'regions', 'tags'] as $deprecatedKey) {
544 if (isset($data[$deprecatedKey])) {
545 unset($data[$deprecatedKey]);
546 }
547 }
548 $data = apply_filters('yatra_trip_create_sanitized_data', $data, $rawData, $request);
549
550 // Extract relationships (fields stored in separate tables)
551 $relationships = [
552 'destinations' => $rawData['destinations'] ?? [],
553 'activities' => $rawData['activity_types'] ?? [],
554 'trip_category' => $rawData['trip_category'] ?? [],
555 'price_types' => $rawData['price_types'] ?? [],
556 'highlights' => $rawData['highlights'] ?? [],
557 'gallery_images' => $rawData['gallery_images'] ?? [],
558 'faqs' => $rawData['faqs'] ?? [],
559 'downloadable_items' => $rawData['downloadable_items'] ?? [],
560 'itinerary_days' => $rawData['itinerary_days'] ?? [],
561 'availability_dates' => $rawData['availability_dates'] ?? [],
562 ];
563
564 $relationships = apply_filters('yatra_trip_create_relationships', $relationships, $rawData, $request);
565
566 $extraUnsetKeys = apply_filters('yatra_trip_create_unset_keys', [], $rawData, $relationships, $request);
567 if (is_array($extraUnsetKeys) && !empty($extraUnsetKeys)) {
568 foreach ($extraUnsetKeys as $key) {
569 if (is_string($key) && isset($rawData[$key])) {
570 unset($rawData[$key]);
571 }
572 if (is_string($key) && isset($data[$key])) {
573 unset($data[$key]);
574 }
575 }
576 }
577
578 $id = $this->service->createWithRelations($data, $relationships);
579
580 return $this->success_response([
581 'id' => $id,
582 'message' => __('Trip created successfully', 'yatra'),
583 ], 201);
584 } catch (\InvalidArgumentException $e) {
585 return $this->error_response($e->getMessage(), $e->getCode() >= 400 ? $e->getCode() : 400);
586 } catch (\Exception $e) {
587 return $this->handle_exception($e);
588 }
589 }
590
591 /**
592 * Update item
593 */
594 public function update_item(WP_REST_Request $request)
595 {
596 try {
597 $id = (int) $request->get_param('id');
598 $data = $request->get_json_params();
599 $data = apply_filters('yatra_trip_update_raw_data', $data, $id, $request);
600
601 // Map old field names to new table schema
602 if (isset($data['booking_deadline'])) {
603 $data['booking_deadline_hours'] = is_numeric($data['booking_deadline']) ? (int) $data['booking_deadline'] : 24;
604 unset($data['booking_deadline']);
605 }
606
607 // Ensure JSON fields stay in main data (not relationships)
608 if (isset($data['included_items'])) {
609 $data['included_items'] = is_string($data['included_items']) ? $data['included_items'] : wp_json_encode($data['included_items']);
610 }
611 if (isset($data['excluded_items'])) {
612 $data['excluded_items'] = is_string($data['excluded_items']) ? $data['excluded_items'] : wp_json_encode($data['excluded_items']);
613 }
614 if (isset($data['frontend_tabs'])) {
615 $data['frontend_tabs'] = is_string($data['frontend_tabs']) ? $data['frontend_tabs'] : wp_json_encode($data['frontend_tabs']);
616 }
617 if (isset($data['testimonial_review_ids'])) {
618 $data['testimonial_review_ids'] = is_string($data['testimonial_review_ids']) ? $data['testimonial_review_ids'] : wp_json_encode($data['testimonial_review_ids']);
619 }
620 if (isset($data['default_time_slots'])) {
621 $data['default_time_slots'] = is_string($data['default_time_slots']) ? $data['default_time_slots'] : wp_json_encode($data['default_time_slots']);
622
623 }
624
625 // Handle featured_priority field (already in $data for update)
626 // Remove legacy/removed columns not present in trips table
627 foreach (['currency', 'testimonials', 'countries', 'regions', 'tags'] as $deprecatedKey) {
628 if (isset($data[$deprecatedKey])) {
629 unset($data[$deprecatedKey]);
630 }
631 }
632
633 // Extract relationships (fields stored in separate tables)
634 $relationships = [];
635 if (isset($data['destinations'])) {
636 $relationships['destinations'] = $data['destinations'];
637 }
638 if (isset($data['activity_types'])) {
639 $relationships['activities'] = $data['activity_types'];
640 }
641 if (isset($data['trip_category'])) {
642 $relationships['trip_category'] = $data['trip_category'];
643 }
644 if (isset($data['price_types'])) {
645 $relationships['price_types'] = $data['price_types'];
646 if (defined('WP_DEBUG') && WP_DEBUG) {
647 }
648 }
649 if (isset($data['highlights'])) {
650 $relationships['highlights'] = $data['highlights'];
651 }
652 if (isset($data['landmarks'])) {
653 $relationships['landmarks'] = $data['landmarks'];
654 }
655 if (isset($data['gallery_images'])) {
656 $relationships['gallery_images'] = $data['gallery_images'];
657 }
658 if (isset($data['faqs'])) {
659 $relationships['faqs'] = $data['faqs'];
660 }
661 if (isset($data['downloadable_items'])) {
662 $relationships['downloadable_items'] = $data['downloadable_items'];
663 }
664 if (isset($data['itinerary_days'])) {
665 $relationships['itinerary_days'] = $data['itinerary_days'];
666 }
667 if (isset($data['availability_dates'])) {
668 $relationships['availability_dates'] = $data['availability_dates'];
669 }
670 if (isset($data['attributes'])) {
671 $relationships['attributes'] = $data['attributes'];
672 }
673
674 $relationships = apply_filters('yatra_trip_update_relationships', $relationships, $data, $request);
675
676 // Validate and sanitize input data
677 TripValidator::validateUpdate($data, $id);
678
679 $data = TripValidator::sanitize($data);
680
681 $data = apply_filters('yatra_trip_update_sanitized_data', $data, $id, $relationships, $request);
682
683 $extraUnsetKeys = apply_filters('yatra_trip_update_unset_keys', [], $data, $relationships, $request);
684 if (is_array($extraUnsetKeys) && !empty($extraUnsetKeys)) {
685 foreach ($extraUnsetKeys as $key) {
686 if (is_string($key) && isset($data[$key])) {
687 unset($data[$key]);
688 }
689 }
690 }
691
692 // Remove relationships from main data (these should not be in the main table)
693 // Note: included_items, excluded_items, frontend_tabs stay in main data as JSON
694 unset(
695 $data['destinations'],
696 $data['activity_types'],
697 $data['trip_category'],
698 $data['highlights'],
699 $data['landmarks'],
700 $data['gallery_images'],
701 $data['faqs'],
702 $data['downloadable_items'],
703 $data['itinerary_days'],
704 $data['availability_dates'],
705 $data['attributes']
706 );
707
708 // Update via service to persist main data and relations
709 $updated = $this->service->updateWithRelations($id, $data, $relationships);
710
711 if (!$updated) {
712 return $this->error_response(__('Failed to update trip', 'yatra'), 500);
713 }
714
715 $trip = $this->service->getWithRelations($id);
716 $prepared = $this->prepare_item_for_response($trip, $request);
717
718 return $this->success_response($prepared, 200);
719 } catch (\InvalidArgumentException $e) {
720 return $this->error_response($e->getMessage(), 400);
721 } catch (\Exception $e) {
722 return $this->error_response($e->getMessage(), 500);
723 }
724 }
725
726 /**
727 * Delete item (soft delete)
728 */
729 public function delete_item(WP_REST_Request $request)
730 {
731 try {
732 $id = (int) $request->get_param('id');
733 $result = $this->service->softDelete($id);
734
735 if (!$result) {
736 return $this->error_response(__('Failed to delete trip', 'yatra'), 500);
737 }
738
739 return $this->success_response([
740 'message' => __('Trip deleted successfully', 'yatra'),
741 ]);
742 } catch (\Exception $e) {
743 return $this->error_response($e->getMessage(), 500);
744 }
745 }
746
747 /**
748 * Permanent delete item (hard delete)
749 */
750 public function permanent_delete_item(WP_REST_Request $request)
751 {
752 try {
753 $id = (int) $request->get_param('id');
754
755 // DEBUG: Log permanent delete attempt
756 if (defined('WP_DEBUG') && WP_DEBUG) {
757 }
758
759 $result = $this->service->permanentDelete($id);
760
761 if (!$result) {
762 return $this->error_response(__('Failed to permanently delete trip', 'yatra'), 500);
763 }
764
765 // DEBUG: Log successful delete
766 if (defined('WP_DEBUG') && WP_DEBUG) {
767 }
768
769 return $this->success_response([
770 'message' => __('Trip permanently deleted', 'yatra'),
771 ]);
772 } catch (\Exception $e) {
773 return $this->error_response($e->getMessage(), 500);
774 }
775 }
776
777 /**
778 * Search items
779 */
780 public function search_items(WP_REST_Request $request)
781 {
782 try {
783 $keyword = $request->get_param('keyword') ?: $request->get_param('search');
784
785 if (empty($keyword)) {
786 return $this->error_response(__('Search keyword is required', 'yatra'), 400);
787 }
788
789 $args = [
790 'limit' => (int) ($request->get_param('per_page') ?: 10),
791 'offset' => ((int) ($request->get_param('page') ?: 1) - 1) * (int) ($request->get_param('per_page') ?: 10),
792 'order_by' => $request->get_param('orderby') ?: 'id',
793 'order' => strtoupper($request->get_param('order') ?: 'DESC'),
794 ];
795
796 $items = $this->service->search($keyword, $args);
797
798 return $this->success_response([
799 'data' => $this->prepare_collection_for_response($items, $request),
800 'total' => count($items),
801 'page' => (int) ($request->get_param('page') ?: 1),
802 'per_page' => (int) ($request->get_param('per_page') ?: 10),
803 ]);
804 } catch (\Exception $e) {
805 return $this->error_response($e->getMessage(), 500);
806 }
807 }
808
809 /**
810 * Get revisions for a trip
811 */
812 public function get_revisions(WP_REST_Request $request)
813 {
814 try {
815 $id = (int) $request->get_param('id');
816 $revisionRepository = new TripRevisionRepository();
817
818 $args = [
819 'order_by' => 'version',
820 'order' => 'DESC',
821 ];
822
823 $revisions = $revisionRepository->findByTripId($id, $args);
824
825 $prepared = array_map(function ($revision) {
826 $user = get_userdata($revision->created_by);
827 return [
828 'id' => (int) $revision->id,
829 'trip_id' => (int) $revision->trip_id,
830 'version' => (int) $revision->version,
831 'status' => $revision->status ?? 'inherit',
832 'created_at' => $revision->created_at,
833 'created_by' => (int) $revision->created_by,
834 'created_by_name' => $user ? $user->display_name : __('Unknown', 'yatra'),
835 ];
836 }, $revisions);
837
838 return $this->success_response($prepared);
839 } catch (\Exception $e) {
840 return $this->error_response($e->getMessage(), 500);
841 }
842 }
843
844 /**
845 * Get single revision
846 */
847 public function get_revision(WP_REST_Request $request)
848 {
849 try {
850 $id = (int) $request->get_param('id');
851 $revisionId = (int) $request->get_param('revision_id');
852 $revisionRepository = new TripRevisionRepository();
853
854 $revision = $revisionRepository->findRevision($revisionId);
855
856 if (!$revision) {
857 return $this->error_response(__('Revision not found', 'yatra'), 404);
858 }
859
860 if ((int) $revision->trip_id !== $id) {
861 return $this->error_response(__('Revision does not belong to this trip', 'yatra'), 400);
862 }
863
864 // Unserialize the data
865 $data = maybe_unserialize($revision->data);
866
867 $user = get_userdata($revision->created_by);
868
869 $prepared = [
870 'id' => (int) $revision->id,
871 'trip_id' => (int) $revision->trip_id,
872 'version' => (int) $revision->version,
873 'status' => $revision->status ?? 'inherit',
874 'data' => $data,
875 'created_at' => $revision->created_at,
876 'created_by' => (int) $revision->created_by,
877 'created_by_name' => $user ? $user->display_name : __('Unknown', 'yatra'),
878 ];
879
880 return $this->success_response($prepared);
881 } catch (\Exception $e) {
882 return $this->error_response($e->getMessage(), 500);
883 }
884 }
885
886 /**
887 * Restore a revision (WordPress-style)
888 */
889 public function restore_revision(WP_REST_Request $request)
890 {
891 try {
892 // Check permissions
893 if (!current_user_can('yatra_edit_trips')) {
894 return $this->error_response(__('You do not have permission to restore revisions', 'yatra'), 403);
895 }
896
897 $id = (int) $request->get_param('id');
898 $revisionId = (int) $request->get_param('revision_id');
899
900 if (!$id || !$revisionId) {
901 return $this->error_response(__('Invalid trip ID or revision ID', 'yatra'), 400);
902 }
903
904 // Restore the revision
905 $result = $this->service->restoreRevision($id, $revisionId);
906
907 if (!$result) {
908 return $this->error_response(__('Failed to restore revision', 'yatra'), 500);
909 }
910
911 // Get the updated trip
912 $trip = $this->service->getWithRelations($id);
913 $prepared = $this->prepare_item_for_response($trip, $request);
914
915 return $this->success_response($prepared, __('Revision restored successfully', 'yatra'), 200);
916 } catch (\Exception $e) {
917 return $this->error_response($e->getMessage(), 500);
918 }
919 }
920
921 /**
922 * Prepare item for response
923 */
924 protected function prepare_item_for_response($item, WP_REST_Request $request): array
925 {
926 if (!$item) {
927 return [];
928 }
929
930 // Convert to array if it's an object
931 $data = is_object($item) ? (array) $item : $item;
932
933 // Parse JSON fields
934 $jsonFields = [
935 'highlights',
936 'testimonials',
937 'countries',
938 'regions',
939 'landmarks',
940 'tags',
941 'included_items',
942 'excluded_items',
943 'gallery_images',
944 'price_types',
945 'itinerary_days',
946 'faqs',
947 'frontend_tabs',
948 'availability_dates',
949 'blackout_dates',
950 'custom_fields',
951 'pricing_rules',
952 'booking_rules',
953 'testimonial_review_ids',
954 'default_time_slots',
955 ];
956
957 foreach ($jsonFields as $field) {
958 if (isset($data[$field]) && is_string($data[$field])) {
959 $decoded = maybe_unserialize($data[$field]);
960 $data[$field] = is_array($decoded) ? $decoded : (json_decode($data[$field], true) ?: []);
961 }
962 }
963
964 // Ensure testimonial_review_ids is always a clean array of integers
965 if (isset($data['testimonial_review_ids'])) {
966 if (!is_array($data['testimonial_review_ids'])) {
967 $data['testimonial_review_ids'] = [];
968 } else {
969 // Filter out null values and ensure all values are integers
970 $data['testimonial_review_ids'] = array_values(array_filter(
971 array_map('intval', $data['testimonial_review_ids']),
972 function($id) { return $id > 0; }
973 ));
974 }
975 } else {
976 $data['testimonial_review_ids'] = [];
977 }
978
979 // Convert boolean fields
980 $booleanFields = [
981 'flexible_dates',
982 'fixed_departures_only',
983 'seasonal_auto_enable',
984 'price_per_person',
985 'deposit_required',
986 'payment_plans_enabled',
987 'tax_included',
988 'group_pricing_enabled',
989 'early_bird_discount_enabled',
990 'last_minute_discount_enabled',
991 'waitlist_enabled',
992 'instant_booking',
993 'requires_approval',
994 'booking_confirmation_email',
995 'booking_reminder_email',
996 'travel_insurance_required',
997 'accommodation_included',
998 'transportation_included',
999 'international_flights_included',
1000 'domestic_flights_included',
1001 'is_featured',
1002 'has_default_time_slots',
1003 ];
1004
1005 foreach ($booleanFields as $field) {
1006 if (isset($data[$field])) {
1007 $data[$field] = (bool) $data[$field];
1008 }
1009 }
1010
1011 // Convert numeric fields
1012 $numericFields = [
1013 'id',
1014 'map_zoom_level',
1015 'duration_days',
1016 'duration_nights',
1017 'duration_hours',
1018 'booking_window_days',
1019 'booking_deadline_hours',
1020 'min_travelers',
1021 'max_travelers',
1022 'max_travelers_per_booking',
1023 'waitlist_capacity',
1024 'reminder_days_before',
1025 'age_min',
1026 'age_max',
1027 'passport_validity_months',
1028 'group_size_min',
1029 'group_size_max',
1030 'early_bird_days',
1031 'last_minute_days',
1032 'version',
1033 'featured_order',
1034 'sort_order',
1035 'views_count',
1036 'bookings_count',
1037 'reviews_count',
1038 'created_by',
1039 'updated_by',
1040 'deleted_by',
1041 ];
1042
1043 foreach ($numericFields as $field) {
1044 if (isset($data[$field])) {
1045 $data[$field] = is_numeric($data[$field]) ? (int) $data[$field] : null;
1046 }
1047 }
1048
1049 // Convert float fields
1050 $floatFields = [
1051 'original_price',
1052 'discounted_price',
1053 'sale_price',
1054 'traveler_min_price',
1055 'traveler_max_price',
1056 'deposit_amount',
1057 'deposit_percentage',
1058 'tax_rate',
1059 'service_charge',
1060 'service_charge_percentage',
1061 'group_discount_percentage',
1062 'group_discount_amount',
1063 'early_bird_discount',
1064 'last_minute_discount',
1065 'revenue_total',
1066 'conversion_rate',
1067 'avg_rating',
1068 ];
1069
1070 foreach ($floatFields as $field) {
1071 if (isset($data[$field])) {
1072 $data[$field] = is_numeric($data[$field]) ? (float) $data[$field] : null;
1073 }
1074 }
1075
1076 // Handle relationships if loaded
1077 if (isset($item->destinations)) {
1078 $data['destinations'] = array_map(function ($dest) {
1079 return [
1080 'id' => (int) ($dest->id ?? 0),
1081 'name' => $dest->name ?? '',
1082 'slug' => $dest->slug ?? '',
1083 'is_primary' => (bool) ($dest->is_primary ?? false),
1084 'order' => (int) ($dest->order ?? 0),
1085 ];
1086 }, $item->destinations);
1087 }
1088
1089 if (isset($item->activities)) {
1090 $data['activity_types'] = array_map(function ($act) {
1091 return [
1092 'id' => (int) ($act->classification_id ?? 0),
1093 'name' => $act->activity_name ?? '',
1094 'slug' => $act->activity_slug ?? '',
1095 'is_primary' => (bool) ($act->is_primary ?? false),
1096 'order' => (int) ($act->order ?? 0),
1097 ];
1098 }, $item->activities);
1099 }
1100
1101 if (isset($item->trip_category)) {
1102 // Check if trip_category is an array (from relation table) or string (old serialized data)
1103 if (is_array($item->trip_category)) {
1104 $data['trip_category'] = array_map(function ($cat) {
1105 return [
1106 'id' => (int) ($cat->classification_id ?? $cat->category_id ?? $cat->id ?? 0),
1107 'name' => $cat->category_name ?? $cat->name ?? '',
1108 'slug' => $cat->category_slug ?? $cat->slug ?? '',
1109 'is_primary' => (bool) ($cat->is_primary ?? false),
1110 'order' => (int) ($cat->order ?? 0),
1111 ];
1112 }, $item->trip_category);
1113 } else {
1114 // It's likely old serialized data - set to empty array
1115 $data['trip_category'] = [];
1116 }
1117
1118 if (defined('WP_DEBUG') && WP_DEBUG) {
1119 }
1120 } else {
1121 $data['trip_category'] = [];
1122 if (defined('WP_DEBUG') && WP_DEBUG) {
1123 }
1124 }
1125
1126 if (isset($item->price_types)) {
1127 // Normalize to array
1128 $rawPriceTypes = $item->price_types;
1129 if (is_string($rawPriceTypes)) {
1130 $decoded = json_decode($rawPriceTypes, true);
1131 $rawPriceTypes = is_array($decoded) ? $decoded : [];
1132 } elseif (!is_array($rawPriceTypes)) {
1133 $rawPriceTypes = [];
1134 }
1135
1136 $data['price_types'] = array_map(function ($pt) {
1137 // Normalize array to object for consistent access
1138 if (is_array($pt)) {
1139 $pt = (object) $pt;
1140 }
1141 return [
1142 'id' => isset($pt->id) ? (int) $pt->id : 0,
1143 'category_id' => isset($pt->category_id) ? (int) $pt->category_id : null,
1144 'category_label' => $pt->category_label ?? ($pt->label ?? ''),
1145 'category_slug' => $pt->category_slug ?? '',
1146 'original_price' => isset($pt->original_price) ? (float) $pt->original_price : null,
1147 'discounted_price' => isset($pt->discounted_price) ? (float) $pt->discounted_price : null,
1148 'sale_price' => isset($pt->sale_price) ? (float) $pt->sale_price : null,
1149 'is_default' => isset($pt->is_default) ? (bool) $pt->is_default : false,
1150 'min_quantity' => isset($pt->min_quantity) ? (int) $pt->min_quantity : 0,
1151 'max_quantity' => isset($pt->max_quantity) ? (int) $pt->max_quantity : null,
1152 'valid_from' => $pt->valid_from ?? null,
1153 'valid_to' => $pt->valid_to ?? null,
1154 ];
1155 }, $rawPriceTypes);
1156 } else {
1157 $data['price_types'] = [];
1158 }
1159
1160 // Handle highlights relationship (send simple strings to match form expectations)
1161 if (isset($item->highlights)) {
1162 $data['highlights'] = array_map(function ($h) {
1163 if (is_object($h) && isset($h->text)) {
1164 return $h->text;
1165 }
1166 if (is_array($h) && isset($h['text'])) {
1167 return $h['text'];
1168 }
1169 if (is_object($h) && isset($h->highlight_text)) {
1170 return $h->highlight_text;
1171 }
1172 if (is_array($h) && isset($h['highlight_text'])) {
1173 return $h['highlight_text'];
1174 }
1175 return is_string($h) ? $h : '';
1176 }, $item->highlights);
1177 }
1178
1179 // Handle gallery images relationship
1180 if (isset($item->gallery_images)) {
1181 $data['gallery_images'] = array_map(function ($img) {
1182 return [
1183 'id' => (int) ($img->image_id ?? 0),
1184 'url' => $img->image_url ?? '',
1185 'thumbnail_url' => $img->thumbnail_url ?? '',
1186 'alt_text' => $img->alt_text ?? '',
1187 'caption' => $img->caption ?? '',
1188 'order' => (int) ($img->order ?? 0),
1189 'is_featured' => (bool) ($img->is_featured ?? false),
1190 ];
1191 }, $item->gallery_images);
1192 }
1193
1194 // Handle FAQs relationship (already normalized in repository)
1195 if (isset($item->faqs)) {
1196 $data['faqs'] = array_map(function ($faq) {
1197 return [
1198 'question' => $faq->question ?? '',
1199 'answer' => $faq->answer ?? '',
1200 'category' => $faq->category ?? '',
1201 'is_featured' => isset($faq->is_featured) ? (bool) $faq->is_featured : false,
1202 'order' => isset($faq->order) ? (int) $faq->order : 0,
1203 ];
1204 }, $item->faqs);
1205 } else {
1206 $data['faqs'] = [];
1207 }
1208
1209 // Handle downloadable_items relationship (already normalized in repository)
1210 if (isset($item->downloadable_items)) {
1211 $data['downloadable_items'] = array_map(function ($download) {
1212 return [
1213 'id' => isset($download->id) ? (int) $download->id : null,
1214 'title' => $download->title ?? '',
1215 'description' => $download->description ?? '',
1216 'attachment_id' => isset($download->attachment_id) ? (int) $download->attachment_id : null,
1217 'attachment_url' => $download->content_url ?? '',
1218 'attachment_title' => $download->title ?? '',
1219 'visibility' => $download->visibility ?? 'booked_only',
1220 'enabled' => isset($download->is_downloadable) ? (bool) $download->is_downloadable : true,
1221 'sort_order' => isset($download->sort_order) ? (int) $download->sort_order : 0,
1222 ];
1223 }, $item->downloadable_items);
1224 } else {
1225 $data['downloadable_items'] = [];
1226 }
1227
1228 if (isset($item->itinerary_days)) {
1229 $data['itinerary_days'] = array_map(function ($day) {
1230 $dayData = [
1231 'id' => isset($day->id) ? (int) $day->id : null,
1232 'day_number' => isset($day->day_number) ? (int) $day->day_number : 0,
1233 'title' => $day->title ?? '',
1234 'description' => $day->description ?? '',
1235 'entries' => [],
1236 ];
1237
1238 // Load entries if they exist
1239 if (isset($day->entries) && is_array($day->entries)) {
1240 $dayData['entries'] = array_map(function ($entry) {
1241 // Handle included_items - already array from repository or JSON string
1242 $includedItems = [];
1243 if (isset($entry->included_items)) {
1244 if (is_array($entry->included_items)) {
1245 $includedItems = $entry->included_items;
1246 } elseif (is_string($entry->included_items)) {
1247 $decoded = json_decode($entry->included_items, true);
1248 $includedItems = is_array($decoded) ? $decoded : [];
1249 }
1250 }
1251
1252 // Handle excluded_items - already array from repository or JSON string
1253 $excludedItems = [];
1254 if (isset($entry->excluded_items)) {
1255 if (is_array($entry->excluded_items)) {
1256 $excludedItems = $entry->excluded_items;
1257 } elseif (is_string($entry->excluded_items)) {
1258 $decoded = json_decode($entry->excluded_items, true);
1259 $excludedItems = is_array($decoded) ? $decoded : [];
1260 }
1261 }
1262
1263 // Handle images - already array from repository or JSON string
1264 $images = [];
1265 if (isset($entry->images)) {
1266 if (is_array($entry->images)) {
1267 $images = $entry->images;
1268 } elseif (is_string($entry->images)) {
1269 $decoded = json_decode($entry->images, true);
1270 $images = is_array($decoded) ? $decoded : [];
1271 }
1272 }
1273
1274 // Decode gallery JSON column on the entry so the React form
1275 // can re-populate the gallery picker without an extra fetch.
1276 $gallery = [];
1277 if (isset($entry->gallery)) {
1278 if (is_array($entry->gallery)) {
1279 $gallery = $entry->gallery;
1280 } elseif (is_string($entry->gallery) && $entry->gallery !== '') {
1281 $decoded = json_decode($entry->gallery, true);
1282 $gallery = is_array($decoded) ? $decoded : [];
1283 }
1284 }
1285
1286 return [
1287 'id' => isset($entry->id) ? (int) $entry->id : null,
1288 'day_id' => isset($entry->day_id) ? (int) $entry->day_id : null,
1289 'time' => $entry->time ?? '',
1290 'start_time' => $entry->start_time ?? null,
1291 'end_time' => $entry->end_time ?? null,
1292 'time_type' => $entry->time_type ?? 'exact',
1293 'title' => $entry->title ?? '',
1294 'description' => $entry->description ?? '',
1295 'location' => $entry->location ?? '',
1296 // The entries table has lat/lng/gallery/video_url + an `order`
1297 // smallint column — but until this serializer included them, the
1298 // /trips/{id} response never carried them. The React activity
1299 // load mapper sorts by `entry.order`; without it, every entry
1300 // arrived with order=null, the sort fell through to id-order,
1301 // and drag-sort reorders never appeared to persist on reload.
1302 'location_latitude' => isset($entry->location_latitude) ? $entry->location_latitude : null,
1303 'location_longitude' => isset($entry->location_longitude) ? $entry->location_longitude : null,
1304 'duration' => $entry->duration ?? '',
1305 'cost' => isset($entry->cost) ? (float) $entry->cost : null,
1306 'cost_per_person' => isset($entry->cost_per_person) ? (bool) $entry->cost_per_person : false,
1307 'notes' => $entry->notes ?? '',
1308 'item_type_id' => isset($entry->item_type_id) ? (int) $entry->item_type_id : null,
1309 'item_id' => isset($entry->item_id) ? (int) $entry->item_id : null,
1310 'status' => $entry->status ?? 'active',
1311 'order' => isset($entry->order) ? (int) $entry->order : 0,
1312 'gallery' => $gallery,
1313 'video_url' => $entry->video_url ?? '',
1314 'created_at' => $entry->created_at ?? '',
1315 'updated_at' => $entry->updated_at ?? '',
1316 'included_items' => $includedItems,
1317 'excluded_items' => $excludedItems,
1318 'images' => $images,
1319 ];
1320 }, $day->entries);
1321 }
1322
1323 return $dayData;
1324 }, $item->itinerary_days);
1325 }
1326
1327 // Handle availability dates relationship
1328 if (isset($item->availability_dates)) {
1329 $data['availability_dates'] = array_map(function ($date) {
1330 return [
1331 'id' => isset($date->id) ? (int) $date->id : null,
1332 'departure_date' => $date->departure_date ?? '',
1333 'arrival_date' => $date->arrival_date ?? '',
1334 'return_date' => $date->return_date ?? '',
1335 'seats_total' => isset($date->seats_total) ? (int) $date->seats_total : 0,
1336 'seats_available' => isset($date->seats_available) ? (int) $date->seats_available : 0,
1337 'original_price' => isset($date->original_price) ? (float) $date->original_price : null,
1338 'discounted_price' => isset($date->discounted_price) ? (float) $date->discounted_price : null,
1339 'status' => $date->status ?? 'available',
1340 ];
1341 }, $item->availability_dates);
1342 }
1343
1344 // Handle attributes relationship
1345 if (isset($item->attributes)) {
1346 $attributes = [];
1347 foreach ($item->attributes as $attribute) {
1348 $attributeId = isset($attribute->attribute_id) ? (int) $attribute->attribute_id : ((isset($attribute->id) ? (int) $attribute->id : null));
1349
1350 if (!$attributeId) {
1351 continue;
1352 }
1353
1354 $value = $attribute->value ?? null;
1355 if (!empty($attribute->value_serialized) && is_string($value)) {
1356 $unserialized = maybe_unserialize($value);
1357 $value = $unserialized !== false ? $unserialized : $value;
1358 }
1359
1360 $attributes[$attributeId] = $value;
1361 }
1362
1363 $data['attributes'] = $attributes;
1364 }
1365
1366 // Add featured image URL
1367 if (isset($data['featured_image']) && $data['featured_image'] > 0) {
1368 $imageUrl = wp_get_attachment_image_url($data['featured_image'], 'medium');
1369 $data['featured_image_url'] = $imageUrl ?: '';
1370 } else {
1371 $data['featured_image_url'] = '';
1372 }
1373
1374 // Add permalink (respects WordPress permalink structure: plain vs pretty)
1375 if (!empty($data['slug'])) {
1376 $data['permalink'] = yatra_get_trip_permalink($item);
1377 }
1378
1379 // Add user information
1380 if (isset($data['created_by']) && $data['created_by'] > 0) {
1381 $user = get_userdata($data['created_by']);
1382 $data['created_by_name'] = $user ? $user->display_name : __('Unknown', 'yatra');
1383 }
1384
1385 if (isset($data['updated_by']) && $data['updated_by'] > 0) {
1386 $user = get_userdata($data['updated_by']);
1387 $data['updated_by_name'] = $user ? $user->display_name : __('Unknown', 'yatra');
1388 }
1389
1390 return apply_filters('yatra_trip_prepare_item_for_response', $data, $item, $request);
1391 }
1392
1393 /**
1394 * Prepare collection for response
1395 */
1396 protected function prepare_collection_for_response(array $items, WP_REST_Request $request): array
1397 {
1398 return array_map(function ($item) use ($request) {
1399 return $this->prepare_item_for_response($item, $request);
1400 }, $items);
1401 }
1402
1403 /**
1404 * Get availability template HTML
1405 * Returns the HTML for the availability section
1406 */
1407 public function get_availability_template(WP_REST_Request $request)
1408 {
1409 try {
1410 $id = (int) $request->get_param('id');
1411 $trip = $this->service->getWithRelations($id);
1412
1413 $sort_key = sanitize_text_field((string) ($request->get_param('sort') ?? 'date-asc'));
1414 $allowed_sorts = ['date-asc', 'date-desc', 'price-asc', 'price-desc', 'seats-desc'];
1415 if (!in_array($sort_key, $allowed_sorts, true)) {
1416 $sort_key = 'date-asc';
1417 }
1418
1419 if (!$trip) {
1420 return $this->error_response('Trip not found', 404);
1421 }
1422
1423 // Get traveler data from request
1424 $num_travelers = (int) ($request->get_param('num_travelers') ?? 1);
1425 $travelers_json = $request->get_param('travelers');
1426 $travelers = [];
1427
1428 if ($travelers_json) {
1429 $decoded = json_decode($travelers_json, true);
1430 if (is_array($decoded)) {
1431 $travelers = $decoded;
1432 }
1433 }
1434
1435 // Get selected date if provided
1436 $selected_date = sanitize_text_field((string) ($request->get_param('date') ?? ''));
1437
1438 // Month filter for list: "all" or lowercase key e.g. "jan-2026" (matches data-month on cards)
1439 $month_filter = sanitize_text_field((string) ($request->get_param('month_filter') ?? ''));
1440 if ($month_filter === '') {
1441 $month_filter = sanitize_text_field((string) ($request->get_param('month') ?? 'all'));
1442 }
1443 $month_filter = strtolower($month_filter ?: 'all');
1444 // Accept YYYY-MM from JS (locale-safe); map to same M-Y keys used on cards
1445 if ($month_filter !== 'all' && preg_match('/^(\d{4})-(\d{2})$/', $month_filter, $mm)) {
1446 $ts = strtotime(sprintf('%04d-%02d-01', (int) $mm[1], (int) $mm[2]));
1447 if ($ts) {
1448 $month_filter = strtolower(date('M-Y', $ts));
1449 }
1450 }
1451
1452 $page = max(1, (int) ($request->get_param('page') ?? 1));
1453 $per_page = (int) ($request->get_param('per_page') ?? 10);
1454 $per_page = max(1, min(50, $per_page));
1455 $partial = (int) ($request->get_param('partial') ?? 0) === 1;
1456
1457 // Fetch availability dates using centralized resolution service
1458 $resolutionService = new \Yatra\Services\AvailabilityResolutionService();
1459
1460 // Always show all dates from today onwards (selected_date is only for highlighting)
1461 $fromDate = date('Y-m-d');
1462 $toDate = date('Y-m-d', strtotime('+12 months'));
1463
1464 $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate);
1465
1466 // Determine if this is a day trip
1467 $is_single_day = ($trip->duration_days ?? 1) <= 1;
1468
1469 // Auto-select month and date
1470 $auto_selected_month = '';
1471 $auto_selected_date = '';
1472
1473 if (!empty($availability_dates)) {
1474 // Legacy UI hint: month of selected date (response JSON); list filter uses month_filter
1475 if (!empty($selected_date)) {
1476 // Use selected date's month (always month-based now)
1477 $selected_timestamp = strtotime($selected_date);
1478 $auto_selected_month = strtolower(date('M-Y', $selected_timestamp));
1479 } else {
1480 // Use first available date's month (always month-based now)
1481 $first_avail = reset($availability_dates);
1482 if (!empty($first_avail->departure_date)) {
1483 $first_date = strtotime($first_avail->departure_date);
1484 $auto_selected_month = strtolower(date('M-Y', $first_date));
1485 }
1486 }
1487
1488 // Find closest available date
1489 if (!empty($selected_date)) {
1490 // Check if selected date is available
1491 $date_found = false;
1492 foreach ($availability_dates as $avail) {
1493 if (!empty($avail->departure_date) && $avail->departure_date === $selected_date) {
1494 $auto_selected_date = $selected_date;
1495 $date_found = true;
1496 break;
1497 }
1498 }
1499
1500 // If selected date not available, find closest
1501 if (!$date_found) {
1502 $selected_timestamp = strtotime($selected_date);
1503 $closest_date = null;
1504 $min_diff = PHP_INT_MAX;
1505
1506 foreach ($availability_dates as $avail) {
1507 if (!empty($avail->departure_date)) {
1508 $avail_timestamp = strtotime($avail->departure_date);
1509 $diff = abs($avail_timestamp - $selected_timestamp);
1510
1511 if ($diff < $min_diff) {
1512 $min_diff = $diff;
1513 $closest_date = $avail->departure_date;
1514 }
1515 }
1516 }
1517
1518 $auto_selected_date = $closest_date ?? '';
1519 }
1520 } else {
1521 // No date provided, select first available date
1522 $first_avail = reset($availability_dates);
1523 $auto_selected_date = $first_avail->departure_date ?? '';
1524 }
1525 }
1526
1527 // Prepare trip data for template
1528 $trip_data = (object) [
1529 'id' => $trip->id,
1530 'title' => $trip->title ?? '',
1531 'starting_location' => $trip->starting_location ?? '',
1532 'ending_location' => $trip->ending_location ?? '',
1533 'original_price' => isset($trip->original_price) ? (float) $trip->original_price : 0,
1534 'discounted_price' => isset($trip->discounted_price) ? (float) $trip->discounted_price : 0,
1535 'sale_price' => isset($trip->sale_price) ? (float) $trip->sale_price : 0,
1536 'currency' => SettingsService::getCurrency(),
1537 'duration_days' => isset($trip->duration_days) ? (int) $trip->duration_days : 1,
1538 'max_travelers' => isset($trip->max_travelers) ? (int) $trip->max_travelers : 20,
1539 'min_travelers' => isset($trip->min_travelers) ? (int) $trip->min_travelers : 1,
1540 'pricing_type' => $trip->pricing_type ?? 'regular',
1541 'price_types' => $trip->price_types ?? [], // Include price_types for traveler-based pricing
1542 'availability_dates' => $availability_dates,
1543 ];
1544
1545 // Start output buffering
1546 ob_start();
1547
1548 $slice_meta = $this->render_availability_template(
1549 $trip_data,
1550 $sort_key,
1551 $travelers,
1552 $num_travelers,
1553 $selected_date,
1554 $auto_selected_month,
1555 $auto_selected_date,
1556 $month_filter,
1557 $page,
1558 $per_page,
1559 $partial
1560 );
1561
1562 $html = ob_get_clean();
1563
1564 $payload = [
1565 'html' => $html,
1566 'selected_month' => $month_filter,
1567 'selected_date' => $auto_selected_date,
1568 'month_filter' => $month_filter,
1569 'sort' => $sort_key,
1570 'total' => $slice_meta['total'],
1571 'page' => $slice_meta['page'],
1572 'per_page' => $slice_meta['per_page'],
1573 'has_more' => $slice_meta['has_more'],
1574 'loaded_count' => $slice_meta['loaded_count'],
1575 'partial' => $partial,
1576 ];
1577
1578 return $this->success_response($payload);
1579 } catch (\Exception $e) {
1580 return $this->error_response($e->getMessage(), 500);
1581 }
1582 }
1583
1584 /**
1585 * Normalize departure date to Y-m-d for comparisons (handles datetime strings).
1586 */
1587 private static function normalizeAvailabilityDateString(?string $value): string
1588 {
1589 $value = trim((string) $value);
1590 if ($value === '') {
1591 return '';
1592 }
1593 if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $m)) {
1594 return $m[1];
1595 }
1596
1597 return $value;
1598 }
1599
1600 /**
1601 * Paginate filtered availability cards (after sort).
1602 * When $pin_date is Y-m-d and that departure exists in the filtered list, the page is
1603 * adjusted so that row is included (fixes sidebar picking e.g. Aug 13 while page 1 only had Aug 1–10).
1604 *
1605 * @return array{items: array, total: int, page: int, per_page: int, has_more: bool, loaded_count: int}
1606 */
1607 private function computeAvailabilityPage(
1608 array $sorted_cards,
1609 string $month_filter,
1610 int $page,
1611 int $per_page,
1612 string $pin_date = ''
1613 ): array {
1614 $month_filter = strtolower($month_filter ?: 'all');
1615 $filtered = $sorted_cards;
1616 if ($month_filter !== 'all') {
1617 $filtered = array_values(array_filter(
1618 $sorted_cards,
1619 static function (array $c) use ($month_filter): bool {
1620 return (string) ($c['data_month'] ?? '') === $month_filter;
1621 }
1622 ));
1623 }
1624
1625 $total = count($filtered);
1626 $per_page = max(1, min(50, $per_page));
1627 $page = max(1, $page);
1628
1629 $pin_date = trim($pin_date);
1630 if ($pin_date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $pin_date)) {
1631 $pin_norm = self::normalizeAvailabilityDateString($pin_date);
1632 foreach ($filtered as $idx => $c) {
1633 $card_norm = self::normalizeAvailabilityDateString((string) ($c['data_date'] ?? ''));
1634 if ($card_norm !== '' && $card_norm === $pin_norm) {
1635 $page = (int) (floor((int) $idx / $per_page) + 1);
1636 break;
1637 }
1638 }
1639 }
1640
1641 $offset = ($page - 1) * $per_page;
1642 $items = array_slice($filtered, $offset, $per_page);
1643 $loaded_count = $offset + count($items);
1644
1645 return [
1646 'items' => $items,
1647 'total' => $total,
1648 'page' => $page,
1649 'per_page' => $per_page,
1650 'has_more' => $loaded_count < $total,
1651 'loaded_count' => $loaded_count,
1652 ];
1653 }
1654
1655 /**
1656 * Render availability template (full section or card fragment only).
1657 *
1658 * @return array{total: int, page: int, per_page: int, has_more: bool, loaded_count: int}
1659 */
1660 private function render_availability_template(
1661 $trip_data,
1662 string $sort_key = 'date-asc',
1663 array $travelers = [],
1664 int $num_travelers = 1,
1665 string $selected_date = '',
1666 string $auto_selected_month = '',
1667 string $auto_selected_date = '',
1668 string $month_filter = 'all',
1669 int $page = 1,
1670 int $per_page = 10,
1671 bool $fragment_cards_only = false
1672 ): array {
1673 // Check if we have real availability data
1674 $has_availability = !empty($trip_data->availability_dates) && is_array($trip_data->availability_dates);
1675
1676 // Build cards from real availability data or use sample data
1677 $availability_cards = [];
1678 $month_filters = [];
1679
1680 // Availability priority (same as the resolver):
1681 // 1) manual availability dates, 2) recurring rules, 3) trip defaults.
1682 // For UI counts + filters we want the list to reflect that priority (not a mixed set).
1683 $availability_dates_for_render = $has_availability ? $trip_data->availability_dates : [];
1684 if ($has_availability) {
1685 $by_source = [
1686 'availability_date' => [],
1687 'recurring_rule' => [],
1688 'trip_default' => [],
1689 ];
1690 foreach ($trip_data->availability_dates as $a) {
1691 if (!is_object($a)) {
1692 continue;
1693 }
1694 $src = strtolower(trim((string) ($a->source ?? '')));
1695 if (isset($by_source[$src])) {
1696 $by_source[$src][] = $a;
1697 }
1698 }
1699 if (!empty($by_source['availability_date'])) {
1700 $availability_dates_for_render = $by_source['availability_date'];
1701 } elseif (!empty($by_source['recurring_rule'])) {
1702 $availability_dates_for_render = $by_source['recurring_rule'];
1703 } elseif (!empty($by_source['trip_default'])) {
1704 $availability_dates_for_render = $by_source['trip_default'];
1705 }
1706 }
1707
1708 // Determine if this is a day trip (duration <= 1 day)
1709 $is_single_day = ($trip_data->duration_days ?? 1) <= 1;
1710
1711 $traveler_category_labels = [];
1712 $traveler_category_meta = [];
1713 $traveler_category_ids = [];
1714 $add_category_ids = static function ($price_types_raw) use (&$traveler_category_ids): void {
1715 if (empty($price_types_raw)) {
1716 return;
1717 }
1718
1719 $decoded = $price_types_raw;
1720 if (is_string($price_types_raw)) {
1721 $decoded = json_decode($price_types_raw, true) ?: [];
1722 }
1723
1724 if (!is_array($decoded)) {
1725 return;
1726 }
1727
1728 foreach ($decoded as $pt) {
1729 if (is_object($pt)) {
1730 $pt = (array) $pt;
1731 }
1732 if (!is_array($pt)) {
1733 continue;
1734 }
1735 $cat_id = $pt['category_id'] ?? null;
1736 if ($cat_id !== null && $cat_id !== '') {
1737 $traveler_category_ids[] = (string) $cat_id;
1738 }
1739 }
1740 };
1741
1742 if (!empty($trip_data->price_types) && is_array($trip_data->price_types)) {
1743 $add_category_ids($trip_data->price_types);
1744 }
1745
1746 if ($has_availability) {
1747 foreach ($availability_dates_for_render as $avail_for_cats) {
1748 if (!empty($avail_for_cats->price_types)) {
1749 $add_category_ids($avail_for_cats->price_types);
1750 }
1751 if (!empty($avail_for_cats->traveler_pricing)) {
1752 $add_category_ids($avail_for_cats->traveler_pricing);
1753 }
1754 }
1755 }
1756
1757 $traveler_category_ids = array_values(array_unique(array_filter($traveler_category_ids)));
1758
1759 if (!empty($traveler_category_ids)) {
1760 $traveler_category_repo = new TravelerCategoryRepository();
1761 $categories = $traveler_category_repo->all([
1762 'where' => [
1763 'id' => $traveler_category_ids,
1764 ],
1765 ]);
1766
1767 foreach ($categories as $cat) {
1768 // Use 'name' field from database, not 'label'
1769 if (!empty($cat->id) && isset($cat->name)) {
1770 $traveler_category_labels[(string) $cat->id] = (string) $cat->name;
1771 }
1772 // Parse metadata for pricing_mode, age_min, age_max, min_pax, max_pax
1773 $meta = !empty($cat->metadata) ? (is_string($cat->metadata) ? json_decode($cat->metadata, true) : (array) $cat->metadata) : [];
1774 $traveler_category_meta[(string) $cat->id] = [
1775 'pricing_mode' => $meta['pricing_mode'] ?? 'per_person',
1776 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null,
1777 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null,
1778 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null,
1779 'max_pax' => isset($meta['max_pax']) ? (int) $meta['max_pax'] : null,
1780 ];
1781 }
1782 }
1783
1784 $enrich_price_types = static function ($price_types_raw) use ($traveler_category_labels, $traveler_category_meta): array {
1785 if (empty($price_types_raw)) {
1786 return [];
1787 }
1788
1789 $decoded = $price_types_raw;
1790 if (is_string($price_types_raw)) {
1791 $decoded = json_decode($price_types_raw, true) ?: [];
1792 }
1793
1794 if (!is_array($decoded)) {
1795 return [];
1796 }
1797
1798 return array_map(static function ($pt) use ($traveler_category_labels, $traveler_category_meta) {
1799 if (is_object($pt)) {
1800 $pt = (array) $pt;
1801 }
1802 if (!is_array($pt)) {
1803 return $pt;
1804 }
1805
1806 if (empty($pt['category_label']) && !empty($pt['traveler_category_label'])) {
1807 $pt['category_label'] = $pt['traveler_category_label'];
1808 }
1809
1810 $cat_id = $pt['category_id'] ?? null;
1811 if ((empty($pt['category_label']) && empty($pt['label'])) && $cat_id !== null) {
1812 $label = $traveler_category_labels[(string) $cat_id] ?? null;
1813 if (!empty($label)) {
1814 $pt['category_label'] = $label;
1815 }
1816 }
1817
1818 if (empty($pt['label']) && !empty($pt['category_label'])) {
1819 $pt['label'] = $pt['category_label'];
1820 }
1821
1822 // Enrich with category metadata (pricing_mode, age, pax limits)
1823 if ($cat_id !== null && isset($traveler_category_meta[(string) $cat_id])) {
1824 $meta = $traveler_category_meta[(string) $cat_id];
1825 // Always use category metadata pricing_mode to ensure correct mode from database
1826 $pt['pricing_mode'] = $meta['pricing_mode'];
1827 if (!isset($pt['age_min'])) $pt['age_min'] = $meta['age_min'];
1828 if (!isset($pt['age_max'])) $pt['age_max'] = $meta['age_max'];
1829 if (!isset($pt['min_pax'])) $pt['min_pax'] = $meta['min_pax'];
1830 if (!isset($pt['max_pax'])) $pt['max_pax'] = $meta['max_pax'];
1831 }
1832
1833 // Payable amount (honors price / sale_price / discounted_price like TripPricingService)
1834 if (!isset($pt['effective_price'])) {
1835 $eff = TripPricingService::resolveCategoryEffectivePrice($pt);
1836 $pt['effective_price'] = $eff;
1837 $orig = (float) ($pt['original_price'] ?? 0);
1838 if ($orig <= 0 && isset($pt['price'])) {
1839 $orig = (float) $pt['price'];
1840 }
1841 if ($orig > 0 && $eff > 0 && $eff < $orig) {
1842 if (!isset($pt['discounted_price']) || (float) $pt['discounted_price'] <= 0) {
1843 $pt['discounted_price'] = $eff;
1844 }
1845 }
1846 if ($orig > 0 && (!isset($pt['original_price']) || (float) $pt['original_price'] <= 0)) {
1847 $pt['original_price'] = $orig;
1848 }
1849 }
1850
1851 return $pt;
1852 }, $decoded);
1853 };
1854
1855 if (!empty($trip_data->price_types)) {
1856 $trip_data->price_types = $enrich_price_types($trip_data->price_types);
1857 }
1858
1859 $dp_display_settings = apply_filters('yatra_get_dynamic_pricing_display_settings', [
1860 'show_original_price' => true,
1861 'show_savings_badge' => true,
1862 'show_urgency_messages' => false,
1863 ]);
1864
1865 if ($has_availability) {
1866 $current_time = time();
1867
1868 foreach ($availability_dates_for_render as $avail) {
1869 if (empty($avail->departure_date)) {
1870 // Skip entries without a valid departure date
1871 continue;
1872 }
1873
1874 $departure_date = strtotime($avail->departure_date);
1875
1876 // Check booking cutoff - show all dates regardless of cutoff time
1877 $cutoff_hours = (int) ($avail->cutoff_hours ?? 24); // Default 24 hours before
1878 $departure_time_str = !empty($avail->departure_time) ? $avail->departure_time : '00:00:00';
1879 $departure_datetime = strtotime($avail->departure_date . ' ' . $departure_time_str);
1880 $cutoff_datetime = $departure_datetime - ($cutoff_hours * 3600);
1881
1882 // Show all dates even if past cutoff time
1883 $is_past_cutoff = $current_time > $cutoff_datetime;
1884
1885 // Show all dates even if no seats available
1886 $seats = (int) ($avail->seats_available ?? 0);
1887 $is_sold_out = $seats <= 0;
1888
1889 // Use arrival_date if set, otherwise return_date, otherwise calculate from duration
1890 $return_date = !empty($avail->arrival_date) ? strtotime($avail->arrival_date) :
1891 (!empty($avail->return_date) ? strtotime($avail->return_date) :
1892 strtotime($avail->departure_date . ' + ' . (($trip_data->duration_days ?? 1) - 1) . ' days'));
1893
1894 // Pricing: Use centralized TripPricingService (single source of truth)
1895 $cardPricing = \Yatra\Services\TripPricingService::resolveCardPricing($avail, $trip_data);
1896 $card_pricing_type = $cardPricing['pricing_type'];
1897 $sale_price = $cardPricing['sale_price'];
1898 $original_price = $cardPricing['original_price'];
1899
1900 // Store base prices before dynamic pricing
1901 $base_original_price = $original_price;
1902 $base_sale_price = $sale_price;
1903
1904 // Apply dynamic pricing if enabled (Pro DynamicPricingModule hooks here).
1905 // Single pass on the effective sale price; list/original stays for strikethrough. Context supplies both for "regular vs discounted" rule base.
1906 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1907 $dp_context = [
1908 'departure_date' => $avail->departure_date ?? null,
1909 'spots_remaining' => $seats,
1910 'availability_id' => $avail->id ?? null,
1911 'original_price' => $base_original_price,
1912 'discounted_price' => $base_sale_price,
1913 ];
1914 $sale_price = apply_filters('yatra_availability_price', $base_sale_price, $trip_data->id, $dp_context);
1915 }
1916
1917 // Savings badge: surge vs pre-DP sale first when DP raises price; else total % off vs list
1918 // (covers regular + traveler-based + date-level pricing; DP stacked on sale is reflected in final vs list).
1919 $discount_text = $this->computeAvailabilitySavingsBadgeText(
1920 $base_original_price,
1921 $base_sale_price,
1922 $sale_price,
1923 (bool) apply_filters('yatra_dynamic_pricing_enabled', false)
1924 );
1925
1926 // Dynamic Pricing → Display: hide savings / surge % badge on card when disabled.
1927 if (is_array($dp_display_settings) && !filter_var($dp_display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN)) {
1928 $discount_text = '';
1929 }
1930
1931 $dp_card_fields = $this->buildAvailabilityDynamicPricingCardFields(
1932 $dp_display_settings,
1933 (int) $trip_data->id,
1934 [
1935 'departure_date' => $avail->departure_date ?? null,
1936 'spots_remaining' => $seats,
1937 'availability_id' => $avail->id ?? null,
1938 'base_sale_price' => $base_sale_price,
1939 'base_original_price' => $base_original_price,
1940 'sale_price' => $sale_price,
1941 'original_price' => $original_price,
1942 ]
1943 );
1944
1945 // Use month-based filters for both day trips and multi-day trips for better navigation
1946 // This prevents overwhelming users with too many individual date filters
1947 $month_key = strtolower(date('M-Y', $departure_date));
1948 $month_filters[$month_key] = date_i18n('M Y', $departure_date);
1949
1950 $from_location = !empty($avail->from_location) ? $avail->from_location : ($trip_data->starting_location ?? '');
1951 $to_location = !empty($avail->to_location) ? $avail->to_location : ($trip_data->ending_location ?? $from_location);
1952
1953 // For day trips, format time; for multi-day trips, format date
1954 $departure_time = !empty($avail->departure_time) ? $avail->departure_time : null;
1955 $arrival_time = !empty($avail->arrival_time) ? $avail->arrival_time : null;
1956
1957 // Format display strings based on trip type (respect Yatra Settings date/time formats)
1958 $yatra_date_format = \Yatra\Services\SettingsService::getString('date_format', 'Y-m-d');
1959 $yatra_time_format = \Yatra\Services\SettingsService::getString('time_format', 'H:i');
1960
1961 if ($is_single_day && $departure_time) {
1962 // Day trip: Show time as main value, date as sub-label
1963 $from_display = date_i18n($yatra_time_format, strtotime($departure_time)); // e.g., "14:30" or "2:30 PM"
1964 $to_display = $arrival_time ? date_i18n($yatra_time_format, strtotime($arrival_time)) : '';
1965 // Show day-trip header date using configured format
1966 $date_display = date_i18n($yatra_date_format, $departure_date);
1967 $from_label = __('Start', 'yatra');
1968 $to_label = __('End', 'yatra');
1969 } else {
1970 // Multi-day trip: Show dates
1971 $from_display = date_i18n($yatra_date_format, $departure_date);
1972 $to_display = date_i18n($yatra_date_format, $return_date);
1973 $date_display = ''; // Not needed for multi-day
1974 $from_label = __('Departure', 'yatra');
1975 $to_label = __('Return', 'yatra');
1976 }
1977
1978 // Use month-based keys for filtering for both day trips and multi-day trips
1979 $filter_key = strtolower(date('M-Y', $departure_date));
1980
1981 // Must match {@see TripPricingService::resolveCardPricing}: trip-level mode wins; do not
1982 // treat inherited stale price_types on a date as traveler-based when the trip is regular.
1983 $card_pricing_type = $cardPricing['pricing_type'];
1984 $card_traveler_pricing = [];
1985 $pts_for_card = $cardPricing['price_types'] ?? [];
1986 if (!empty($pts_for_card) && is_array($pts_for_card)) {
1987 $card_traveler_pricing = $enrich_price_types($pts_for_card);
1988 }
1989
1990 $availability_cards[] = [
1991 'id' => $avail->id,
1992 'from_label' => $from_label,
1993 'from_date' => $from_display,
1994 'from_location' => $from_location,
1995 'to_label' => $to_label,
1996 'to_date' => $to_display,
1997 'to_location' => $to_location,
1998 'date_display' => $date_display, // For day trips: "Saturday, 30 Nov 2025"
1999 'date' => $avail->departure_date, // Raw date for dynamic pricing
2000 'spots_remaining' => $seats, // For dynamic pricing
2001 'seats' => $seats > 10 ? '10+' : (string) $seats,
2002 'seats_available' => $seats,
2003 'discount_text' => $discount_text,
2004 'original_price' => $original_price,
2005 'sale_price' => $sale_price,
2006 'title' => $trip_data->title,
2007 'type' => __('Group Departure', 'yatra'),
2008 'start_date' => $from_display,
2009 'end_date' => $to_display,
2010 'start_location' => $from_location,
2011 'end_location' => $to_location,
2012 'data_month' => $filter_key,
2013 'data_date' => $avail->departure_date,
2014 'departure_time' => $departure_time,
2015 'arrival_time' => $arrival_time,
2016 'is_day_trip' => $is_single_day,
2017 'status' => $avail->status ?? 'available',
2018 'is_limited' => $seats <= 5 && $seats > 0,
2019 'is_sold_out' => $is_sold_out,
2020 // Card-specific pricing
2021 'pricing_type' => $card_pricing_type,
2022 'traveler_pricing' => $card_traveler_pricing,
2023 'is_recurring' => !empty($avail->is_recurring),
2024 'rule_id' => $avail->rule_id ?? null,
2025 ] + $dp_card_fields;
2026 }
2027 }
2028
2029 // Use sample data only if no real availability
2030 if (empty($availability_cards)) {
2031 $sample_original = (float) ($trip_data->original_price ?? $trip_data->price ?? 0);
2032 $sample_sale = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip_data) ?: $sample_original;
2033 $sample_date = date('Y-m-d', strtotime('+7 days'));
2034 $sample_seats = 15;
2035
2036 // Store base prices before dynamic pricing
2037 $base_sample_original = $sample_original;
2038 $base_sample_sale = $sample_sale;
2039
2040 // Apply dynamic pricing to sample card (sale line only; list price unchanged for display)
2041 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
2042 $sample_sale = apply_filters('yatra_availability_price', $base_sample_sale, $trip_data->id, [
2043 'departure_date' => $sample_date,
2044 'spots_remaining' => $sample_seats,
2045 'availability_id' => 'sample-1',
2046 'original_price' => $base_sample_original,
2047 'discounted_price' => $base_sample_sale,
2048 ]);
2049 }
2050
2051 $sample_discount_text = $this->computeAvailabilitySavingsBadgeText(
2052 $base_sample_original,
2053 $base_sample_sale,
2054 $sample_sale,
2055 (bool) apply_filters('yatra_dynamic_pricing_enabled', false)
2056 );
2057
2058 if (is_array($dp_display_settings) && !filter_var($dp_display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN)) {
2059 $sample_discount_text = '';
2060 }
2061
2062 $sample_dp_fields = $this->buildAvailabilityDynamicPricingCardFields(
2063 $dp_display_settings,
2064 (int) $trip_data->id,
2065 [
2066 'departure_date' => $sample_date,
2067 'spots_remaining' => $sample_seats,
2068 'availability_id' => 'sample-1',
2069 'base_sale_price' => $base_sample_sale,
2070 'base_original_price' => $base_sample_original,
2071 'sale_price' => $sample_sale,
2072 'original_price' => $sample_original,
2073 ]
2074 );
2075
2076 $availability_cards = [
2077 [
2078 'id' => 'sample-1',
2079 'from_label' => __('Departure', 'yatra'),
2080 'from_date' => date_i18n('j M Y', strtotime('+7 days')),
2081 'from_location' => $trip_data->starting_location ?: __('Starting Point', 'yatra'),
2082 'to_label' => __('Return', 'yatra'),
2083 'to_date' => date_i18n('j M Y', strtotime('+' . (7 + ($trip_data->duration_days ?? 5) - 1) . ' days')),
2084 'to_location' => $trip_data->ending_location ?: ($trip_data->starting_location ?: __('Ending Point', 'yatra')),
2085 'seats' => '10+',
2086 'seats_available' => $sample_seats,
2087 'discount_text' => $sample_discount_text,
2088 'original_price' => $sample_original,
2089 'sale_price' => $sample_sale,
2090 'title' => $trip_data->title,
2091 'type' => __('Group Departure', 'yatra'),
2092 'start_date' => date_i18n('j M Y', strtotime('+7 days')),
2093 'end_date' => date_i18n('j M Y', strtotime('+' . (7 + ($trip_data->duration_days ?? 5) - 1) . ' days')),
2094 'start_location' => $trip_data->starting_location ?: __('Starting Point', 'yatra'),
2095 'end_location' => $trip_data->ending_location ?: ($trip_data->starting_location ?: __('Ending Point', 'yatra')),
2096 'data_month' => strtolower(date('M-Y', strtotime('+7 days'))),
2097 'data_date' => date('Y-m-d', strtotime('+7 days')),
2098 'status' => 'available',
2099 'is_limited' => false,
2100 // Use trip-level pricing for sample data
2101 'pricing_type' => $trip_data->pricing_type ?? 'regular',
2102 'traveler_pricing' => $trip_data->price_types ?? [],
2103 'is_recurring' => false,
2104 'rule_id' => null,
2105 ] + $sample_dp_fields,
2106 ];
2107 $month_filters[strtolower(date('M-Y', strtotime('+7 days')))] = date_i18n('M Y', strtotime('+7 days'));
2108 }
2109
2110 $sorted_cards = $this->sortAvailabilityCards($availability_cards, $sort_key);
2111
2112 $pin_date = '';
2113 if (!$fragment_cards_only) {
2114 $pin_candidate = trim((string) $selected_date);
2115 if ($pin_candidate !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $pin_candidate)) {
2116 $pin_date = $pin_candidate;
2117 }
2118 }
2119
2120 $slice = $this->computeAvailabilityPage($sorted_cards, $month_filter, $page, $per_page, $pin_date);
2121
2122 $pricing_type = $trip_data->pricing_type ?? 'regular';
2123 $price_types = $trip_data->price_types ?? [];
2124 $is_day_trip = ($trip_data->duration_days ?? 1) <= 1;
2125
2126 $initial_travelers = $travelers;
2127 $initial_num_travelers = $num_travelers;
2128 $initial_selected_date = $selected_date;
2129
2130 $selected_month_filter = strtolower($month_filter ?: 'all');
2131 $selected_date_filter = !empty($selected_date) ? $selected_date : $auto_selected_date;
2132
2133 if ($fragment_cards_only) {
2134 foreach ($slice['items'] as $index => $card) {
2135 include YATRA_PLUGIN_PATH . 'templates/partials/availability-card.php';
2136 }
2137
2138 return $slice;
2139 }
2140
2141 $availability_cards = $slice['items'];
2142 $availability_total_matching = $slice['total'];
2143 $availability_page = $slice['page'];
2144 $availability_per_page = $slice['per_page'];
2145 $availability_has_more = $slice['has_more'];
2146 $availability_loaded_count = $slice['loaded_count'];
2147
2148 // Month filter active but no matching departures while other months exist
2149 $availability_filtered_no_results = $selected_month_filter !== 'all'
2150 && $slice['total'] === 0
2151 && !empty($month_filters);
2152
2153 $template_path = YATRA_PLUGIN_PATH . 'templates/partials/availability-section.php';
2154
2155 if (file_exists($template_path)) {
2156 include $template_path;
2157 }
2158
2159 return $slice;
2160 }
2161
2162 /**
2163 * "% OFF" / "+%" badge for availability cards after dynamic pricing is applied to the sale line.
2164 *
2165 * - If dynamic pricing is on and the final price is above the pre-DP sale, show surge vs that sale (priority).
2166 * - Otherwise, if list/original on the card is above the final price, show total % off vs list (trip/date
2167 * discount + any extra DP discount in one number — never understates vs showing only the old catalog %).
2168 * - If there is no list price but DP reduced the promo-only anchor, show % off vs that anchor.
2169 *
2170 * Works for regular, traveler-based (uses same header O/B/F from {@see TripPricingService::resolveCardPricing}),
2171 * and availability date pricing (already in O/B from the card resolver).
2172 */
2173 private function computeAvailabilitySavingsBadgeText(
2174 float $base_original_price,
2175 float $base_sale_price,
2176 float $final_sale_price,
2177 bool $dynamic_pricing_enabled
2178 ): string {
2179 $O = max(0.0, $base_original_price);
2180 $B = max(0.0, $base_sale_price);
2181 $F = max(0.0, $final_sale_price);
2182 $eps = 0.005;
2183
2184 if ($dynamic_pricing_enabled && $B > $eps && $F > $B + $eps) {
2185 $p = (int) round((($F - $B) / $B) * 100);
2186
2187 return $p > 0 ? sprintf(__('+%d%%', 'yatra'), $p) : '';
2188 }
2189
2190 if ($O > $eps && $F < $O - $eps) {
2191 $p = (int) round((($O - $F) / $O) * 100);
2192
2193 return $p > 0 ? sprintf(__('%d%% OFF', 'yatra'), $p) : '';
2194 }
2195
2196 if ($O <= $eps && $B > $eps && $F < $B - $eps) {
2197 $p = (int) round((($B - $F) / $B) * 100);
2198
2199 return $p > 0 ? sprintf(__('%d%% OFF', 'yatra'), $p) : '';
2200 }
2201
2202 return '';
2203 }
2204
2205 /**
2206 * Per-departure-card dynamic pricing display flags + urgency lines (Pro fills via filter).
2207 *
2208 * @param array<string, mixed> $display_settings From yatra_get_dynamic_pricing_display_settings
2209 * @param array<string, mixed> $context departure_date, spots_remaining, prices, availability_id, …
2210 * @return array{dynamic_pricing_display: array<string, bool>, dynamic_pricing_urgency_messages: array<int, string>}
2211 */
2212 private function buildAvailabilityDynamicPricingCardFields(array $display_settings, int $trip_id, array $context): array
2213 {
2214 $display = [
2215 'show_original_price' => filter_var($display_settings['show_original_price'] ?? true, FILTER_VALIDATE_BOOLEAN),
2216 'show_savings_badge' => filter_var($display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN),
2217 'show_urgency_messages' => filter_var($display_settings['show_urgency_messages'] ?? false, FILTER_VALIDATE_BOOLEAN),
2218 ];
2219
2220 $meta = apply_filters(
2221 'yatra_availability_card_dynamic_pricing_meta',
2222 ['urgency_messages' => []],
2223 array_merge($context, [
2224 'trip_id' => $trip_id,
2225 'display' => $display,
2226 'dp_display_settings' => $display_settings,
2227 ])
2228 );
2229
2230 $urgency = [];
2231 if (is_array($meta) && !empty($meta['urgency_messages']) && is_array($meta['urgency_messages'])) {
2232 foreach ($meta['urgency_messages'] as $m) {
2233 $line = sanitize_text_field((string) $m);
2234 if ($line !== '') {
2235 $urgency[] = $line;
2236 }
2237 }
2238 $urgency = array_values(array_unique($urgency));
2239 }
2240
2241 return [
2242 'dynamic_pricing_display' => $display,
2243 'dynamic_pricing_urgency_messages' => $urgency,
2244 ];
2245 }
2246
2247 private function sortAvailabilityCards(array $cards, string $sort_key): array
2248 {
2249 $sort_key = sanitize_text_field($sort_key);
2250
2251 usort($cards, function ($a, $b) use ($sort_key) {
2252 $aDate = (string) ($a['data_date'] ?? '');
2253 $bDate = (string) ($b['data_date'] ?? '');
2254 $aTime = (string) ($a['departure_time'] ?? '');
2255 $bTime = (string) ($b['departure_time'] ?? '');
2256
2257 $aDateTime = trim($aDate . ' ' . $aTime);
2258 $bDateTime = trim($bDate . ' ' . $bTime);
2259
2260 $aPrice = (float) ($a['sale_price'] ?? 0);
2261 $bPrice = (float) ($b['sale_price'] ?? 0);
2262
2263 $aSeats = (int) ($a['seats_available'] ?? 0);
2264 $bSeats = (int) ($b['seats_available'] ?? 0);
2265
2266 if ($sort_key === 'date-desc') {
2267 $cmp = strcmp($bDateTime, $aDateTime);
2268 } elseif ($sort_key === 'price-asc') {
2269 $cmp = $aPrice <=> $bPrice;
2270 } elseif ($sort_key === 'price-desc') {
2271 $cmp = $bPrice <=> $aPrice;
2272 } elseif ($sort_key === 'seats-desc') {
2273 $cmp = $bSeats <=> $aSeats;
2274 } else {
2275 $cmp = strcmp($aDateTime, $bDateTime);
2276 }
2277
2278 if ($cmp !== 0) {
2279 return $cmp;
2280 }
2281
2282 return strcmp($aDateTime, $bDateTime);
2283 });
2284
2285 return $cards;
2286 }
2287
2288 /**
2289 * Merge specific availability dates with recurring generated dates
2290 * Specific dates take priority over recurring dates for the same date
2291 *
2292 * @param array $specificDates Array of specific date objects from database
2293 * @param array $recurringDates Array of generated recurring date objects
2294 * @return array Merged and sorted availability dates
2295 */
2296 private function mergeAvailabilityDates(array $specificDates, array $recurringDates): array
2297 {
2298 // Index specific dates by departure_date + departure_time for quick lookup
2299 $specificIndex = [];
2300 foreach ($specificDates as $date) {
2301 $key = $date->departure_date . '_' . ($date->departure_time ?? '');
2302 $specificIndex[$key] = true;
2303 }
2304
2305 // Filter out recurring dates that conflict with specific dates
2306 $filteredRecurring = [];
2307 foreach ($recurringDates as $date) {
2308 $key = $date->departure_date . '_' . ($date->departure_time ?? '');
2309 if (!isset($specificIndex[$key])) {
2310 $filteredRecurring[] = $date;
2311 }
2312 }
2313
2314 // Merge both arrays
2315 $merged = array_merge($specificDates, $filteredRecurring);
2316
2317 // Sort by departure_date, then departure_time
2318 usort($merged, function ($a, $b) {
2319 $dateCompare = strcmp($a->departure_date, $b->departure_date);
2320 if ($dateCompare !== 0) {
2321 return $dateCompare;
2322 }
2323 return strcmp($a->departure_time ?? '', $b->departure_time ?? '');
2324 });
2325
2326 return $merged;
2327 }
2328
2329 /**
2330 * Get date-specific pricing and availability info
2331 */
2332 public function get_date_pricing(\WP_REST_Request $request)
2333 {
2334 try {
2335 $trip_id = (int) $request->get_param('id');
2336 $date = sanitize_text_field($request->get_param('date'));
2337
2338 if (!$date) {
2339 return $this->error_response('Date parameter is required', 400);
2340 }
2341
2342 $trip = $this->service->getWithRelations($trip_id);
2343 if (!$trip) {
2344 return $this->error_response('Trip not found', 404);
2345 }
2346
2347 // Use TripService to count departures for this date
2348 $departures_count = $this->service->countDeparturesByDate($trip_id, $date);
2349
2350 // Generate travelers HTML with dynamic pricing
2351 ob_start();
2352 $pricing_type = $trip->pricing_type ?? 'regular';
2353 $price_types = $trip->price_types ?? [];
2354
2355 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
2356 // Apply dynamic pricing to each price type
2357 $dp_enabled = apply_filters('yatra_dynamic_pricing_enabled', false);
2358
2359 foreach ($price_types as &$pt) {
2360 $pt = is_array($pt) ? (object) $pt : $pt;
2361 $price = 0;
2362
2363 if (isset($pt->sale_price) && $pt->sale_price > 0) {
2364 $price = (float) $pt->sale_price;
2365 } elseif (isset($pt->original_price) && $pt->original_price > 0) {
2366 $price = (float) $pt->original_price;
2367 }
2368
2369 // Apply dynamic pricing
2370 if ($dp_enabled && $price > 0) {
2371 $pt_orig = (float) ($pt->original_price ?? 0);
2372 $pt_disc = (float) ($pt->sale_price ?? $pt->discounted_price ?? $pt->effective_price ?? $price);
2373 if ($pt_disc <= 0) {
2374 $pt_disc = $price;
2375 }
2376 $price = apply_filters('yatra_availability_price', $price, $trip_id, [
2377 'departure_date' => $date,
2378 'price_type_id' => $pt->id ?? null,
2379 'original_price' => $pt_orig > 0 ? $pt_orig : $price,
2380 'discounted_price' => $pt_disc > 0 ? $pt_disc : $price,
2381 ]);
2382 }
2383
2384 $pt->effective_price = $price;
2385 }
2386
2387 // Render traveler-based pricing HTML
2388 include YATRA_ABSPATH . '/templates/partials/booking-form-fields.php';
2389 } else {
2390 // Regular pricing - simple number input
2391 echo '<div class="yatra-booking-field">';
2392 echo '<label for="num_travelers">' . esc_html__('Number of Travelers', 'yatra') . '</label>';
2393 echo '<input type="number" id="num_travelers" name="num_travelers" value="1" min="1" max="' . esc_attr($trip->max_travelers ?? 20) . '" />';
2394 echo '</div>';
2395 }
2396
2397 $travelers_html = ob_get_clean();
2398
2399 return $this->success_response([
2400 'success' => true,
2401 'departures_count' => (int) $departures_count,
2402 'travelers_html' => $travelers_html,
2403 'pricing_type' => $pricing_type,
2404 ]);
2405 } catch (\Exception $e) {
2406 return $this->error_response($e->getMessage(), 500);
2407 }
2408 }
2409
2410 /**
2411 * Get public trips for frontend display
2412 * Only returns published trips, excludes soft-deleted trips
2413 */
2414 public function get_public_trips(WP_REST_Request $request)
2415 {
2416 try {
2417 $args = [
2418 'limit' => (int) ($request->get_param('per_page') ?: 20),
2419 'offset' => ((int) ($request->get_param('page') ?: 1) - 1) * (int) ($request->get_param('per_page') ?: 20),
2420 'order_by' => $request->get_param('orderby') ?: 'created_at',
2421 'order' => strtoupper($request->get_param('order') ?: 'DESC'),
2422 // Only return published trips for public endpoint
2423 'where' => ['status' => ['publish']],
2424 // Never include deleted trips for public endpoint
2425 'include_deleted' => false,
2426 ];
2427
2428 // Add search if provided
2429 $search = $request->get_param('search');
2430 if ($search) {
2431 $items = $this->service->search($search, $args);
2432 $total = count($items);
2433 } else {
2434 $items = $this->service->getAll($args);
2435 $total = $this->service->count($args);
2436 }
2437
2438 return $this->success_response([
2439 'data' => $items,
2440 'total' => $total,
2441 'per_page' => (int) ($request->get_param('per_page') ?: 20),
2442 'page' => (int) ($request->get_param('page') ?: 1),
2443 ]);
2444 } catch (\Exception $e) {
2445 return $this->error_response($e->getMessage(), 500);
2446 }
2447 }
2448
2449 /**
2450 * Get trip attributes
2451 */
2452 public function get_trip_attributes(WP_REST_Request $request)
2453 {
2454 try {
2455 $trip_id = (int) $request->get_param('id');
2456
2457 if (!$trip_id) {
2458 return $this->error_response('Trip ID is required', 400);
2459 }
2460
2461 // Use TripService to get trip attributes
2462 $attributes = $this->service->getTripAttributes($trip_id);
2463
2464 $formatted_attributes = [];
2465 foreach ($attributes as $attr) {
2466 $row = is_array($attr) ? (object) $attr : $attr;
2467
2468 $value = $row->value ?? null;
2469 if (isset($row->value_serialized) && $row->value_serialized && $value !== null && $value !== '') {
2470 $unserialized = @unserialize((string) $value, ['allowed_classes' => false]);
2471 if ($unserialized !== false || (string) $value === 'b:0;') {
2472 $value = $unserialized;
2473 }
2474 }
2475
2476 $fieldType = isset($row->field_type) ? trim((string) $row->field_type, '"') : 'text';
2477 $fieldOptions = $row->field_options ?? null;
2478 if (is_string($fieldOptions)) {
2479 $fieldOptions = trim($fieldOptions, '"');
2480 $decoded = json_decode($fieldOptions, true);
2481 if (json_last_error() === JSON_ERROR_NONE) {
2482 $fieldOptions = $decoded;
2483 }
2484 }
2485
2486 $linkId = (int) ($row->relationship_id ?? $row->id ?? 0);
2487 $attributeId = (int) ($row->attribute_id ?? 0);
2488
2489 $formatted_attributes[] = [
2490 'id' => $linkId > 0 ? $linkId : $attributeId,
2491 'attribute_id' => $attributeId,
2492 'name' => (string) ($row->name ?? ''),
2493 'field_type' => $fieldType,
2494 'field_options' => $fieldOptions,
2495 'value' => $value,
2496 'created_at' => (string) ($row->created_at ?? ''),
2497 'updated_at' => (string) ($row->updated_at ?? ''),
2498 ];
2499 }
2500
2501 return $this->success_response($formatted_attributes);
2502 } catch (\Exception $e) {
2503 return $this->error_response($e->getMessage(), 500);
2504 }
2505 }
2506
2507 /**
2508 * Test endpoint to verify routing works
2509 */
2510 public function test_endpoint(): WP_REST_Response
2511 {
2512 return $this->success_response(['message' => 'Test endpoint working', 'timestamp' => date('Y-m-d H:i:s')]);
2513 }
2514
2515 /**
2516 * Update trip attributes
2517 */
2518 public function update_trip_attributes(WP_REST_Request $request)
2519 {
2520 try {
2521 $trip_id = (int) $request->get_param('id');
2522 $attributes = $request->get_param('attributes') ?? [];
2523
2524 if (!$trip_id) {
2525 return $this->error_response('Trip ID is required', 400);
2526 }
2527
2528 if (!is_array($attributes)) {
2529 return $this->error_response('Attributes must be an array', 400);
2530 }
2531
2532 // Prepare attributes for TripService
2533 $formattedAttributes = [];
2534 foreach ($attributes as $attribute_id => $value) {
2535 $formattedAttributes[] = [
2536 'attribute_id' => $attribute_id,
2537 'value' => $value
2538 ];
2539 }
2540
2541 // Use TripService to update trip attributes
2542 $result = $this->service->updateTripAttributes($trip_id, $formattedAttributes);
2543 return $this->success_response(['message' => 'Trip attributes updated successfully']);
2544 } catch (\InvalidArgumentException $e) {
2545 return $this->error_response($e->getMessage(), $e->getCode() >= 400 ? $e->getCode() : 400);
2546 } catch (\Exception $e) {
2547 return $this->error_response($e->getMessage(), 500);
2548 }
2549 }
2550
2551 /**
2552 * Delete trip attribute
2553 */
2554 public function delete_trip_attribute(WP_REST_Request $request)
2555 {
2556 try {
2557 $trip_id = (int) $request->get_param('id');
2558 $attribute_id = (int) $request->get_param('attribute_id');
2559
2560 if (!$trip_id || !$attribute_id) {
2561 return $this->error_response('Trip ID and Attribute ID are required', 400);
2562 }
2563
2564 // Use TripService to delete trip attribute
2565 $result = $this->service->deleteTripAttribute($trip_id, $attribute_id);
2566
2567 if (!$result) {
2568 return $this->error_response('Failed to delete trip attribute', 500);
2569 }
2570
2571 return $this->success_response(['message' => 'Trip attribute deleted successfully']);
2572 } catch (\Exception $e) {
2573 return $this->error_response($e->getMessage(), 500);
2574 }
2575 }
2576 }
2577