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

2,409 lines 100.7 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 if ($has_availability) {
1808 $current_time = time();
1809
1810 foreach ($trip_data->availability_dates as $avail) {
1811 if (empty($avail->departure_date)) {
1812 // Skip entries without a valid departure date
1813 continue;
1814 }
1815
1816 $departure_date = strtotime($avail->departure_date);
1817
1818 // Check booking cutoff - show all dates regardless of cutoff time
1819 $cutoff_hours = (int) ($avail->cutoff_hours ?? 24); // Default 24 hours before
1820 $departure_time_str = !empty($avail->departure_time) ? $avail->departure_time : '00:00:00';
1821 $departure_datetime = strtotime($avail->departure_date . ' ' . $departure_time_str);
1822 $cutoff_datetime = $departure_datetime - ($cutoff_hours * 3600);
1823
1824 // Show all dates even if past cutoff time
1825 $is_past_cutoff = $current_time > $cutoff_datetime;
1826
1827 // Show all dates even if no seats available
1828 $seats = (int) ($avail->seats_available ?? 0);
1829 $is_sold_out = $seats <= 0;
1830
1831 // Use arrival_date if set, otherwise return_date, otherwise calculate from duration
1832 $return_date = !empty($avail->arrival_date) ? strtotime($avail->arrival_date) :
1833 (!empty($avail->return_date) ? strtotime($avail->return_date) :
1834 strtotime($avail->departure_date . ' + ' . (($trip_data->duration_days ?? 1) - 1) . ' days'));
1835
1836 // Pricing: Use centralized TripPricingService (single source of truth)
1837 $cardPricing = \Yatra\Services\TripPricingService::resolveCardPricing($avail, $trip_data);
1838 $card_pricing_type = $cardPricing['pricing_type'];
1839 $sale_price = $cardPricing['sale_price'];
1840 $original_price = $cardPricing['original_price'];
1841
1842 // Store base prices before dynamic pricing
1843 $base_original_price = $original_price;
1844 $base_sale_price = $sale_price;
1845
1846 // Apply dynamic pricing if enabled (Pro DynamicPricingModule hooks here)
1847 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1848 $dp_context = [
1849 'departure_date' => $avail->departure_date ?? null,
1850 'spots_remaining' => $seats,
1851 'availability_id' => $avail->id ?? null,
1852 ];
1853 $original_price = apply_filters('yatra_availability_price', $original_price, $trip_data->id, $dp_context);
1854 $sale_price = apply_filters('yatra_availability_price', $sale_price, $trip_data->id, $dp_context);
1855 }
1856
1857 // Calculate discount/surge pricing badge
1858 $discount_percent = $cardPricing['discount_percentage'];
1859 $discount_text = '';
1860
1861 if ($discount_percent > 0) {
1862 $discount_text = sprintf(__('%d%% OFF', 'yatra'), $discount_percent);
1863 }
1864 // Check if dynamic pricing increased the price (surge)
1865 elseif ($base_sale_price > 0 && $sale_price > $base_sale_price) {
1866 $surge_percent = round((($sale_price - $base_sale_price) / $base_sale_price) * 100);
1867 $discount_text = $surge_percent > 0 ? sprintf(__('+%d%%', 'yatra'), $surge_percent) : '';
1868 }
1869
1870 // Use month-based filters for both day trips and multi-day trips for better navigation
1871 // This prevents overwhelming users with too many individual date filters
1872 $month_key = strtolower(date('M-Y', $departure_date));
1873 $month_filters[$month_key] = date('M Y', $departure_date);
1874
1875 $from_location = !empty($avail->from_location) ? $avail->from_location : ($trip_data->starting_location ?? '');
1876 $to_location = !empty($avail->to_location) ? $avail->to_location : ($trip_data->ending_location ?? $from_location);
1877
1878 // For day trips, format time; for multi-day trips, format date
1879 $departure_time = !empty($avail->departure_time) ? $avail->departure_time : null;
1880 $arrival_time = !empty($avail->arrival_time) ? $avail->arrival_time : null;
1881
1882 // Format display strings based on trip type (respect Yatra Settings date/time formats)
1883 $yatra_date_format = \Yatra\Services\SettingsService::getString('date_format', 'Y-m-d');
1884 $yatra_time_format = \Yatra\Services\SettingsService::getString('time_format', 'H:i');
1885
1886 if ($is_single_day && $departure_time) {
1887 // Day trip: Show time as main value, date as sub-label
1888 $from_display = date_i18n($yatra_time_format, strtotime($departure_time)); // e.g., "14:30" or "2:30 PM"
1889 $to_display = $arrival_time ? date_i18n($yatra_time_format, strtotime($arrival_time)) : '';
1890 // Show day-trip header date using configured format
1891 $date_display = date_i18n($yatra_date_format, $departure_date);
1892 $from_label = __('Start', 'yatra');
1893 $to_label = __('End', 'yatra');
1894 } else {
1895 // Multi-day trip: Show dates
1896 $from_display = date_i18n($yatra_date_format, $departure_date);
1897 $to_display = date_i18n($yatra_date_format, $return_date);
1898 $date_display = ''; // Not needed for multi-day
1899 $from_label = __('Departure', 'yatra');
1900 $to_label = __('Return', 'yatra');
1901 }
1902
1903 // Use month-based keys for filtering for both day trips and multi-day trips
1904 $filter_key = strtolower(date('M-Y', $departure_date));
1905
1906 // pricing_type MODEL comes from trip level (regular vs traveler_based)
1907 // Note: $avail->pricing_type enum is about price state, not pricing model
1908 $card_pricing_type = $trip_data->pricing_type ?? 'regular';
1909 if (!empty($avail->price_types) && is_array($avail->price_types) && count($avail->price_types) > 0) {
1910 $card_pricing_type = 'traveler_based';
1911 }
1912
1913 // price_types come from centralized service (already resolved with priority: Rules → Dates → Trip)
1914 $card_traveler_pricing = [];
1915 if (!empty($avail->price_types)) {
1916 $card_traveler_pricing = is_array($avail->price_types) ? $avail->price_types : [];
1917
1918 // Enrich with category labels if needed
1919 if (!empty($card_traveler_pricing)) {
1920 $card_traveler_pricing = $enrich_price_types($card_traveler_pricing);
1921 }
1922
1923 }
1924
1925 $availability_cards[] = [
1926 'id' => $avail->id,
1927 'from_label' => $from_label,
1928 'from_date' => $from_display,
1929 'from_location' => $from_location,
1930 'to_label' => $to_label,
1931 'to_date' => $to_display,
1932 'to_location' => $to_location,
1933 'date_display' => $date_display, // For day trips: "Saturday, 30 Nov 2025"
1934 'date' => $avail->departure_date, // Raw date for dynamic pricing
1935 'spots_remaining' => $seats, // For dynamic pricing
1936 'seats' => $seats > 10 ? '10+' : (string) $seats,
1937 'seats_available' => $seats,
1938 'discount_text' => $discount_text,
1939 'original_price' => $original_price,
1940 'sale_price' => $sale_price,
1941 'title' => $trip_data->title,
1942 'type' => __('Group Departure', 'yatra'),
1943 'start_date' => $from_display,
1944 'end_date' => $to_display,
1945 'start_location' => $from_location,
1946 'end_location' => $to_location,
1947 'data_month' => $filter_key,
1948 'data_date' => $avail->departure_date,
1949 'departure_time' => $departure_time,
1950 'arrival_time' => $arrival_time,
1951 'is_day_trip' => $is_single_day,
1952 'status' => $avail->status ?? 'available',
1953 'is_limited' => $seats <= 5 && $seats > 0,
1954 'is_sold_out' => $is_sold_out,
1955 // Card-specific pricing
1956 'pricing_type' => $card_pricing_type,
1957 'traveler_pricing' => $card_traveler_pricing,
1958 'is_recurring' => !empty($avail->is_recurring),
1959 'rule_id' => $avail->rule_id ?? null,
1960 ];
1961 }
1962 }
1963
1964 // Use sample data only if no real availability
1965 if (empty($availability_cards)) {
1966 $sample_original = (float) ($trip_data->original_price ?? $trip_data->price ?? 0);
1967 $sample_sale = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip_data) ?: $sample_original;
1968 $sample_date = date('Y-m-d', strtotime('+7 days'));
1969 $sample_seats = 15;
1970
1971 // Store base prices before dynamic pricing
1972 $base_sample_original = $sample_original;
1973 $base_sample_sale = $sample_sale;
1974
1975 // Apply dynamic pricing to sample card
1976 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1977 $sample_original = apply_filters('yatra_availability_price', $sample_original, $trip_data->id, [
1978 'departure_date' => $sample_date,
1979 'spots_remaining' => $sample_seats,
1980 'availability_id' => 'sample-1',
1981 ]);
1982 $sample_sale = apply_filters('yatra_availability_price', $sample_sale, $trip_data->id, [
1983 'departure_date' => $sample_date,
1984 'spots_remaining' => $sample_seats,
1985 'availability_id' => 'sample-1',
1986 ]);
1987 }
1988
1989 // Calculate discount/surge pricing badge for sample card
1990 $sample_discount_text = '';
1991 if ($base_sample_original > 0 && $base_sample_sale < $base_sample_original) {
1992 $discount_percent = round((($base_sample_original - $base_sample_sale) / $base_sample_original) * 100);
1993 $sample_discount_text = $discount_percent > 0 ? sprintf(__('%d%% OFF', 'yatra'), $discount_percent) : '';
1994 }
1995 elseif ($base_sample_sale > 0 && $sample_sale > $base_sample_sale) {
1996 $surge_percent = round((($sample_sale - $base_sample_sale) / $base_sample_sale) * 100);
1997 $sample_discount_text = $surge_percent > 0 ? sprintf(__('+%d%%', 'yatra'), $surge_percent) : '';
1998 }
1999
2000 $availability_cards = [
2001 [
2002 'id' => 'sample-1',
2003 'from_label' => __('Departure', 'yatra'),
2004 'from_date' => date_i18n('j M Y', strtotime('+7 days')),
2005 'from_location' => $trip_data->starting_location ?: __('Starting Point', 'yatra'),
2006 'to_label' => __('Return', 'yatra'),
2007 'to_date' => date_i18n('j M Y', strtotime('+' . (7 + ($trip_data->duration_days ?? 5) - 1) . ' days')),
2008 'to_location' => $trip_data->ending_location ?: ($trip_data->starting_location ?: __('Ending Point', 'yatra')),
2009 'seats' => '10+',
2010 'seats_available' => $sample_seats,
2011 'discount_text' => $sample_discount_text,
2012 'original_price' => $sample_original,
2013 'sale_price' => $sample_sale,
2014 'title' => $trip_data->title,
2015 'type' => __('Group Departure', 'yatra'),
2016 'start_date' => date_i18n('j M Y', strtotime('+7 days')),
2017 'end_date' => date_i18n('j M Y', strtotime('+' . (7 + ($trip_data->duration_days ?? 5) - 1) . ' days')),
2018 'start_location' => $trip_data->starting_location ?: __('Starting Point', 'yatra'),
2019 'end_location' => $trip_data->ending_location ?: ($trip_data->starting_location ?: __('Ending Point', 'yatra')),
2020 'data_month' => strtolower(date('M-Y', strtotime('+7 days'))),
2021 'data_date' => date('Y-m-d', strtotime('+7 days')),
2022 'status' => 'available',
2023 'is_limited' => false,
2024 // Use trip-level pricing for sample data
2025 'pricing_type' => $trip_data->pricing_type ?? 'regular',
2026 'traveler_pricing' => $trip_data->price_types ?? [],
2027 'is_recurring' => false,
2028 'rule_id' => null,
2029 ],
2030 ];
2031 $month_filters[strtolower(date('M-Y', strtotime('+7 days')))] = date('M Y', strtotime('+7 days'));
2032 }
2033
2034 $sorted_cards = $this->sortAvailabilityCards($availability_cards, $sort_key);
2035
2036 $pin_date = '';
2037 if (!$fragment_cards_only) {
2038 $pin_candidate = trim((string) $selected_date);
2039 if ($pin_candidate !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $pin_candidate)) {
2040 $pin_date = $pin_candidate;
2041 }
2042 }
2043
2044 $slice = $this->computeAvailabilityPage($sorted_cards, $month_filter, $page, $per_page, $pin_date);
2045
2046 $pricing_type = $trip_data->pricing_type ?? 'regular';
2047 $price_types = $trip_data->price_types ?? [];
2048 $is_day_trip = ($trip_data->duration_days ?? 1) <= 1;
2049
2050 $initial_travelers = $travelers;
2051 $initial_num_travelers = $num_travelers;
2052 $initial_selected_date = $selected_date;
2053
2054 $selected_month_filter = strtolower($month_filter ?: 'all');
2055 $selected_date_filter = !empty($selected_date) ? $selected_date : $auto_selected_date;
2056
2057 if ($fragment_cards_only) {
2058 foreach ($slice['items'] as $index => $card) {
2059 include YATRA_PLUGIN_PATH . 'templates/partials/availability-card.php';
2060 }
2061
2062 return $slice;
2063 }
2064
2065 $availability_cards = $slice['items'];
2066 $availability_total_matching = $slice['total'];
2067 $availability_page = $slice['page'];
2068 $availability_per_page = $slice['per_page'];
2069 $availability_has_more = $slice['has_more'];
2070 $availability_loaded_count = $slice['loaded_count'];
2071
2072 // Month filter active but no matching departures while other months exist
2073 $availability_filtered_no_results = $selected_month_filter !== 'all'
2074 && $slice['total'] === 0
2075 && !empty($month_filters);
2076
2077 $template_path = YATRA_PLUGIN_PATH . 'templates/partials/availability-section.php';
2078
2079 if (file_exists($template_path)) {
2080 include $template_path;
2081 }
2082
2083 return $slice;
2084 }
2085
2086 private function sortAvailabilityCards(array $cards, string $sort_key): array
2087 {
2088 $sort_key = sanitize_text_field($sort_key);
2089
2090 usort($cards, function ($a, $b) use ($sort_key) {
2091 $aDate = (string) ($a['data_date'] ?? '');
2092 $bDate = (string) ($b['data_date'] ?? '');
2093 $aTime = (string) ($a['departure_time'] ?? '');
2094 $bTime = (string) ($b['departure_time'] ?? '');
2095
2096 $aDateTime = trim($aDate . ' ' . $aTime);
2097 $bDateTime = trim($bDate . ' ' . $bTime);
2098
2099 $aPrice = (float) ($a['sale_price'] ?? 0);
2100 $bPrice = (float) ($b['sale_price'] ?? 0);
2101
2102 $aSeats = (int) ($a['seats_available'] ?? 0);
2103 $bSeats = (int) ($b['seats_available'] ?? 0);
2104
2105 if ($sort_key === 'date-desc') {
2106 $cmp = strcmp($bDateTime, $aDateTime);
2107 } elseif ($sort_key === 'price-asc') {
2108 $cmp = $aPrice <=> $bPrice;
2109 } elseif ($sort_key === 'price-desc') {
2110 $cmp = $bPrice <=> $aPrice;
2111 } elseif ($sort_key === 'seats-desc') {
2112 $cmp = $bSeats <=> $aSeats;
2113 } else {
2114 $cmp = strcmp($aDateTime, $bDateTime);
2115 }
2116
2117 if ($cmp !== 0) {
2118 return $cmp;
2119 }
2120
2121 return strcmp($aDateTime, $bDateTime);
2122 });
2123
2124 return $cards;
2125 }
2126
2127 /**
2128 * Merge specific availability dates with recurring generated dates
2129 * Specific dates take priority over recurring dates for the same date
2130 *
2131 * @param array $specificDates Array of specific date objects from database
2132 * @param array $recurringDates Array of generated recurring date objects
2133 * @return array Merged and sorted availability dates
2134 */
2135 private function mergeAvailabilityDates(array $specificDates, array $recurringDates): array
2136 {
2137 // Index specific dates by departure_date + departure_time for quick lookup
2138 $specificIndex = [];
2139 foreach ($specificDates as $date) {
2140 $key = $date->departure_date . '_' . ($date->departure_time ?? '');
2141 $specificIndex[$key] = true;
2142 }
2143
2144 // Filter out recurring dates that conflict with specific dates
2145 $filteredRecurring = [];
2146 foreach ($recurringDates as $date) {
2147 $key = $date->departure_date . '_' . ($date->departure_time ?? '');
2148 if (!isset($specificIndex[$key])) {
2149 $filteredRecurring[] = $date;
2150 }
2151 }
2152
2153 // Merge both arrays
2154 $merged = array_merge($specificDates, $filteredRecurring);
2155
2156 // Sort by departure_date, then departure_time
2157 usort($merged, function ($a, $b) {
2158 $dateCompare = strcmp($a->departure_date, $b->departure_date);
2159 if ($dateCompare !== 0) {
2160 return $dateCompare;
2161 }
2162 return strcmp($a->departure_time ?? '', $b->departure_time ?? '');
2163 });
2164
2165 return $merged;
2166 }
2167
2168 /**
2169 * Get date-specific pricing and availability info
2170 */
2171 public function get_date_pricing(\WP_REST_Request $request)
2172 {
2173 try {
2174 $trip_id = (int) $request->get_param('id');
2175 $date = sanitize_text_field($request->get_param('date'));
2176
2177 if (!$date) {
2178 return $this->error_response('Date parameter is required', 400);
2179 }
2180
2181 $trip = $this->service->getWithRelations($trip_id);
2182 if (!$trip) {
2183 return $this->error_response('Trip not found', 404);
2184 }
2185
2186 // Use TripService to count departures for this date
2187 $departures_count = $this->service->countDeparturesByDate($trip_id, $date);
2188
2189 // Generate travelers HTML with dynamic pricing
2190 ob_start();
2191 $pricing_type = $trip->pricing_type ?? 'regular';
2192 $price_types = $trip->price_types ?? [];
2193
2194 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
2195 // Apply dynamic pricing to each price type
2196 $dp_enabled = apply_filters('yatra_dynamic_pricing_enabled', false);
2197
2198 foreach ($price_types as &$pt) {
2199 $pt = is_array($pt) ? (object) $pt : $pt;
2200 $price = 0;
2201
2202 if (isset($pt->sale_price) && $pt->sale_price > 0) {
2203 $price = (float) $pt->sale_price;
2204 } elseif (isset($pt->original_price) && $pt->original_price > 0) {
2205 $price = (float) $pt->original_price;
2206 }
2207
2208 // Apply dynamic pricing
2209 if ($dp_enabled && $price > 0) {
2210 $price = apply_filters('yatra_availability_price', $price, $trip_id, [
2211 'departure_date' => $date,
2212 'price_type_id' => $pt->id ?? null,
2213 ]);
2214 }
2215
2216 $pt->effective_price = $price;
2217 }
2218
2219 // Render traveler-based pricing HTML
2220 include YATRA_ABSPATH . '/templates/partials/booking-form-fields.php';
2221 } else {
2222 // Regular pricing - simple number input
2223 echo '<div class="yatra-booking-field">';
2224 echo '<label for="num_travelers">' . esc_html__('Number of Travelers', 'yatra') . '</label>';
2225 echo '<input type="number" id="num_travelers" name="num_travelers" value="1" min="1" max="' . esc_attr($trip->max_travelers ?? 20) . '" />';
2226 echo '</div>';
2227 }
2228
2229 $travelers_html = ob_get_clean();
2230
2231 return $this->success_response([
2232 'success' => true,
2233 'departures_count' => (int) $departures_count,
2234 'travelers_html' => $travelers_html,
2235 'pricing_type' => $pricing_type,
2236 ]);
2237 } catch (\Exception $e) {
2238 return $this->error_response($e->getMessage(), 500);
2239 }
2240 }
2241
2242 /**
2243 * Get public trips for frontend display
2244 * Only returns published trips, excludes soft-deleted trips
2245 */
2246 public function get_public_trips(WP_REST_Request $request)
2247 {
2248 try {
2249 $args = [
2250 'limit' => (int) ($request->get_param('per_page') ?: 20),
2251 'offset' => ((int) ($request->get_param('page') ?: 1) - 1) * (int) ($request->get_param('per_page') ?: 20),
2252 'order_by' => $request->get_param('orderby') ?: 'created_at',
2253 'order' => strtoupper($request->get_param('order') ?: 'DESC'),
2254 // Only return published trips for public endpoint
2255 'where' => ['status' => ['publish']],
2256 // Never include deleted trips for public endpoint
2257 'include_deleted' => false,
2258 ];
2259
2260 // Add search if provided
2261 $search = $request->get_param('search');
2262 if ($search) {
2263 $items = $this->service->search($search, $args);
2264 $total = count($items);
2265 } else {
2266 $items = $this->service->getAll($args);
2267 $total = $this->service->count($args);
2268 }
2269
2270 return $this->success_response([
2271 'data' => $items,
2272 'total' => $total,
2273 'per_page' => (int) ($request->get_param('per_page') ?: 20),
2274 'page' => (int) ($request->get_param('page') ?: 1),
2275 ]);
2276 } catch (\Exception $e) {
2277 return $this->error_response($e->getMessage(), 500);
2278 }
2279 }
2280
2281 /**
2282 * Get trip attributes
2283 */
2284 public function get_trip_attributes(WP_REST_Request $request)
2285 {
2286 try {
2287 $trip_id = (int) $request->get_param('id');
2288
2289 if (!$trip_id) {
2290 return $this->error_response('Trip ID is required', 400);
2291 }
2292
2293 // Use TripService to get trip attributes
2294 $attributes = $this->service->getTripAttributes($trip_id);
2295
2296 $formatted_attributes = [];
2297 foreach ($attributes as $attr) {
2298 $row = is_array($attr) ? (object) $attr : $attr;
2299
2300 $value = $row->value ?? null;
2301 if (isset($row->value_serialized) && $row->value_serialized && $value !== null && $value !== '') {
2302 $unserialized = @unserialize((string) $value, ['allowed_classes' => false]);
2303 if ($unserialized !== false || (string) $value === 'b:0;') {
2304 $value = $unserialized;
2305 }
2306 }
2307
2308 $fieldType = isset($row->field_type) ? trim((string) $row->field_type, '"') : 'text';
2309 $fieldOptions = $row->field_options ?? null;
2310 if (is_string($fieldOptions)) {
2311 $fieldOptions = trim($fieldOptions, '"');
2312 $decoded = json_decode($fieldOptions, true);
2313 if (json_last_error() === JSON_ERROR_NONE) {
2314 $fieldOptions = $decoded;
2315 }
2316 }
2317
2318 $linkId = (int) ($row->relationship_id ?? $row->id ?? 0);
2319 $attributeId = (int) ($row->attribute_id ?? 0);
2320
2321 $formatted_attributes[] = [
2322 'id' => $linkId > 0 ? $linkId : $attributeId,
2323 'attribute_id' => $attributeId,
2324 'name' => (string) ($row->name ?? ''),
2325 'field_type' => $fieldType,
2326 'field_options' => $fieldOptions,
2327 'value' => $value,
2328 'created_at' => (string) ($row->created_at ?? ''),
2329 'updated_at' => (string) ($row->updated_at ?? ''),
2330 ];
2331 }
2332
2333 return $this->success_response($formatted_attributes);
2334 } catch (\Exception $e) {
2335 return $this->error_response($e->getMessage(), 500);
2336 }
2337 }
2338
2339 /**
2340 * Test endpoint to verify routing works
2341 */
2342 public function test_endpoint(): WP_REST_Response
2343 {
2344 return $this->success_response(['message' => 'Test endpoint working', 'timestamp' => date('Y-m-d H:i:s')]);
2345 }
2346
2347 /**
2348 * Update trip attributes
2349 */
2350 public function update_trip_attributes(WP_REST_Request $request)
2351 {
2352 try {
2353 $trip_id = (int) $request->get_param('id');
2354 $attributes = $request->get_param('attributes') ?? [];
2355
2356 if (!$trip_id) {
2357 return $this->error_response('Trip ID is required', 400);
2358 }
2359
2360 if (!is_array($attributes)) {
2361 return $this->error_response('Attributes must be an array', 400);
2362 }
2363
2364 // Prepare attributes for TripService
2365 $formattedAttributes = [];
2366 foreach ($attributes as $attribute_id => $value) {
2367 $formattedAttributes[] = [
2368 'attribute_id' => $attribute_id,
2369 'value' => $value
2370 ];
2371 }
2372
2373 // Use TripService to update trip attributes
2374 $result = $this->service->updateTripAttributes($trip_id, $formattedAttributes);
2375 return $this->success_response(['message' => 'Trip attributes updated successfully']);
2376 } catch (\InvalidArgumentException $e) {
2377 return $this->error_response($e->getMessage(), $e->getCode() >= 400 ? $e->getCode() : 400);
2378 } catch (\Exception $e) {
2379 return $this->error_response($e->getMessage(), 500);
2380 }
2381 }
2382
2383 /**
2384 * Delete trip attribute
2385 */
2386 public function delete_trip_attribute(WP_REST_Request $request)
2387 {
2388 try {
2389 $trip_id = (int) $request->get_param('id');
2390 $attribute_id = (int) $request->get_param('attribute_id');
2391
2392 if (!$trip_id || !$attribute_id) {
2393 return $this->error_response('Trip ID and Attribute ID are required', 400);
2394 }
2395
2396 // Use TripService to delete trip attribute
2397 $result = $this->service->deleteTripAttribute($trip_id, $attribute_id);
2398
2399 if (!$result) {
2400 return $this->error_response('Failed to delete trip attribute', 500);
2401 }
2402
2403 return $this->success_response(['message' => 'Trip attribute deleted successfully']);
2404 } catch (\Exception $e) {
2405 return $this->error_response($e->getMessage(), 500);
2406 }
2407 }
2408 }
2409