PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 trunk, at app/Controllers/TripController.php

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