PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Controllers / TripController.php

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

2,661 lines 113.2 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
1520 // highlighting), up to the configurable booking horizon (Settings →
1521 // Booking; 12 months unless changed — the previous hard-coded value).
1522 $fromDate = date('Y-m-d');
1523 $toDate = yatra_get_availability_horizon_date($fromDate);
1524
1525 $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate, \Yatra\Services\SettingsService::isEnabled('show_sold_out'));
1526
1527 // Determine if this is a day trip
1528 $is_single_day = ($trip->duration_days ?? 1) <= 1;
1529
1530 // Auto-select month and date
1531 $auto_selected_month = '';
1532 $auto_selected_date = '';
1533
1534 if (!empty($availability_dates)) {
1535 // Legacy UI hint: month of selected date (response JSON); list filter uses month_filter
1536 if (!empty($selected_date)) {
1537 // Use selected date's month (always month-based now)
1538 $selected_timestamp = strtotime($selected_date);
1539 $auto_selected_month = strtolower(date('M-Y', $selected_timestamp));
1540 } else {
1541 // Use first available date's month (always month-based now)
1542 $first_avail = reset($availability_dates);
1543 if (!empty($first_avail->departure_date)) {
1544 $first_date = strtotime($first_avail->departure_date);
1545 $auto_selected_month = strtolower(date('M-Y', $first_date));
1546 }
1547 }
1548
1549 // Find closest available date
1550 if (!empty($selected_date)) {
1551 // Check if selected date is available
1552 $date_found = false;
1553 foreach ($availability_dates as $avail) {
1554 if (!empty($avail->departure_date) && $avail->departure_date === $selected_date) {
1555 $auto_selected_date = $selected_date;
1556 $date_found = true;
1557 break;
1558 }
1559 }
1560
1561 // If selected date not available, find closest
1562 if (!$date_found) {
1563 $selected_timestamp = strtotime($selected_date);
1564 $closest_date = null;
1565 $min_diff = PHP_INT_MAX;
1566
1567 foreach ($availability_dates as $avail) {
1568 if (!empty($avail->departure_date)) {
1569 $avail_timestamp = strtotime($avail->departure_date);
1570 $diff = abs($avail_timestamp - $selected_timestamp);
1571
1572 if ($diff < $min_diff) {
1573 $min_diff = $diff;
1574 $closest_date = $avail->departure_date;
1575 }
1576 }
1577 }
1578
1579 $auto_selected_date = $closest_date ?? '';
1580 }
1581 } else {
1582 // No date provided, select first available date
1583 $first_avail = reset($availability_dates);
1584 $auto_selected_date = $first_avail->departure_date ?? '';
1585 }
1586 }
1587
1588 // Prepare trip data for template
1589 $trip_data = (object) [
1590 'id' => $trip->id,
1591 'title' => $trip->title ?? '',
1592 'starting_location' => $trip->starting_location ?? '',
1593 'ending_location' => $trip->ending_location ?? '',
1594 'original_price' => isset($trip->original_price) ? (float) $trip->original_price : 0,
1595 'discounted_price' => isset($trip->discounted_price) ? (float) $trip->discounted_price : 0,
1596 'sale_price' => isset($trip->sale_price) ? (float) $trip->sale_price : 0,
1597 'currency' => SettingsService::getCurrency(),
1598 'duration_days' => isset($trip->duration_days) ? (int) $trip->duration_days : 1,
1599 // Hour-based day tours show "8 Hours" instead of "1 Day" on the
1600 // availability cards. 0 for every existing (day-based) trip.
1601 'duration_hours' => isset($trip->duration_hours) ? (int) $trip->duration_hours : 0,
1602 'max_travelers' => isset($trip->max_travelers) ? (int) $trip->max_travelers : 20,
1603 'min_travelers' => isset($trip->min_travelers) ? (int) $trip->min_travelers : 1,
1604 'pricing_type' => $trip->pricing_type ?? 'regular',
1605 'price_types' => $trip->price_types ?? [], // Include price_types for traveler-based pricing
1606 'availability_dates' => $availability_dates,
1607 ];
1608
1609 // Start output buffering
1610 ob_start();
1611
1612 $slice_meta = $this->render_availability_template(
1613 $trip_data,
1614 $sort_key,
1615 $travelers,
1616 $num_travelers,
1617 $selected_date,
1618 $auto_selected_month,
1619 $auto_selected_date,
1620 $month_filter,
1621 $page,
1622 $per_page,
1623 $partial
1624 );
1625
1626 $html = ob_get_clean();
1627
1628 $payload = [
1629 'html' => $html,
1630 'selected_month' => $month_filter,
1631 'selected_date' => $auto_selected_date,
1632 'month_filter' => $month_filter,
1633 'sort' => $sort_key,
1634 'total' => $slice_meta['total'],
1635 'page' => $slice_meta['page'],
1636 'per_page' => $slice_meta['per_page'],
1637 'has_more' => $slice_meta['has_more'],
1638 'loaded_count' => $slice_meta['loaded_count'],
1639 'partial' => $partial,
1640 ];
1641
1642 return $this->success_response($payload);
1643 } catch (\Exception $e) {
1644 return $this->error_response($e->getMessage(), 500);
1645 }
1646 }
1647
1648 /**
1649 * Normalize departure date to Y-m-d for comparisons (handles datetime strings).
1650 */
1651 private static function normalizeAvailabilityDateString(?string $value): string
1652 {
1653 $value = trim((string) $value);
1654 if ($value === '') {
1655 return '';
1656 }
1657 if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $value, $m)) {
1658 return $m[1];
1659 }
1660
1661 return $value;
1662 }
1663
1664 /**
1665 * Paginate filtered availability cards (after sort).
1666 * When $pin_date is Y-m-d and that departure exists in the filtered list, the page is
1667 * adjusted so that row is included (fixes sidebar picking e.g. Aug 13 while page 1 only had Aug 1–10).
1668 *
1669 * @return array{items: array, total: int, page: int, per_page: int, has_more: bool, loaded_count: int}
1670 */
1671 private function computeAvailabilityPage(
1672 array $sorted_cards,
1673 string $month_filter,
1674 int $page,
1675 int $per_page,
1676 string $pin_date = ''
1677 ): array {
1678 $month_filter = strtolower($month_filter ?: 'all');
1679 $filtered = $sorted_cards;
1680 if ($month_filter !== 'all') {
1681 $filtered = array_values(array_filter(
1682 $sorted_cards,
1683 static function (array $c) use ($month_filter): bool {
1684 return (string) ($c['data_month'] ?? '') === $month_filter;
1685 }
1686 ));
1687 }
1688
1689 $total = count($filtered);
1690 $per_page = max(1, min(50, $per_page));
1691 $page = max(1, $page);
1692
1693 $pin_date = trim($pin_date);
1694 if ($pin_date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $pin_date)) {
1695 $pin_norm = self::normalizeAvailabilityDateString($pin_date);
1696 foreach ($filtered as $idx => $c) {
1697 $card_norm = self::normalizeAvailabilityDateString((string) ($c['data_date'] ?? ''));
1698 if ($card_norm !== '' && $card_norm === $pin_norm) {
1699 $page = (int) (floor((int) $idx / $per_page) + 1);
1700 break;
1701 }
1702 }
1703 }
1704
1705 $offset = ($page - 1) * $per_page;
1706 $items = array_slice($filtered, $offset, $per_page);
1707 $loaded_count = $offset + count($items);
1708
1709 return [
1710 'items' => $items,
1711 'total' => $total,
1712 'page' => $page,
1713 'per_page' => $per_page,
1714 'has_more' => $loaded_count < $total,
1715 'loaded_count' => $loaded_count,
1716 ];
1717 }
1718
1719 /**
1720 * Render availability template (full section or card fragment only).
1721 *
1722 * @return array{total: int, page: int, per_page: int, has_more: bool, loaded_count: int}
1723 */
1724 private function render_availability_template(
1725 $trip_data,
1726 string $sort_key = 'date-asc',
1727 array $travelers = [],
1728 int $num_travelers = 1,
1729 string $selected_date = '',
1730 string $auto_selected_month = '',
1731 string $auto_selected_date = '',
1732 string $month_filter = 'all',
1733 int $page = 1,
1734 int $per_page = 10,
1735 bool $fragment_cards_only = false
1736 ): array {
1737 // Check if we have real availability data
1738 $has_availability = !empty($trip_data->availability_dates) && is_array($trip_data->availability_dates);
1739
1740 // Build cards from real availability data or use sample data
1741 $availability_cards = [];
1742 $month_filters = [];
1743
1744 // Availability priority (same as the resolver):
1745 // 1) manual availability dates, 2) recurring rules, 3) trip defaults.
1746 // For UI counts + filters we want the list to reflect that priority (not a mixed set).
1747 $availability_dates_for_render = $has_availability ? $trip_data->availability_dates : [];
1748 if ($has_availability) {
1749 $by_source = [
1750 'availability_date' => [],
1751 'recurring_rule' => [],
1752 'trip_default' => [],
1753 ];
1754 foreach ($trip_data->availability_dates as $a) {
1755 if (!is_object($a)) {
1756 continue;
1757 }
1758 $src = strtolower(trim((string) ($a->source ?? '')));
1759 if (isset($by_source[$src])) {
1760 $by_source[$src][] = $a;
1761 }
1762 }
1763 if (!empty($by_source['availability_date'])) {
1764 $availability_dates_for_render = $by_source['availability_date'];
1765 } elseif (!empty($by_source['recurring_rule'])) {
1766 $availability_dates_for_render = $by_source['recurring_rule'];
1767 } elseif (!empty($by_source['trip_default'])) {
1768 $availability_dates_for_render = $by_source['trip_default'];
1769 }
1770 }
1771
1772 // Determine if this is a day trip (duration <= 1 day)
1773 $is_single_day = ($trip_data->duration_days ?? 1) <= 1;
1774
1775 $traveler_category_labels = [];
1776 $traveler_category_meta = [];
1777 $traveler_category_ids = [];
1778 $add_category_ids = static function ($price_types_raw) use (&$traveler_category_ids): void {
1779 if (empty($price_types_raw)) {
1780 return;
1781 }
1782
1783 $decoded = $price_types_raw;
1784 if (is_string($price_types_raw)) {
1785 $decoded = json_decode($price_types_raw, true) ?: [];
1786 }
1787
1788 if (!is_array($decoded)) {
1789 return;
1790 }
1791
1792 foreach ($decoded as $pt) {
1793 if (is_object($pt)) {
1794 $pt = (array) $pt;
1795 }
1796 if (!is_array($pt)) {
1797 continue;
1798 }
1799 $cat_id = $pt['category_id'] ?? null;
1800 if ($cat_id !== null && $cat_id !== '') {
1801 $traveler_category_ids[] = (string) $cat_id;
1802 }
1803 }
1804 };
1805
1806 if (!empty($trip_data->price_types) && is_array($trip_data->price_types)) {
1807 $add_category_ids($trip_data->price_types);
1808 }
1809
1810 if ($has_availability) {
1811 foreach ($availability_dates_for_render as $avail_for_cats) {
1812 if (!empty($avail_for_cats->price_types)) {
1813 $add_category_ids($avail_for_cats->price_types);
1814 }
1815 if (!empty($avail_for_cats->traveler_pricing)) {
1816 $add_category_ids($avail_for_cats->traveler_pricing);
1817 }
1818 }
1819 }
1820
1821 $traveler_category_ids = array_values(array_unique(array_filter($traveler_category_ids)));
1822
1823 if (!empty($traveler_category_ids)) {
1824 $traveler_category_repo = new TravelerCategoryRepository();
1825 $categories = $traveler_category_repo->all([
1826 'where' => [
1827 'id' => $traveler_category_ids,
1828 ],
1829 ]);
1830
1831 foreach ($categories as $cat) {
1832 // Use 'name' field from database, not 'label'
1833 if (!empty($cat->id) && isset($cat->name)) {
1834 $traveler_category_labels[(string) $cat->id] = (string) $cat->name;
1835 }
1836 // Parse metadata for pricing_mode, age_min, age_max, min_pax, max_pax
1837 $meta = !empty($cat->metadata) ? (is_string($cat->metadata) ? json_decode($cat->metadata, true) : (array) $cat->metadata) : [];
1838 $traveler_category_meta[(string) $cat->id] = [
1839 'pricing_mode' => $meta['pricing_mode'] ?? 'per_person',
1840 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null,
1841 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null,
1842 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null,
1843 'max_pax' => isset($meta['max_pax']) ? (int) $meta['max_pax'] : null,
1844 'group_overflow' => isset($meta['group_overflow']) && in_array($meta['group_overflow'], ['block', 'per_block'], true) ? $meta['group_overflow'] : 'block',
1845 ];
1846 }
1847 }
1848
1849 $enrich_price_types = static function ($price_types_raw) use ($traveler_category_labels, $traveler_category_meta): array {
1850 if (empty($price_types_raw)) {
1851 return [];
1852 }
1853
1854 $decoded = $price_types_raw;
1855 if (is_string($price_types_raw)) {
1856 $decoded = json_decode($price_types_raw, true) ?: [];
1857 }
1858
1859 if (!is_array($decoded)) {
1860 return [];
1861 }
1862
1863 return array_map(static function ($pt) use ($traveler_category_labels, $traveler_category_meta) {
1864 if (is_object($pt)) {
1865 $pt = (array) $pt;
1866 }
1867 if (!is_array($pt)) {
1868 return $pt;
1869 }
1870
1871 if (empty($pt['category_label']) && !empty($pt['traveler_category_label'])) {
1872 $pt['category_label'] = $pt['traveler_category_label'];
1873 }
1874
1875 $cat_id = $pt['category_id'] ?? null;
1876 if ((empty($pt['category_label']) && empty($pt['label'])) && $cat_id !== null) {
1877 $label = $traveler_category_labels[(string) $cat_id] ?? null;
1878 if (!empty($label)) {
1879 $pt['category_label'] = $label;
1880 }
1881 }
1882
1883 if (empty($pt['label']) && !empty($pt['category_label'])) {
1884 $pt['label'] = $pt['category_label'];
1885 }
1886
1887 // Enrich with category metadata (pricing_mode, age, pax limits)
1888 if ($cat_id !== null && isset($traveler_category_meta[(string) $cat_id])) {
1889 $meta = $traveler_category_meta[(string) $cat_id];
1890 // Always use category metadata pricing_mode to ensure correct mode from database
1891 $pt['pricing_mode'] = $meta['pricing_mode'];
1892 if (!isset($pt['age_min'])) $pt['age_min'] = $meta['age_min'];
1893 if (!isset($pt['age_max'])) $pt['age_max'] = $meta['age_max'];
1894 if (!isset($pt['min_pax'])) $pt['min_pax'] = $meta['min_pax'];
1895 if (!isset($pt['max_pax'])) $pt['max_pax'] = $meta['max_pax'];
1896 $pt['group_overflow'] = $meta['group_overflow'] ?? 'block';
1897 }
1898
1899 // Payable amount (honors price / sale_price / discounted_price like TripPricingService)
1900 if (!isset($pt['effective_price'])) {
1901 $eff = TripPricingService::resolveCategoryEffectivePrice($pt);
1902 $pt['effective_price'] = $eff;
1903 $orig = (float) ($pt['original_price'] ?? 0);
1904 if ($orig <= 0 && isset($pt['price'])) {
1905 $orig = (float) $pt['price'];
1906 }
1907 if ($orig > 0 && $eff > 0 && $eff < $orig) {
1908 if (!isset($pt['discounted_price']) || (float) $pt['discounted_price'] <= 0) {
1909 $pt['discounted_price'] = $eff;
1910 }
1911 }
1912 if ($orig > 0 && (!isset($pt['original_price']) || (float) $pt['original_price'] <= 0)) {
1913 $pt['original_price'] = $orig;
1914 }
1915 }
1916
1917 return $pt;
1918 }, $decoded);
1919 };
1920
1921 if (!empty($trip_data->price_types)) {
1922 $trip_data->price_types = $enrich_price_types($trip_data->price_types);
1923 }
1924
1925 $dp_display_settings = apply_filters('yatra_get_dynamic_pricing_display_settings', [
1926 'show_original_price' => true,
1927 'show_savings_badge' => true,
1928 'show_urgency_messages' => false,
1929 ]);
1930
1931 if ($has_availability) {
1932 $current_time = time();
1933
1934 foreach ($availability_dates_for_render as $avail) {
1935 if (empty($avail->departure_date)) {
1936 // Skip entries without a valid departure date
1937 continue;
1938 }
1939
1940 $departure_date = strtotime($avail->departure_date);
1941
1942 // Check booking cutoff - show all dates regardless of cutoff time
1943 $cutoff_hours = (int) ($avail->cutoff_hours ?? 24); // Default 24 hours before
1944 $departure_time_str = !empty($avail->departure_time) ? $avail->departure_time : '00:00:00';
1945 $departure_datetime = strtotime($avail->departure_date . ' ' . $departure_time_str);
1946 $cutoff_datetime = $departure_datetime - ($cutoff_hours * 3600);
1947
1948 // Show all dates even if past cutoff time
1949 $is_past_cutoff = $current_time > $cutoff_datetime;
1950
1951 // Show all dates even if no seats available
1952 $seats = (int) ($avail->seats_available ?? 0);
1953 $is_sold_out = $seats <= 0;
1954
1955 // Use arrival_date if set, otherwise return_date, otherwise calculate from duration
1956 $return_date = !empty($avail->arrival_date) ? strtotime($avail->arrival_date) :
1957 (!empty($avail->return_date) ? strtotime($avail->return_date) :
1958 strtotime($avail->departure_date . ' + ' . (($trip_data->duration_days ?? 1) - 1) . ' days'));
1959
1960 // Pricing: Use centralized TripPricingService (single source of truth)
1961 $cardPricing = \Yatra\Services\TripPricingService::resolveCardPricing($avail, $trip_data);
1962 $card_pricing_type = $cardPricing['pricing_type'];
1963 $sale_price = $cardPricing['sale_price'];
1964 $original_price = $cardPricing['original_price'];
1965
1966 // Store base prices before dynamic pricing
1967 $base_original_price = $original_price;
1968 $base_sale_price = $sale_price;
1969
1970 // Apply dynamic pricing if enabled (Pro DynamicPricingModule hooks here).
1971 // Single pass on the effective sale price; list/original stays for strikethrough. Context supplies both for "regular vs discounted" rule base.
1972 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1973 $dp_context = [
1974 'departure_date' => $avail->departure_date ?? null,
1975 'spots_remaining' => $seats,
1976 'availability_id' => $avail->id ?? null,
1977 'original_price' => $base_original_price,
1978 'discounted_price' => $base_sale_price,
1979 ];
1980 $sale_price = apply_filters('yatra_availability_price', $base_sale_price, $trip_data->id, $dp_context);
1981 }
1982
1983 // Savings badge: surge vs pre-DP sale first when DP raises price; else total % off vs list
1984 // (covers regular + traveler-based + date-level pricing; DP stacked on sale is reflected in final vs list).
1985 $discount_text = $this->computeAvailabilitySavingsBadgeText(
1986 $base_original_price,
1987 $base_sale_price,
1988 $sale_price,
1989 (bool) apply_filters('yatra_dynamic_pricing_enabled', false)
1990 );
1991
1992 // Dynamic Pricing → Display: hide savings / surge % badge on card when disabled.
1993 if (is_array($dp_display_settings) && !filter_var($dp_display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN)) {
1994 $discount_text = '';
1995 }
1996
1997 $dp_card_fields = $this->buildAvailabilityDynamicPricingCardFields(
1998 $dp_display_settings,
1999 (int) $trip_data->id,
2000 [
2001 'departure_date' => $avail->departure_date ?? null,
2002 'spots_remaining' => $seats,
2003 'availability_id' => $avail->id ?? null,
2004 'base_sale_price' => $base_sale_price,
2005 'base_original_price' => $base_original_price,
2006 'sale_price' => $sale_price,
2007 'original_price' => $original_price,
2008 ]
2009 );
2010
2011 // Use month-based filters for both day trips and multi-day trips for better navigation
2012 // This prevents overwhelming users with too many individual date filters
2013 $month_key = strtolower(date('M-Y', $departure_date));
2014 $month_filters[$month_key] = date_i18n('M Y', $departure_date);
2015
2016 $from_location = !empty($avail->from_location) ? $avail->from_location : ($trip_data->starting_location ?? '');
2017 $to_location = !empty($avail->to_location) ? $avail->to_location : ($trip_data->ending_location ?? $from_location);
2018
2019 // For day trips, format time; for multi-day trips, format date
2020 $departure_time = !empty($avail->departure_time) ? $avail->departure_time : null;
2021 $arrival_time = !empty($avail->arrival_time) ? $avail->arrival_time : null;
2022
2023 // Format display strings based on trip type (respect Yatra Settings date/time formats)
2024 $yatra_date_format = \Yatra\Services\SettingsService::getString('date_format', 'Y-m-d');
2025 $yatra_time_format = \Yatra\Services\SettingsService::getString('time_format', 'H:i');
2026
2027 if ($is_single_day && $departure_time) {
2028 // Day trip: Show time as main value, date as sub-label
2029 $from_display = date_i18n($yatra_time_format, strtotime($departure_time)); // e.g., "14:30" or "2:30 PM"
2030 $to_display = $arrival_time ? date_i18n($yatra_time_format, strtotime($arrival_time)) : '';
2031 // Show day-trip header date using configured format
2032 $date_display = date_i18n($yatra_date_format, $departure_date);
2033 $from_label = __('Start', 'yatra');
2034 $to_label = __('End', 'yatra');
2035 } else {
2036 // Multi-day trip: Show dates
2037 $from_display = date_i18n($yatra_date_format, $departure_date);
2038 $to_display = date_i18n($yatra_date_format, $return_date);
2039 $date_display = ''; // Not needed for multi-day
2040 $from_label = __('Departure', 'yatra');
2041 $to_label = __('Return', 'yatra');
2042 }
2043
2044 // Per-card duration: derive from THIS card's departure→return span
2045 // so the displayed "X Days" always matches the departure/return
2046 // dates shown on the same card. When no custom arrival is stored,
2047 // $return_date is departure + (duration_days - 1), so the span
2048 // equals the trip's duration_days (no visible change). Only when an
2049 // operator stored an arrival that disagrees with the trip default
2050 // does this diverge — and then the customer sees a self-consistent
2051 // card (e.g. "10 Days" over a Jun 25 → Jul 04 span) instead of a
2052 // "9 Days" badge contradicting the dates. round() (not floor())
2053 // absorbs any ±1h DST drift between two local-midnight timestamps.
2054 $card_duration_days = $is_single_day
2055 ? max(1, (int) ($trip_data->duration_days ?? 1))
2056 : max(1, (int) round(($return_date - $departure_date) / DAY_IN_SECONDS) + 1);
2057
2058 // Use month-based keys for filtering for both day trips and multi-day trips
2059 $filter_key = strtolower(date('M-Y', $departure_date));
2060
2061 // Must match {@see TripPricingService::resolveCardPricing}: trip-level mode wins; do not
2062 // treat inherited stale price_types on a date as traveler-based when the trip is regular.
2063 $card_pricing_type = $cardPricing['pricing_type'];
2064 $card_traveler_pricing = [];
2065 $pts_for_card = $cardPricing['price_types'] ?? [];
2066 if (!empty($pts_for_card) && is_array($pts_for_card)) {
2067 $card_traveler_pricing = $enrich_price_types($pts_for_card);
2068 }
2069
2070 $availability_cards[] = [
2071 'id' => $avail->id,
2072 'from_label' => $from_label,
2073 'from_date' => $from_display,
2074 'from_location' => $from_location,
2075 'to_label' => $to_label,
2076 'to_date' => $to_display,
2077 'to_location' => $to_location,
2078 'date_display' => $date_display, // For day trips: "Saturday, 30 Nov 2025"
2079 'duration_days' => $card_duration_days, // Inclusive span of THIS card's dates
2080 'date' => $avail->departure_date, // Raw date for dynamic pricing
2081 'spots_remaining' => $seats, // For dynamic pricing
2082 'seats' => $seats > 10 ? '10+' : (string) $seats,
2083 'seats_available' => $seats,
2084 'discount_text' => $discount_text,
2085 'original_price' => $original_price,
2086 'sale_price' => $sale_price,
2087 'title' => $trip_data->title,
2088 'type' => __('Group Departure', 'yatra'),
2089 'start_date' => $from_display,
2090 'end_date' => $to_display,
2091 'start_location' => $from_location,
2092 'end_location' => $to_location,
2093 'data_month' => $filter_key,
2094 'data_date' => $avail->departure_date,
2095 'departure_time' => $departure_time,
2096 'arrival_time' => $arrival_time,
2097 'is_day_trip' => $is_single_day,
2098 'status' => $avail->status ?? 'available',
2099 'is_limited' => $seats <= 5 && $seats > 0,
2100 'is_sold_out' => $is_sold_out,
2101 // Card-specific pricing
2102 'pricing_type' => $card_pricing_type,
2103 'traveler_pricing' => $card_traveler_pricing,
2104 'is_recurring' => !empty($avail->is_recurring),
2105 'rule_id' => $avail->rule_id ?? null,
2106 ] + $dp_card_fields;
2107 }
2108 }
2109
2110 // Use sample data only if no real availability
2111 if (empty($availability_cards)) {
2112 $sample_original = (float) ($trip_data->original_price ?? $trip_data->price ?? 0);
2113 $sample_sale = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip_data) ?: $sample_original;
2114 $sample_date = date('Y-m-d', strtotime('+7 days'));
2115 $sample_seats = 15;
2116
2117 // Store base prices before dynamic pricing
2118 $base_sample_original = $sample_original;
2119 $base_sample_sale = $sample_sale;
2120
2121 // Apply dynamic pricing to sample card (sale line only; list price unchanged for display)
2122 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
2123 $sample_sale = apply_filters('yatra_availability_price', $base_sample_sale, $trip_data->id, [
2124 'departure_date' => $sample_date,
2125 'spots_remaining' => $sample_seats,
2126 'availability_id' => 'sample-1',
2127 'original_price' => $base_sample_original,
2128 'discounted_price' => $base_sample_sale,
2129 ]);
2130 }
2131
2132 $sample_discount_text = $this->computeAvailabilitySavingsBadgeText(
2133 $base_sample_original,
2134 $base_sample_sale,
2135 $sample_sale,
2136 (bool) apply_filters('yatra_dynamic_pricing_enabled', false)
2137 );
2138
2139 if (is_array($dp_display_settings) && !filter_var($dp_display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN)) {
2140 $sample_discount_text = '';
2141 }
2142
2143 $sample_dp_fields = $this->buildAvailabilityDynamicPricingCardFields(
2144 $dp_display_settings,
2145 (int) $trip_data->id,
2146 [
2147 'departure_date' => $sample_date,
2148 'spots_remaining' => $sample_seats,
2149 'availability_id' => 'sample-1',
2150 'base_sale_price' => $base_sample_sale,
2151 'base_original_price' => $base_sample_original,
2152 'sale_price' => $sample_sale,
2153 'original_price' => $sample_original,
2154 ]
2155 );
2156
2157 $availability_cards = [
2158 [
2159 'id' => 'sample-1',
2160 'from_label' => __('Departure', 'yatra'),
2161 'from_date' => date_i18n('j M Y', strtotime('+7 days')),
2162 'from_location' => $trip_data->starting_location ?: __('Starting Point', 'yatra'),
2163 'to_label' => __('Return', 'yatra'),
2164 'to_date' => date_i18n('j M Y', strtotime('+' . (7 + ($trip_data->duration_days ?? 5) - 1) . ' days')),
2165 'to_location' => $trip_data->ending_location ?: ($trip_data->starting_location ?: __('Ending Point', 'yatra')),
2166 'seats' => '10+',
2167 'seats_available' => $sample_seats,
2168 'discount_text' => $sample_discount_text,
2169 'original_price' => $sample_original,
2170 'sale_price' => $sample_sale,
2171 'title' => $trip_data->title,
2172 'type' => __('Group Departure', 'yatra'),
2173 'start_date' => date_i18n('j M Y', strtotime('+7 days')),
2174 'end_date' => date_i18n('j M Y', strtotime('+' . (7 + ($trip_data->duration_days ?? 5) - 1) . ' days')),
2175 'start_location' => $trip_data->starting_location ?: __('Starting Point', 'yatra'),
2176 'end_location' => $trip_data->ending_location ?: ($trip_data->starting_location ?: __('Ending Point', 'yatra')),
2177 'data_month' => strtolower(date('M-Y', strtotime('+7 days'))),
2178 'data_date' => date('Y-m-d', strtotime('+7 days')),
2179 'status' => 'available',
2180 'is_limited' => false,
2181 // Use trip-level pricing for sample data
2182 'pricing_type' => $trip_data->pricing_type ?? 'regular',
2183 'traveler_pricing' => $trip_data->price_types ?? [],
2184 'is_recurring' => false,
2185 'rule_id' => null,
2186 ] + $sample_dp_fields,
2187 ];
2188 $month_filters[strtolower(date('M-Y', strtotime('+7 days')))] = date_i18n('M Y', strtotime('+7 days'));
2189 }
2190
2191 $sorted_cards = $this->sortAvailabilityCards($availability_cards, $sort_key);
2192
2193 $pin_date = '';
2194 if (!$fragment_cards_only) {
2195 $pin_candidate = trim((string) $selected_date);
2196 if ($pin_candidate !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $pin_candidate)) {
2197 $pin_date = $pin_candidate;
2198 }
2199 }
2200
2201 $slice = $this->computeAvailabilityPage($sorted_cards, $month_filter, $page, $per_page, $pin_date);
2202
2203 $pricing_type = $trip_data->pricing_type ?? 'regular';
2204 $price_types = $trip_data->price_types ?? [];
2205 $is_day_trip = ($trip_data->duration_days ?? 1) <= 1;
2206
2207 $initial_travelers = $travelers;
2208 $initial_num_travelers = $num_travelers;
2209 $initial_selected_date = $selected_date;
2210
2211 $selected_month_filter = strtolower($month_filter ?: 'all');
2212 $selected_date_filter = !empty($selected_date) ? $selected_date : $auto_selected_date;
2213
2214 if ($fragment_cards_only) {
2215 foreach ($slice['items'] as $index => $card) {
2216 include YATRA_PLUGIN_PATH . 'templates/partials/availability-card.php';
2217 }
2218
2219 return $slice;
2220 }
2221
2222 $availability_cards = $slice['items'];
2223 $availability_total_matching = $slice['total'];
2224 $availability_page = $slice['page'];
2225 $availability_per_page = $slice['per_page'];
2226 $availability_has_more = $slice['has_more'];
2227 $availability_loaded_count = $slice['loaded_count'];
2228
2229 // Month filter active but no matching departures while other months exist
2230 $availability_filtered_no_results = $selected_month_filter !== 'all'
2231 && $slice['total'] === 0
2232 && !empty($month_filters);
2233
2234 $template_path = YATRA_PLUGIN_PATH . 'templates/partials/availability-section.php';
2235
2236 if (file_exists($template_path)) {
2237 include $template_path;
2238 }
2239
2240 return $slice;
2241 }
2242
2243 /**
2244 * "% OFF" / "+%" badge for availability cards after dynamic pricing is applied to the sale line.
2245 *
2246 * - If dynamic pricing is on and the final price is above the pre-DP sale, show surge vs that sale (priority).
2247 * - Otherwise, if list/original on the card is above the final price, show total % off vs list (trip/date
2248 * discount + any extra DP discount in one number — never understates vs showing only the old catalog %).
2249 * - If there is no list price but DP reduced the promo-only anchor, show % off vs that anchor.
2250 *
2251 * Works for regular, traveler-based (uses same header O/B/F from {@see TripPricingService::resolveCardPricing}),
2252 * and availability date pricing (already in O/B from the card resolver).
2253 */
2254 private function computeAvailabilitySavingsBadgeText(
2255 float $base_original_price,
2256 float $base_sale_price,
2257 float $final_sale_price,
2258 bool $dynamic_pricing_enabled
2259 ): string {
2260 $O = max(0.0, $base_original_price);
2261 $B = max(0.0, $base_sale_price);
2262 $F = max(0.0, $final_sale_price);
2263 $eps = 0.005;
2264
2265 if ($dynamic_pricing_enabled && $B > $eps && $F > $B + $eps) {
2266 $p = (int) round((($F - $B) / $B) * 100);
2267
2268 /* translators: %d: dynamic pricing increase percentage. */
2269 return $p > 0 ? sprintf(__('+%d%%', 'yatra'), $p) : '';
2270 }
2271
2272 if ($O > $eps && $F < $O - $eps) {
2273 $p = (int) round((($O - $F) / $O) * 100);
2274
2275 /* translators: %d: discount percentage. */
2276 return $p > 0 ? sprintf(__('%d%% OFF', 'yatra'), $p) : '';
2277 }
2278
2279 if ($O <= $eps && $B > $eps && $F < $B - $eps) {
2280 $p = (int) round((($B - $F) / $B) * 100);
2281
2282 /* translators: %d: discount percentage. */
2283 return $p > 0 ? sprintf(__('%d%% OFF', 'yatra'), $p) : '';
2284 }
2285
2286 return '';
2287 }
2288
2289 /**
2290 * Per-departure-card dynamic pricing display flags + urgency lines (Pro fills via filter).
2291 *
2292 * @param array<string, mixed> $display_settings From yatra_get_dynamic_pricing_display_settings
2293 * @param array<string, mixed> $context departure_date, spots_remaining, prices, availability_id, …
2294 * @return array{dynamic_pricing_display: array<string, bool>, dynamic_pricing_urgency_messages: array<int, string>}
2295 */
2296 private function buildAvailabilityDynamicPricingCardFields(array $display_settings, int $trip_id, array $context): array
2297 {
2298 $display = [
2299 'show_original_price' => filter_var($display_settings['show_original_price'] ?? true, FILTER_VALIDATE_BOOLEAN),
2300 'show_savings_badge' => filter_var($display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN),
2301 'show_urgency_messages' => filter_var($display_settings['show_urgency_messages'] ?? false, FILTER_VALIDATE_BOOLEAN),
2302 ];
2303
2304 $meta = apply_filters(
2305 'yatra_availability_card_dynamic_pricing_meta',
2306 ['urgency_messages' => []],
2307 array_merge($context, [
2308 'trip_id' => $trip_id,
2309 'display' => $display,
2310 'dp_display_settings' => $display_settings,
2311 ])
2312 );
2313
2314 $urgency = [];
2315 if (is_array($meta) && !empty($meta['urgency_messages']) && is_array($meta['urgency_messages'])) {
2316 foreach ($meta['urgency_messages'] as $m) {
2317 $line = sanitize_text_field((string) $m);
2318 if ($line !== '') {
2319 $urgency[] = $line;
2320 }
2321 }
2322 $urgency = array_values(array_unique($urgency));
2323 }
2324
2325 return [
2326 'dynamic_pricing_display' => $display,
2327 'dynamic_pricing_urgency_messages' => $urgency,
2328 ];
2329 }
2330
2331 private function sortAvailabilityCards(array $cards, string $sort_key): array
2332 {
2333 $sort_key = sanitize_text_field($sort_key);
2334
2335 usort($cards, function ($a, $b) use ($sort_key) {
2336 $aDate = (string) ($a['data_date'] ?? '');
2337 $bDate = (string) ($b['data_date'] ?? '');
2338 $aTime = (string) ($a['departure_time'] ?? '');
2339 $bTime = (string) ($b['departure_time'] ?? '');
2340
2341 $aDateTime = trim($aDate . ' ' . $aTime);
2342 $bDateTime = trim($bDate . ' ' . $bTime);
2343
2344 $aPrice = (float) ($a['sale_price'] ?? 0);
2345 $bPrice = (float) ($b['sale_price'] ?? 0);
2346
2347 $aSeats = (int) ($a['seats_available'] ?? 0);
2348 $bSeats = (int) ($b['seats_available'] ?? 0);
2349
2350 if ($sort_key === 'date-desc') {
2351 $cmp = strcmp($bDateTime, $aDateTime);
2352 } elseif ($sort_key === 'price-asc') {
2353 $cmp = $aPrice <=> $bPrice;
2354 } elseif ($sort_key === 'price-desc') {
2355 $cmp = $bPrice <=> $aPrice;
2356 } elseif ($sort_key === 'seats-desc') {
2357 $cmp = $bSeats <=> $aSeats;
2358 } else {
2359 $cmp = strcmp($aDateTime, $bDateTime);
2360 }
2361
2362 if ($cmp !== 0) {
2363 return $cmp;
2364 }
2365
2366 return strcmp($aDateTime, $bDateTime);
2367 });
2368
2369 return $cards;
2370 }
2371
2372 /**
2373 * Merge specific availability dates with recurring generated dates
2374 * Specific dates take priority over recurring dates for the same date
2375 *
2376 * @param array $specificDates Array of specific date objects from database
2377 * @param array $recurringDates Array of generated recurring date objects
2378 * @return array Merged and sorted availability dates
2379 */
2380 private function mergeAvailabilityDates(array $specificDates, array $recurringDates): array
2381 {
2382 // Index specific dates by departure_date + departure_time for quick lookup
2383 $specificIndex = [];
2384 foreach ($specificDates as $date) {
2385 $key = $date->departure_date . '_' . ($date->departure_time ?? '');
2386 $specificIndex[$key] = true;
2387 }
2388
2389 // Filter out recurring dates that conflict with specific dates
2390 $filteredRecurring = [];
2391 foreach ($recurringDates as $date) {
2392 $key = $date->departure_date . '_' . ($date->departure_time ?? '');
2393 if (!isset($specificIndex[$key])) {
2394 $filteredRecurring[] = $date;
2395 }
2396 }
2397
2398 // Merge both arrays
2399 $merged = array_merge($specificDates, $filteredRecurring);
2400
2401 // Sort by departure_date, then departure_time
2402 usort($merged, function ($a, $b) {
2403 $dateCompare = strcmp($a->departure_date, $b->departure_date);
2404 if ($dateCompare !== 0) {
2405 return $dateCompare;
2406 }
2407 return strcmp($a->departure_time ?? '', $b->departure_time ?? '');
2408 });
2409
2410 return $merged;
2411 }
2412
2413 /**
2414 * Get date-specific pricing and availability info
2415 */
2416 public function get_date_pricing(\WP_REST_Request $request)
2417 {
2418 try {
2419 $trip_id = (int) $request->get_param('id');
2420 $date = sanitize_text_field($request->get_param('date'));
2421
2422 if (!$date) {
2423 return $this->error_response('Date parameter is required', 400);
2424 }
2425
2426 $trip = $this->service->getWithRelations($trip_id);
2427 if (!$trip) {
2428 return $this->error_response('Trip not found', 404);
2429 }
2430
2431 // Use TripService to count departures for this date
2432 $departures_count = $this->service->countDeparturesByDate($trip_id, $date);
2433
2434 // Generate travelers HTML with dynamic pricing
2435 ob_start();
2436 $pricing_type = $trip->pricing_type ?? 'regular';
2437 $price_types = $trip->price_types ?? [];
2438
2439 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
2440 // Apply dynamic pricing to each price type
2441 $dp_enabled = apply_filters('yatra_dynamic_pricing_enabled', false);
2442
2443 foreach ($price_types as &$pt) {
2444 $pt = is_array($pt) ? (object) $pt : $pt;
2445 $price = 0;
2446
2447 if (isset($pt->sale_price) && $pt->sale_price > 0) {
2448 $price = (float) $pt->sale_price;
2449 } elseif (isset($pt->original_price) && $pt->original_price > 0) {
2450 $price = (float) $pt->original_price;
2451 }
2452
2453 // Apply dynamic pricing
2454 if ($dp_enabled && $price > 0) {
2455 $pt_orig = (float) ($pt->original_price ?? 0);
2456 $pt_disc = (float) ($pt->sale_price ?? $pt->discounted_price ?? $pt->effective_price ?? $price);
2457 if ($pt_disc <= 0) {
2458 $pt_disc = $price;
2459 }
2460 $price = apply_filters('yatra_availability_price', $price, $trip_id, [
2461 'departure_date' => $date,
2462 'price_type_id' => $pt->id ?? null,
2463 'original_price' => $pt_orig > 0 ? $pt_orig : $price,
2464 'discounted_price' => $pt_disc > 0 ? $pt_disc : $price,
2465 ]);
2466 }
2467
2468 $pt->effective_price = $price;
2469 }
2470
2471 // Render traveler-based pricing HTML
2472 include YATRA_ABSPATH . '/templates/partials/booking-form-fields.php';
2473 } else {
2474 // Regular pricing - simple number input
2475 echo '<div class="yatra-booking-field">';
2476 echo '<label for="num_travelers">' . esc_html__('Number of Travelers', 'yatra') . '</label>';
2477 echo '<input type="number" id="num_travelers" name="num_travelers" value="1" min="1" max="' . esc_attr($trip->max_travelers ?? 20) . '" />';
2478 echo '</div>';
2479 }
2480
2481 $travelers_html = ob_get_clean();
2482
2483 return $this->success_response([
2484 'success' => true,
2485 'departures_count' => (int) $departures_count,
2486 'travelers_html' => $travelers_html,
2487 'pricing_type' => $pricing_type,
2488 ]);
2489 } catch (\Exception $e) {
2490 return $this->error_response($e->getMessage(), 500);
2491 }
2492 }
2493
2494 /**
2495 * Get public trips for frontend display
2496 * Only returns published trips, excludes soft-deleted trips
2497 */
2498 public function get_public_trips(WP_REST_Request $request)
2499 {
2500 try {
2501 $args = [
2502 'limit' => (int) ($request->get_param('per_page') ?: 20),
2503 'offset' => ((int) ($request->get_param('page') ?: 1) - 1) * (int) ($request->get_param('per_page') ?: 20),
2504 'order_by' => $request->get_param('orderby') ?: 'created_at',
2505 'order' => strtoupper($request->get_param('order') ?: 'DESC'),
2506 // Only return published trips for public endpoint
2507 'where' => ['status' => ['publish']],
2508 // Never include deleted trips for public endpoint
2509 'include_deleted' => false,
2510 ];
2511
2512 // Add search if provided
2513 $search = $request->get_param('search');
2514 if ($search) {
2515 $items = $this->service->search($search, $args);
2516 $total = count($items);
2517 } else {
2518 $items = $this->service->getAll($args);
2519 $total = $this->service->count($args);
2520 }
2521
2522 return $this->success_response([
2523 'data' => $items,
2524 'total' => $total,
2525 'per_page' => (int) ($request->get_param('per_page') ?: 20),
2526 'page' => (int) ($request->get_param('page') ?: 1),
2527 ]);
2528 } catch (\Exception $e) {
2529 return $this->error_response($e->getMessage(), 500);
2530 }
2531 }
2532
2533 /**
2534 * Get trip attributes
2535 */
2536 public function get_trip_attributes(WP_REST_Request $request)
2537 {
2538 try {
2539 $trip_id = (int) $request->get_param('id');
2540
2541 if (!$trip_id) {
2542 return $this->error_response('Trip ID is required', 400);
2543 }
2544
2545 // Use TripService to get trip attributes
2546 $attributes = $this->service->getTripAttributes($trip_id);
2547
2548 $formatted_attributes = [];
2549 foreach ($attributes as $attr) {
2550 $row = is_array($attr) ? (object) $attr : $attr;
2551
2552 $value = $row->value ?? null;
2553 if (isset($row->value_serialized) && $row->value_serialized && $value !== null && $value !== '') {
2554 $unserialized = @unserialize((string) $value, ['allowed_classes' => false]);
2555 if ($unserialized !== false || (string) $value === 'b:0;') {
2556 $value = $unserialized;
2557 }
2558 }
2559
2560 $fieldType = isset($row->field_type) ? trim((string) $row->field_type, '"') : 'text';
2561 $fieldOptions = $row->field_options ?? null;
2562 if (is_string($fieldOptions)) {
2563 $fieldOptions = trim($fieldOptions, '"');
2564 $decoded = json_decode($fieldOptions, true);
2565 if (json_last_error() === JSON_ERROR_NONE) {
2566 $fieldOptions = $decoded;
2567 }
2568 }
2569
2570 $linkId = (int) ($row->relationship_id ?? $row->id ?? 0);
2571 $attributeId = (int) ($row->attribute_id ?? 0);
2572
2573 $formatted_attributes[] = [
2574 'id' => $linkId > 0 ? $linkId : $attributeId,
2575 'attribute_id' => $attributeId,
2576 'name' => (string) ($row->name ?? ''),
2577 'field_type' => $fieldType,
2578 'field_options' => $fieldOptions,
2579 'value' => $value,
2580 'created_at' => (string) ($row->created_at ?? ''),
2581 'updated_at' => (string) ($row->updated_at ?? ''),
2582 ];
2583 }
2584
2585 return $this->success_response($formatted_attributes);
2586 } catch (\Exception $e) {
2587 return $this->error_response($e->getMessage(), 500);
2588 }
2589 }
2590
2591 /**
2592 * Test endpoint to verify routing works
2593 */
2594 public function test_endpoint(): WP_REST_Response
2595 {
2596 return $this->success_response(['message' => 'Test endpoint working', 'timestamp' => date('Y-m-d H:i:s')]);
2597 }
2598
2599 /**
2600 * Update trip attributes
2601 */
2602 public function update_trip_attributes(WP_REST_Request $request)
2603 {
2604 try {
2605 $trip_id = (int) $request->get_param('id');
2606 $attributes = $request->get_param('attributes') ?? [];
2607
2608 if (!$trip_id) {
2609 return $this->error_response('Trip ID is required', 400);
2610 }
2611
2612 if (!is_array($attributes)) {
2613 return $this->error_response('Attributes must be an array', 400);
2614 }
2615
2616 // Prepare attributes for TripService
2617 $formattedAttributes = [];
2618 foreach ($attributes as $attribute_id => $value) {
2619 $formattedAttributes[] = [
2620 'attribute_id' => $attribute_id,
2621 'value' => $value
2622 ];
2623 }
2624
2625 // Use TripService to update trip attributes
2626 $result = $this->service->updateTripAttributes($trip_id, $formattedAttributes);
2627 return $this->success_response(['message' => 'Trip attributes updated successfully']);
2628 } catch (\InvalidArgumentException $e) {
2629 return $this->error_response($e->getMessage(), $e->getCode() >= 400 ? $e->getCode() : 400);
2630 } catch (\Exception $e) {
2631 return $this->error_response($e->getMessage(), 500);
2632 }
2633 }
2634
2635 /**
2636 * Delete trip attribute
2637 */
2638 public function delete_trip_attribute(WP_REST_Request $request)
2639 {
2640 try {
2641 $trip_id = (int) $request->get_param('id');
2642 $attribute_id = (int) $request->get_param('attribute_id');
2643
2644 if (!$trip_id || !$attribute_id) {
2645 return $this->error_response('Trip ID and Attribute ID are required', 400);
2646 }
2647
2648 // Use TripService to delete trip attribute
2649 $result = $this->service->deleteTripAttribute($trip_id, $attribute_id);
2650
2651 if (!$result) {
2652 return $this->error_response('Failed to delete trip attribute', 500);
2653 }
2654
2655 return $this->success_response(['message' => 'Trip attribute deleted successfully']);
2656 } catch (\Exception $e) {
2657 return $this->error_response($e->getMessage(), 500);
2658 }
2659 }
2660 }
2661