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

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