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

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

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