PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
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 / Repositories / TripRepository.php

TripRepository.php in Yatra – Travel Booking & Tour Operator Software 3.0.14.2, at app/Repositories/TripRepository.php

3,836 lines 146.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Repositories;
6
7 use Yatra\Database\Tables\BookingsTable;
8 use Yatra\Database\Tables\ClassificationsTable;
9 use Yatra\Database\Tables\ReviewsTable;
10 use Yatra\Database\Tables\TripClassificationsTable;
11 use Yatra\Database\Tables\TripContentTable;
12 use Yatra\Database\Tables\TripItineraryDayEntryTable;
13 use Yatra\Database\Tables\TripItineraryDaysTable;
14 use Yatra\Database\Tables\TripsTable;
15 use Yatra\Repositories\TripDownloadRepository;
16 use Yatra\Repositories\AttributeRepository;
17 use Yatra\Models\Trip;
18 use Yatra\Utils\Cache;
19 use Yatra\Utils\QueryCache;
20 use Yatra\Database\Tables\TripAvailabilityDatesTable;
21 use Yatra\Database\Tables\TripAvailabilityRulesTable;
22 use Yatra\Database\Tables\DeparturesTable;
23 use Yatra\Constants\ClassificationTypes;
24
25 /**
26 * Trip Repository
27 * Handles database operations for trips with comprehensive field support
28 *
29 * Expert-level repository design:
30 * - Relationship management (destinations, activities)
31 * - JSON field handling
32 * - Soft delete support
33 * - Optimized queries with proper indexing
34 */
35 class TripRepository extends BaseRepository
36 {
37 /**
38 * Get bookings count map for given trip IDs.
39 *
40 * The trips table has a `bookings_count` column but it is not reliably maintained.
41 * For list views, compute counts from the bookings table in one grouped query.
42 *
43 * @param int[] $tripIds
44 * @param string[]|null $excludeStatuses
45 * @return array<int,int> map trip_id => count
46 */
47 public function getBookingsCountMap(array $tripIds, ?array $excludeStatuses = null): array
48 {
49 $tripIds = array_values(array_filter(array_map('intval', $tripIds)));
50 if (empty($tripIds)) {
51 return [];
52 }
53
54 // Default: ignore cancelled/failed bookings in counts (can be overridden)
55 $excludeStatuses = $excludeStatuses ?? apply_filters(
56 'yatra_trip_bookings_count_exclude_statuses',
57 ['cancelled', 'failed'],
58 $tripIds
59 );
60 $excludeStatuses = is_array($excludeStatuses) ? array_values(array_filter(array_map('strval', $excludeStatuses))) : [];
61
62 $bookingsTable = BookingsTable::getTableName();
63
64 $idPlaceholders = implode(',', array_fill(0, count($tripIds), '%d'));
65 $where = "trip_id IN ({$idPlaceholders})";
66 $params = $tripIds;
67
68 if (!empty($excludeStatuses)) {
69 $stPlaceholders = implode(',', array_fill(0, count($excludeStatuses), '%s'));
70 $where .= " AND status NOT IN ({$stPlaceholders})";
71 $params = array_merge($params, $excludeStatuses);
72 }
73
74 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- uses $wpdb->prepare with placeholders
75 $sql = $this->wpdb->prepare(
76 "SELECT trip_id, COUNT(*) AS cnt
77 FROM {$bookingsTable}
78 WHERE {$where}
79 GROUP BY trip_id",
80 $params
81 );
82
83 $rows = $this->wpdb->get_results($sql);
84 $map = [];
85 foreach ((array) $rows as $row) {
86 $tId = (int) ($row->trip_id ?? 0);
87 if ($tId > 0) {
88 $map[$tId] = (int) ($row->cnt ?? 0);
89 }
90 }
91
92 return $map;
93 }
94
95 /**
96 * Cache for table existence checks to avoid repeated SHOW TABLES queries
97 */
98 private static array $tableExistsCache = [];
99
100 /**
101 * Rich text fields specific to trips
102 */
103 protected array $richTextFields = ['description'];
104
105 /**
106 * Integer fields specific to trips
107 */
108 protected array $integerFields = [
109 'id',
110 'created_by',
111 'updated_by',
112 'difficulty_level',
113 'duration_days',
114 'duration_nights',
115 'duration_hours',
116 'booking_window_days',
117 'booking_deadline_hours',
118 'min_travelers',
119 'max_travelers',
120 'group_size',
121 'age_min',
122 'age_max',
123 'version',
124 'views_count',
125 'bookings_count',
126 'reviews_count'
127 ];
128
129 /**
130 * JSON fields specific to trips
131 */
132 protected array $jsonFields = [
133 'included_items',
134 'excluded_items',
135 'frontend_tabs',
136 'testimonial_review_ids',
137 'custom_fields',
138 'price_types',
139 ];
140
141 /**
142 * Publish trips whose scheduled_publish_date has passed
143 */
144 public function publishScheduledTrips(string $now): void
145 {
146 $table = esc_sql($this->table);
147 $affected = $this->wpdb->query(
148 $this->wpdb->prepare(
149 "UPDATE {$table}
150 SET status = 'publish', scheduled_publish_date = NULL, updated_at = %s
151 WHERE scheduled_publish_date IS NOT NULL
152 AND scheduled_publish_date <= %s
153 AND status <> 'publish'",
154 $now,
155 $now
156 )
157 );
158 if ($affected !== false && (int) $affected > 0) {
159 Cache::invalidateAfterBulkTripTableWrites();
160 }
161 }
162
163 /**
164 * Archive trips whose scheduled_unpublish_date has passed
165 */
166 public function archiveScheduledTrips(string $now): void
167 {
168 $table = esc_sql($this->table);
169 $affected = $this->wpdb->query(
170 $this->wpdb->prepare(
171 "UPDATE {$table}
172 SET status = 'archived', scheduled_unpublish_date = NULL, updated_at = %s
173 WHERE scheduled_unpublish_date IS NOT NULL
174 AND scheduled_unpublish_date <= %s
175 AND status = 'publish'",
176 $now,
177 $now
178 )
179 );
180 if ($affected !== false && (int) $affected > 0) {
181 Cache::invalidateAfterBulkTripTableWrites();
182 }
183 }
184
185 /**
186 * Enable trips when seasonal_auto_enable is set and enable date reached
187 */
188 public function enableSeasonalTrips(string $today, string $now): void
189 {
190 $table = esc_sql($this->table);
191 $affected = $this->wpdb->query(
192 $this->wpdb->prepare(
193 "UPDATE {$table}
194 SET status = 'publish', updated_at = %s
195 WHERE seasonal_auto_enable = 1
196 AND seasonal_enable_date IS NOT NULL
197 AND seasonal_enable_date <= %s
198 AND status <> 'publish'",
199 $now,
200 $today
201 )
202 );
203 if ($affected !== false && (int) $affected > 0) {
204 Cache::invalidateAfterBulkTripTableWrites();
205 }
206 }
207
208 /**
209 * Disable trips when seasonal_auto_enable is set and disable date reached
210 */
211 public function disableSeasonalTrips(string $today, string $now): void
212 {
213 $table = esc_sql($this->table);
214 $affected = $this->wpdb->query(
215 $this->wpdb->prepare(
216 "UPDATE {$table}
217 SET status = 'archived', updated_at = %s
218 WHERE seasonal_auto_enable = 1
219 AND seasonal_disable_date IS NOT NULL
220 AND seasonal_disable_date <= %s
221 AND status = 'publish'",
222 $now,
223 $today
224 )
225 );
226 if ($affected !== false && (int) $affected > 0) {
227 Cache::invalidateAfterBulkTripTableWrites();
228 }
229 }
230
231 /**
232 * Get table name
233 */
234 protected function getTableName(): string
235 {
236 return TripsTable::getTableName();
237 }
238
239 /**
240 * Cached single trip row (same key family as {@see CacheService::cacheTrip} consumers).
241 */
242 public function findByIdCached(int $id): ?\stdClass
243 {
244 return $this->cacheQueryResult(
245 Cache::PREFIX_TRIP_DATA . $id,
246 function () use ($id): ?\stdClass {
247 return $this->find($id);
248 },
249 Cache::DURATION_TRIP_DATA
250 );
251 }
252
253 /**
254 * Trip row with relationships loaded — used where {@see TripService::getWithRelationsCached} applied.
255 */
256 public function findWithRelationsCached(int $id): ?\stdClass
257 {
258 $key = Cache::PREFIX_QUERY_RESULT . 'trip_with_relations_' . $id;
259
260 return $this->cacheQueryResult($key, function () use ($id): ?\stdClass {
261 $trip = $this->find($id);
262 if ($trip) {
263 $this->loadTripRelationships($trip);
264 }
265
266 return $trip;
267 }, Cache::DURATION_TRIP_DATA);
268 }
269
270 /**
271 * Create trip row; {@see afterWrite} handles cache; fires action `yatra_trip_created` once per insert.
272 */
273 public function create(array $data): int
274 {
275 $data = $this->dropUnsupportedDurationHours($data);
276 $id = parent::create($data);
277 do_action('yatra_trip_created', $id);
278
279 return $id;
280 }
281
282 /**
283 * Defensive: if the `duration_hours` column is somehow missing (a failed or
284 * pending column-add migration on a restrictive host), drop it from the
285 * write set so a normal trip save still succeeds instead of erroring on an
286 * unknown column. The hour-based feature simply stays off until the column
287 * exists — it never breaks saving.
288 *
289 * @param array<string, mixed> $data
290 * @return array<string, mixed>
291 */
292 protected function dropUnsupportedDurationHours(array $data): array
293 {
294 if (array_key_exists('duration_hours', $data) && !$this->tripTableHasColumn('duration_hours')) {
295 unset($data['duration_hours']);
296 }
297
298 return $data;
299 }
300
301 /**
302 * Override update method to provide proper field formats
303 */
304 public function update(int $id, array $data): bool
305 {
306 $data = $this->dropUnsupportedDurationHours($data);
307 $data = $this->sanitizeData($data);
308 $data['updated_at'] = current_time('mysql');
309
310 // Build format array based on field types
311 $formats = [];
312 foreach ($data as $key => $value) {
313 if (in_array($key, $this->integerFields, true)) {
314 $formats[] = '%d';
315 } elseif (in_array($key, ['created_at', 'updated_at'], true)) {
316 $formats[] = '%s';
317 } elseif (in_array($key, ['original_price', 'discounted_price', 'sale_price', 'deposit_amount', 'deposit_percentage', 'avg_rating', 'revenue_total', 'conversion_rate'], true)) {
318 $formats[] = '%f';
319 } elseif (in_array($key, ['transportation_included', 'is_featured', 'seasonal_auto_enable', 'has_default_time_slots'], true)) {
320 $formats[] = '%d'; // boolean as integer
321 } else {
322 $formats[] = '%s'; // default to string
323 }
324 }
325
326 $result = $this->wpdb->update(
327 $this->getTableName(),
328 $data,
329 ['id' => $id],
330 $formats,
331 ['%d']
332 );
333
334 if ($result !== false) {
335 $this->afterWrite('update', $id, []);
336 }
337
338 return $result !== false;
339 }
340
341 /**
342 * Invalidate trip/listing caches. Trip created hook is fired from {@see create()} after relations
343 * are irrelevant for cache; update/delete hooks fire here.
344 *
345 * @param 'create'|'update'|'delete' $operation
346 */
347 protected function afterWrite(string $operation, int $id, array $context = []): void
348 {
349 Cache::invalidateAfterTripWrite($operation, $id);
350
351 if ($operation === 'update') {
352 do_action('yatra_trip_updated', $id);
353 } elseif ($operation === 'delete') {
354 do_action('yatra_trip_deleted', $id);
355 }
356 }
357
358 /**
359 * Find published/active trip by ID
360 *
361 * @param int $id Trip ID
362 * @return \stdClass|null
363 */
364 public function findPublished(int $id): ?\stdClass
365 {
366 $table = esc_sql($this->table);
367 $query = "SELECT * FROM `{$table}` WHERE id = %d AND status IN ('publish', 'published')";
368
369 if ($this->hasSoftDelete()) {
370 $query .= " AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')";
371 }
372
373 $result = $this->wpdb->get_row($this->wpdb->prepare($query, $id));
374
375 return $result ?: null;
376 }
377
378 /**
379 * Get downloads for a trip (normalized for UI)
380 */
381 public function getDownloads(int $tripId): array
382 {
383 $repo = new TripDownloadRepository();
384 // TripDownloadRepository already normalizes the data correctly
385 // No need to normalize again
386 return $repo->getByTripId($tripId);
387 }
388
389 /**
390 * Attach attribute filter map for findWithFilters() (trip ↔ attribute rows in trip_classifications).
391 *
392 * @param array<string,mixed> $filters
393 * @param array<int,mixed> $attributeFilters
394 * @return array<string,mixed>
395 */
396 public function filterByAttributes(array $filters, array $attributeFilters): array
397 {
398 if ($attributeFilters === []) {
399 return $filters;
400 }
401 $filters['attribute_filters'] = $attributeFilters;
402
403 return $filters;
404 }
405
406 /** @var array<string,bool> */
407 private static array $tripColumnExistsCache = [];
408
409 protected function tripTableHasColumn(string $column): bool
410 {
411 if (isset(self::$tripColumnExistsCache[$column])) {
412 return self::$tripColumnExistsCache[$column];
413 }
414 $table = $this->getTableName();
415 $n = (int) $this->wpdb->get_var($this->wpdb->prepare(
416 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = %s',
417 $table,
418 $column
419 ));
420 self::$tripColumnExistsCache[$column] = $n > 0;
421
422 return self::$tripColumnExistsCache[$column];
423 }
424
425 /**
426 * SQL expression for trip "current" list price (matches TripPricingService::resolveRegularCurrentPrice).
427 */
428 protected function sqlTripEffectiveListPrice(): string
429 {
430 return '(CASE '
431 . 'WHEN CAST(t.discounted_price AS DECIMAL(10,2)) > 0 THEN CAST(t.discounted_price AS DECIMAL(10,2)) '
432 . 'WHEN CAST(t.sale_price AS DECIMAL(10,2)) > 0 THEN CAST(t.sale_price AS DECIMAL(10,2)) '
433 . 'ELSE CAST(t.original_price AS DECIMAL(10,2)) END)';
434 }
435
436 /**
437 * Find trips with comprehensive filtering, pagination, and relationships
438 *
439 * @param array $filters Filter criteria
440 * @param int $page Current page
441 * @param int $perPage Items per page
442 * @return array
443 */
444 public function findWithFilters(array $filters = [], int $page = 1, int $perPage = 10): array
445 {
446 // Create cache key based on filters and pagination
447 $cacheKey = Cache::KEY_TRIPS_WITH_FILTERS . '_' . md5(serialize($filters) . "_page_{$page}_per_page_{$perPage}");
448
449 return $this->cacheQueryResult($cacheKey, function() use ($filters, $page, $perPage) {
450 global $wpdb;
451
452 // Table references
453 $trip_table = $this->getTableName();
454
455 // Use new table classes
456 $reviews_table = ReviewsTable::getTableName();
457 $bookings_table = BookingsTable::getTableName();
458 // Build WHERE conditions and parameters
459 $wheres = ["t.status IN ('publish', 'published')", "(t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')"];
460 $params = [];
461 $joins = [];
462 $having_clauses = [];
463 $rating_params = [];
464
465 $classificationsTable = ClassificationsTable::getTableName();
466 $tripClassificationsTable = TripClassificationsTable::getTableName();
467
468 // Keyword search (trip fields + attribute values marked searchable in metadata)
469 if (!empty($filters['search']) && is_string($filters['search'])) {
470 $like = '%' . $wpdb->esc_like($filters['search']) . '%';
471 $searchFlag = AttributeRepository::metadataEnabledSqlOnAlias('yatra_c_srch', 'searchable');
472 $wheres[] = '(t.title LIKE %s OR t.short_description LIKE %s OR t.description LIKE %s OR t.slug LIKE %s OR EXISTS (
473 SELECT 1 FROM `' . esc_sql($tripClassificationsTable) . '` yatra_tc_srch
474 INNER JOIN `' . esc_sql($classificationsTable) . '` yatra_c_srch
475 ON yatra_c_srch.id = yatra_tc_srch.classification_id
476 WHERE yatra_tc_srch.trip_id = t.id
477 AND yatra_tc_srch.classification_type = %s
478 AND yatra_tc_srch.is_active = 1
479 AND yatra_c_srch.type = %s
480 AND yatra_c_srch.status = %s
481 AND (' . $searchFlag . ')
482 AND (
483 LOWER(JSON_UNQUOTE(JSON_EXTRACT(yatra_tc_srch.metadata, \'$.value\'))) LIKE LOWER(%s)
484 OR LOWER(yatra_tc_srch.metadata) LIKE LOWER(%s)
485 )
486 ))';
487 $params[] = $like;
488 $params[] = $like;
489 $params[] = $like;
490 $params[] = $like;
491 $params[] = 'attribute';
492 $params[] = ClassificationTypes::ATTRIBUTE;
493 $params[] = 'publish';
494 $params[] = $like;
495 $params[] = $like;
496 }
497
498 // Trip duration type (DB column)
499 if (!empty($filters['trip_type']) && is_string($filters['trip_type'])) {
500 $allowedTypes = ['single_day', 'multi_day', 'flexible'];
501 if (in_array($filters['trip_type'], $allowedTypes, true)) {
502 $wheres[] = 't.trip_type = %s';
503 $params[] = $filters['trip_type'];
504 }
505 }
506
507 // Featured Priority (column on yatra_trips, indexed by idx_featured_priority).
508 // Admin form's Featured Priority dropdown is the single source of truth.
509 // Legacy `is_featured` shortcode/block flag is normalised upstream into featured_priority='featured'.
510 if (
511 !empty($filters['featured_priority'])
512 && is_string($filters['featured_priority'])
513 && in_array($filters['featured_priority'], ['featured', 'new', 'limited'], true)
514 && $this->tripTableHasColumn('featured_priority')
515 ) {
516 $wheres[] = 't.featured_priority = %s';
517 $params[] = $filters['featured_priority'];
518 }
519
520 // Destination: checkbox classification IDs (OR) or single slug
521 if (!empty($filters['destination_ids']) && is_array($filters['destination_ids'])) {
522 $ids = array_values(array_filter(array_map('intval', $filters['destination_ids']), static fn (int $id): bool => $id > 0));
523 if ($ids !== []) {
524 $placeholders = implode(',', array_fill(0, count($ids), '%d'));
525 // Rely on joined classification row type only (some rows may have inconsistent classification_type).
526 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcdx INNER JOIN {$classificationsTable} destx ON destx.id = tcdx.classification_id AND destx.type = %s WHERE tcdx.trip_id = t.id AND tcdx.is_active = 1 AND tcdx.classification_id IN ({$placeholders}))";
527 $params[] = ClassificationTypes::DESTINATION;
528 $params = array_merge($params, $ids);
529 }
530 } elseif (!empty($filters['destination'])) {
531 if (is_array($filters['destination'])) {
532 $slugs = array_values(array_filter(array_map('sanitize_title', $filters['destination'])));
533 if ($slugs !== []) {
534 $placeholders = implode(',', array_fill(0, count($slugs), '%s'));
535 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcdx INNER JOIN {$classificationsTable} destx ON destx.id = tcdx.classification_id AND destx.type = %s WHERE tcdx.trip_id = t.id AND tcdx.is_active = 1 AND destx.slug IN ({$placeholders}))";
536 $params[] = ClassificationTypes::DESTINATION;
537 $params = array_merge($params, $slugs);
538 }
539 } elseif (is_string($filters['destination']) && $filters['destination'] !== '') {
540 $joins[] = "LEFT JOIN {$tripClassificationsTable} tcd ON tcd.trip_id = t.id";
541 $joins[] = "LEFT JOIN {$classificationsTable} dest ON dest.id = tcd.classification_id";
542 $wheres[] = 'dest.type = %s AND dest.slug = %s';
543 $params[] = ClassificationTypes::DESTINATION;
544 $params[] = $filters['destination'];
545 }
546 }
547
548 // Activity: IDs or slug
549 if (!empty($filters['activity_ids']) && is_array($filters['activity_ids'])) {
550 $ids = array_values(array_filter(array_map('intval', $filters['activity_ids']), static fn (int $id): bool => $id > 0));
551 if ($ids !== []) {
552 $placeholders = implode(',', array_fill(0, count($ids), '%d'));
553 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcax INNER JOIN {$classificationsTable} actx ON actx.id = tcax.classification_id AND actx.type = %s WHERE tcax.trip_id = t.id AND tcax.is_active = 1 AND tcax.classification_id IN ({$placeholders}))";
554 $params[] = ClassificationTypes::ACTIVITY;
555 $params = array_merge($params, $ids);
556 }
557 } elseif (!empty($filters['activity'])) {
558 if (is_array($filters['activity'])) {
559 $slugs = array_values(array_filter(array_map('sanitize_title', $filters['activity'])));
560 if ($slugs !== []) {
561 $placeholders = implode(',', array_fill(0, count($slugs), '%s'));
562 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcax INNER JOIN {$classificationsTable} actx ON actx.id = tcax.classification_id AND actx.type = %s WHERE tcax.trip_id = t.id AND tcax.is_active = 1 AND actx.slug IN ({$placeholders}))";
563 $params[] = ClassificationTypes::ACTIVITY;
564 $params = array_merge($params, $slugs);
565 }
566 } elseif (is_string($filters['activity']) && $filters['activity'] !== '') {
567 $joins[] = "LEFT JOIN {$tripClassificationsTable} tca ON tca.trip_id = t.id";
568 $joins[] = "LEFT JOIN {$classificationsTable} act ON act.id = tca.classification_id";
569 $wheres[] = 'act.type = %s AND act.slug = %s';
570 $params[] = ClassificationTypes::ACTIVITY;
571 $params[] = $filters['activity'];
572 }
573 }
574
575 // Category: IDs or slug
576 if (!empty($filters['category_ids']) && is_array($filters['category_ids'])) {
577 $ids = array_values(array_filter(array_map('intval', $filters['category_ids']), static fn (int $id): bool => $id > 0));
578 if ($ids !== []) {
579 $placeholders = implode(',', array_fill(0, count($ids), '%d'));
580 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tccx INNER JOIN {$classificationsTable} catx ON catx.id = tccx.classification_id AND catx.type = %s WHERE tccx.trip_id = t.id AND tccx.is_active = 1 AND tccx.classification_id IN ({$placeholders}))";
581 $params[] = ClassificationTypes::CATEGORY;
582 $params = array_merge($params, $ids);
583 }
584 } elseif (!empty($filters['trip_category'])) {
585 if (is_array($filters['trip_category'])) {
586 $slugs = array_values(array_filter(array_map('sanitize_title', $filters['trip_category'])));
587 if ($slugs !== []) {
588 $placeholders = implode(',', array_fill(0, count($slugs), '%s'));
589 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tccx INNER JOIN {$classificationsTable} catx ON catx.id = tccx.classification_id AND catx.type = %s WHERE tccx.trip_id = t.id AND tccx.is_active = 1 AND catx.slug IN ({$placeholders}))";
590 $params[] = ClassificationTypes::CATEGORY;
591 $params = array_merge($params, $slugs);
592 }
593 } elseif (is_string($filters['trip_category']) && $filters['trip_category'] !== '') {
594 $joins[] = "LEFT JOIN {$tripClassificationsTable} tcc ON tcc.trip_id = t.id";
595 $joins[] = "LEFT JOIN {$classificationsTable} cat ON cat.id = tcc.classification_id";
596 $wheres[] = 'cat.type = %s AND cat.slug = %s';
597 $params[] = ClassificationTypes::CATEGORY;
598 $params[] = $filters['trip_category'];
599 }
600 }
601
602 // Special offers (OR within group)
603 if (!empty($filters['special_offers']) && is_array($filters['special_offers'])) {
604 $offerParts = [];
605 foreach (array_unique($filters['special_offers']) as $offer) {
606 if (!is_string($offer)) {
607 continue;
608 }
609 switch ($offer) {
610 case 'discount':
611 $offerParts[] = '(t.discounted_price IS NOT NULL OR t.sale_price IS NOT NULL)';
612 break;
613 case 'early-bird':
614 if ($this->tripTableHasColumn('early_bird_discount_enabled')) {
615 $offerParts[] = 't.early_bird_discount_enabled = 1';
616 }
617 break;
618 case 'last-minute':
619 if ($this->tripTableHasColumn('last_minute_discount_enabled')) {
620 $offerParts[] = 't.last_minute_discount_enabled = 1';
621 }
622 break;
623 case 'instant-booking':
624 if ($this->tripTableHasColumn('instant_booking')) {
625 $offerParts[] = 't.instant_booking = 1';
626 }
627 break;
628 case 'flexible-dates':
629 if ($this->tripTableHasColumn('flexible_dates')) {
630 $offerParts[] = 't.flexible_dates = 1';
631 }
632 break;
633 case 'deposit-available':
634 if ($this->tripTableHasColumn('deposit_required')) {
635 $offerParts[] = 't.deposit_required = 1';
636 }
637 break;
638 }
639 }
640 if ($offerParts !== []) {
641 $wheres[] = '(' . implode(' OR ', $offerParts) . ')';
642 }
643 }
644
645 // Booking options (OR)
646 if (!empty($filters['booking_options']) && is_array($filters['booking_options'])) {
647 $bookParts = [];
648 foreach (array_unique($filters['booking_options']) as $opt) {
649 if (!is_string($opt)) {
650 continue;
651 }
652 switch ($opt) {
653 case 'instant':
654 if ($this->tripTableHasColumn('instant_booking')) {
655 $bookParts[] = 't.instant_booking = 1';
656 }
657 break;
658 case 'flexible':
659 if ($this->tripTableHasColumn('flexible_dates')) {
660 $bookParts[] = 't.flexible_dates = 1';
661 }
662 break;
663 case 'pay-later':
664 if ($this->tripTableHasColumn('deposit_required')) {
665 $bookParts[] = 't.deposit_required = 1';
666 }
667 break;
668 }
669 }
670 if ($bookParts !== []) {
671 $wheres[] = '(' . implode(' OR ', $bookParts) . ')';
672 }
673 }
674
675 // Age suitability (OR)
676 if (!empty($filters['age_suitability']) && is_array($filters['age_suitability'])) {
677 $ageParts = [];
678 foreach (array_unique($filters['age_suitability']) as $age) {
679 if (!is_string($age)) {
680 continue;
681 }
682 switch ($age) {
683 case 'family-friendly':
684 $ageParts[] = '(t.age_min IS NULL OR t.age_min <= 5)';
685 break;
686 case 'kids-friendly':
687 $ageParts[] = '(t.age_min IS NULL OR t.age_min <= 12)';
688 break;
689 case 'senior-friendly':
690 $ageParts[] = '(t.age_max IS NULL OR t.age_max >= 65)';
691 break;
692 case 'adults-only':
693 $ageParts[] = 't.age_min >= 18';
694 break;
695 }
696 }
697 if ($ageParts !== []) {
698 $wheres[] = '(' . implode(' OR ', $ageParts) . ')';
699 }
700 }
701
702 // Accommodation type
703 if (!empty($filters['accommodation']) && is_array($filters['accommodation']) && $this->tripTableHasColumn('accommodation_type')) {
704 $acc = array_values(array_filter(array_map('sanitize_text_field', $filters['accommodation'])));
705 if ($acc !== []) {
706 $placeholders = implode(',', array_fill(0, count($acc), '%s'));
707 $wheres[] = "t.accommodation_type IN ({$placeholders})";
708 $params = array_merge($params, $acc);
709 }
710 }
711
712 // Included services (loose match on JSON text)
713 if (!empty($filters['included_services']) && is_array($filters['included_services'])) {
714 foreach (array_unique($filters['included_services']) as $svc) {
715 if (!is_string($svc) || $svc === '') {
716 continue;
717 }
718 $like = '%' . $wpdb->esc_like($svc) . '%';
719 $wheres[] = 't.included_items LIKE %s';
720 $params[] = $like;
721 }
722 }
723
724 // Dynamic attribute filters (only attributes allowed in public filters — admin setting "Show in Filters")
725 if (!empty($filters['attribute_filters']) && is_array($filters['attribute_filters'])) {
726 $allowedFilterAttrIds = (new AttributeRepository())->getFilterableAttributeIds();
727 foreach ($filters['attribute_filters'] as $attrId => $rawVal) {
728 $attrId = (int) $attrId;
729 if ($attrId <= 0) {
730 continue;
731 }
732 if (!in_array($attrId, $allowedFilterAttrIds, true)) {
733 continue;
734 }
735 if (is_array($rawVal) && (isset($rawVal['min']) || isset($rawVal['max']))) {
736 if (isset($rawVal['min']) && is_numeric($rawVal['min'])) {
737 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_n WHERE ta_n.trip_id = t.id AND ta_n.classification_type = 'attribute' AND ta_n.classification_id = {$attrId} AND ta_n.is_active = 1 AND CAST(JSON_UNQUOTE(JSON_EXTRACT(ta_n.metadata, '$.value')) AS DECIMAL(14,4)) >= %f)";
738 $params[] = (float) $rawVal['min'];
739 }
740 if (isset($rawVal['max']) && is_numeric($rawVal['max'])) {
741 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_x WHERE ta_x.trip_id = t.id AND ta_x.classification_type = 'attribute' AND ta_x.classification_id = {$attrId} AND ta_x.is_active = 1 AND CAST(JSON_UNQUOTE(JSON_EXTRACT(ta_x.metadata, '$.value')) AS DECIMAL(14,4)) <= %f)";
742 $params[] = (float) $rawVal['max'];
743 }
744 } elseif (is_array($rawVal) && $rawVal !== []) {
745 $vals = array_values(array_filter(array_map('sanitize_text_field', $rawVal)));
746 if ($vals !== []) {
747 $placeholders = implode(',', array_fill(0, count($vals), '%s'));
748 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_m WHERE ta_m.trip_id = t.id AND ta_m.classification_type = 'attribute' AND ta_m.classification_id = {$attrId} AND ta_m.is_active = 1 AND JSON_UNQUOTE(JSON_EXTRACT(ta_m.metadata, '$.value')) IN ({$placeholders}))";
749 $params = array_merge($params, $vals);
750 }
751 } else {
752 $v = sanitize_text_field((string) $rawVal);
753 if ($v !== '') {
754 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_s WHERE ta_s.trip_id = t.id AND ta_s.classification_type = 'attribute' AND ta_s.classification_id = {$attrId} AND ta_s.is_active = 1 AND JSON_UNQUOTE(JSON_EXTRACT(ta_s.metadata, '$.value')) = %s)";
755 $params[] = $v;
756 }
757 }
758 }
759 }
760
761 // Price range filter.
762 //
763 // The previous version compared a single legacy "effective"
764 // price (discounted → sale → original column) against the
765 // range. That excluded traveler-based trips whose real
766 // displayed price lives in the `price_types` JSON — so eg. a
767 // trip costing €3755 (Adult category) never appeared in search
768 // when the user set max=€3755, because its legacy
769 // original_price was €0 / stale.
770 //
771 // New approach: match a trip if EITHER the legacy effective
772 // price OR ANY per-category price in the JSON falls in the
773 // requested range. Numeric values in the `price_types` JSON
774 // are extracted with a regex on the raw text — works in all
775 // MySQL 5.7+ builds without needing JSON_VALUE / JSON_TABLE
776 // (which are inconsistent across MariaDB / older MySQL).
777 $effPrice = $this->sqlTripEffectiveListPrice();
778 $priceMin = !empty($filters['price_min']) && $filters['price_min'] > 0 ? (float) $filters['price_min'] : null;
779 $priceMax = !empty($filters['price_max']) && $filters['price_max'] > 0 ? (float) $filters['price_max'] : null;
780
781 if ($priceMin !== null || $priceMax !== null) {
782 $legacyCond = [];
783 if ($priceMin !== null) { $legacyCond[] = "{$effPrice} >= %f"; $params[] = $priceMin; }
784 if ($priceMax !== null) { $legacyCond[] = "{$effPrice} <= %f"; $params[] = $priceMax; }
785 $legacyClause = implode(' AND ', $legacyCond);
786
787 // For per-category pricing, find ANY price in `price_types`
788 // JSON that falls in the requested range. This subquery
789 // creates an ad-hoc number sequence (n=0..49 covers up to
790 // 50 categories — far more than any real trip uses) and
791 // uses JSON_EXTRACT to fetch each entry's effective price.
792 $numbers = "(SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 UNION ALL SELECT 19)";
793 // Each category's effective price = first non-null of
794 // discounted_price → sale_price → original_price → price.
795 $categoryEff = "COALESCE("
796 . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].discounted_price'))) AS DECIMAL(10,2)), 0),"
797 . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].sale_price'))) AS DECIMAL(10,2)), 0),"
798 . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].original_price'))) AS DECIMAL(10,2)), 0),"
799 . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].price'))) AS DECIMAL(10,2)), 0)"
800 . ")";
801 $catCond = [];
802 if ($priceMin !== null) { $catCond[] = "{$categoryEff} >= %f"; $params[] = $priceMin; }
803 if ($priceMax !== null) { $catCond[] = "{$categoryEff} <= %f"; $params[] = $priceMax; }
804 $catClause = implode(' AND ', $catCond);
805
806 // Guard the per-category branch against malformed JSON.
807 // JSON_EXTRACT throws on invalid JSON, and `price_types`
808 // can be NULL, '', '[]' or a stale string on legacy rows.
809 // JSON_VALID() + IFNULL() lets us bail cleanly for those.
810 $perCategoryExists = "("
811 . "t.price_types IS NOT NULL"
812 . " AND t.price_types <> ''"
813 . " AND JSON_VALID(t.price_types) = 1"
814 . " AND EXISTS ("
815 . "SELECT 1 FROM {$numbers} _n"
816 . " WHERE JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, ']')) IS NOT NULL"
817 . " AND {$catClause}"
818 . ")"
819 . ")";
820
821 // Match if EITHER condition passes — a single legacy-priced
822 // trip OR a per-category trip with any matching tier.
823 $wheres[] = "(({$legacyClause}) OR {$perCategoryExists})";
824 }
825
826 // Duration filter
827 if (!empty($filters['duration_min']) && $filters['duration_min'] > 0) {
828 $wheres[] = "CAST(t.duration_days AS UNSIGNED) >= %d";
829 $params[] = $filters['duration_min'];
830 }
831
832 if (!empty($filters['duration_max']) && $filters['duration_max'] > 0) {
833 $wheres[] = "CAST(t.duration_days AS UNSIGNED) <= %d";
834 $params[] = $filters['duration_max'];
835 }
836
837 // Availability date filter ("available from this date onward"): keep only
838 // trips that have a departure starting ON OR AFTER the selected date, so
839 // a customer who picks a date sees every trip they could still travel on
840 // from then. A departure matches when its effective start date (the
841 // explicit start_date, else the single `date`) is >= the chosen day and
842 // it has not been cancelled — the date bound alone excludes past dates
843 // when the picker's min=today is respected, so no reliance on a possibly
844 // stale 'past' status. Clause + param are appended together so the WHERE
845 // ordering stays in sync with $params (see the count/main queries).
846 if (!empty($filters['available_date'])) {
847 $departures_table = DeparturesTable::getTableName();
848 $wheres[] = "EXISTS (
849 SELECT 1 FROM {$departures_table} yd
850 WHERE yd.trip_id = t.id
851 AND COALESCE(NULLIF(yd.start_date, '0000-00-00'), yd.date) >= %s
852 AND yd.status <> 'cancelled'
853 )";
854 $params[] = $filters['available_date'];
855 }
856
857 // Rating filter
858 if (!empty($filters['rating_min']) && $filters['rating_min'] > 0) {
859 $having_clauses[] = "AVG(r.rating) >= %f";
860 $rating_params[] = $filters['rating_min'];
861 }
862
863 // Difficulty filter
864 if (!empty($filters['difficulty']) && is_array($filters['difficulty'])) {
865 $joins[] = "LEFT JOIN {$classificationsTable} dl ON dl.id = t.difficulty_level";
866 $difficulty_placeholders = implode(',', array_fill(0, count($filters['difficulty']), '%d'));
867 $wheres[] = "dl.type = %s AND dl.id IN ({$difficulty_placeholders})";
868 $params[] = ClassificationTypes::DIFFICULTY;
869 $params = array_merge($params, $filters['difficulty']);
870 }
871
872 // Build SQL components
873 $join_sql = !empty($joins) ? implode(' ', $joins) : '';
874 $where_sql = 'WHERE ' . implode(' AND ', $wheres);
875 $having_sql = !empty($having_clauses) ? ('HAVING ' . implode(' AND ', $having_clauses)) : '';
876
877 // Calculate pagination
878 $offset = ($page - 1) * $perPage;
879
880 // Count total results
881 $count_params = array_merge($params, $rating_params);
882 if (!empty($having_clauses)) {
883 $count_sql = "SELECT COUNT(*) FROM (
884 SELECT t.id
885 FROM {$trip_table} t
886 {$join_sql}
887 LEFT JOIN {$reviews_table} r ON r.trip_id = t.id AND r.status = 'approved'
888 {$where_sql}
889 GROUP BY t.id
890 {$having_sql}
891 ) as filtered_trips";
892 } else {
893 $count_sql = "SELECT COUNT(DISTINCT t.id)
894 FROM {$trip_table} t
895 {$join_sql}
896 {$where_sql}";
897 $count_params = $params;
898 }
899
900 $prepared_count_query = empty($count_params) ? $count_sql :
901 $wpdb->prepare($count_sql, ...$count_params);
902
903 $total = (int) $wpdb->get_var($prepared_count_query);
904 $total_pages = $total > 0 ? (int) ceil($total / $perPage) : 1;
905
906 // Build ORDER BY clause
907 $order_clause = $this->buildTripOrderClause($filters['sort'] ?? '');
908
909 // Check for difficulty table and build difficulty JOIN
910 $difficulty_join = $this->buildDifficultyJoin();
911 $difficulty_select = $difficulty_join ? ', diff.name AS difficulty_name, diff.icon AS difficulty_icon' : '';
912
913
914 // Main query
915 $query_sql = "SELECT t.*,
916 AVG(r.rating) AS average_rating,
917 COUNT(DISTINCT r.id) AS review_count,
918 COUNT(DISTINCT b.id) AS booking_count{$difficulty_select}
919 FROM {$trip_table} t
920 {$join_sql}
921 LEFT JOIN {$reviews_table} r ON r.trip_id = t.id AND r.status = 'approved'
922 LEFT JOIN {$bookings_table} b ON b.trip_id = t.id AND b.status IN ('confirmed', 'completed', 'paid')
923 {$difficulty_join}
924 {$where_sql}
925 GROUP BY t.id
926 {$having_sql}
927 {$order_clause}
928 LIMIT %d OFFSET %d";
929
930 $main_query_params = array_merge($params, $rating_params, [$perPage, $offset]);
931 $prepared_query = $wpdb->prepare($query_sql, ...$main_query_params);
932
933 $trips = $wpdb->get_results($prepared_query) ?: [];
934
935 // Process trips in batch for better performance (eliminates N+1 queries)
936 if (!empty($trips)) {
937 $this->batchEnrichTrips($trips);
938 }
939
940 return [
941 'trips' => $trips,
942 'total' => $total,
943 'pages' => $total_pages,
944 'page' => $page,
945 'per_page' => $perPage
946 ];
947 }, Cache::DURATION_QUERY_RESULT); // Cache for 10 minutes
948 }
949
950 /**
951 * Build ORDER BY clause for trip-specific sorting
952 */
953 protected function buildTripOrderClause(string $sort): string
954 {
955 $effPrice = $this->sqlTripEffectiveListPrice();
956 switch ($sort) {
957 case 'most_popular':
958 return "ORDER BY booking_count DESC, t.created_at DESC";
959 case 'price_low':
960 return "ORDER BY {$effPrice} ASC";
961 case 'price_high':
962 return "ORDER BY {$effPrice} DESC";
963 case 'rating_high':
964 return "ORDER BY average_rating DESC";
965 case 'date_asc':
966 return "ORDER BY t.created_at ASC";
967 case 'duration_short':
968 return "ORDER BY CAST(t.duration_days AS UNSIGNED) ASC, CAST(t.duration_nights AS UNSIGNED) ASC";
969 case 'duration_long':
970 return "ORDER BY CAST(t.duration_days AS UNSIGNED) DESC, CAST(t.duration_nights AS UNSIGNED) DESC";
971 default:
972 return "ORDER BY t.created_at DESC";
973 }
974 }
975
976 /**
977 * Build difficulty JOIN clause if table exists
978 */
979 protected function buildDifficultyJoin(): string
980 {
981 global $wpdb;
982
983 // Use ClassificationsTable for difficulty levels (type = 'difficulty')
984 $difficulty_table = ClassificationsTable::getTableName();
985
986 // Use advanced caching system instead of simple array cache
987 $tableExists = Cache::tableExists($difficulty_table, function() use ($wpdb, $difficulty_table) {
988 return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$difficulty_table}'");
989 });
990
991 if ($tableExists) {
992 // Map yatra_trips.difficulty_level (bigint ID) to yatra_classifications table
993 // The difficulty_level field contains the classification ID
994 return sprintf(
995 "LEFT JOIN {$difficulty_table} diff ON diff.id = t.difficulty_level AND diff.type = '%s'",
996 ClassificationTypes::DIFFICULTY
997 );
998 }
999 return '';
1000 }
1001
1002 /**
1003 * Batch enrich multiple trips to eliminate N+1 queries
1004 *
1005 * @param array $trips Array of trip objects
1006 */
1007 protected function batchEnrichTrips(array $trips): void
1008 {
1009 if (empty($trips)) {
1010 return;
1011 }
1012
1013 global $wpdb;
1014 $trip_ids = array_column($trips, 'id');
1015 $trip_ids_placeholder = implode(',', array_fill(0, count($trip_ids), '%d'));
1016
1017 // Batch load destinations
1018 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1019 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1020 $destinations_data = [];
1021 $destTableExists = Cache::tableExists($tripClassificationsTable, function() use ($wpdb, $tripClassificationsTable) {
1022 return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$tripClassificationsTable}'");
1023 });
1024
1025 if ($destTableExists) {
1026 $destinations_raw = $wpdb->get_results($wpdb->prepare(
1027 "SELECT tc.trip_id, c.* FROM {$classificationsTable} c
1028 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
1029 WHERE tc.trip_id IN ({$trip_ids_placeholder}) AND c.type = %s AND c.status = 'publish'
1030 ORDER BY tc.trip_id, tc.sort_order ASC, c.name ASC",
1031 ClassificationTypes::DESTINATION, ...$trip_ids
1032 ));
1033
1034 foreach ($destinations_raw as $dest) {
1035 $trip_id = $dest->trip_id;
1036 unset($dest->trip_id);
1037 $destinations_data[$trip_id][] = $dest;
1038 }
1039 }
1040
1041 // Batch load activities
1042 $activities_data = [];
1043 $actTableExists = Cache::tableExists($tripClassificationsTable, function() use ($wpdb, $tripClassificationsTable) {
1044 return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$tripClassificationsTable}'");
1045 });
1046
1047 if ($actTableExists) {
1048 $activities_raw = $wpdb->get_results($wpdb->prepare(
1049 "SELECT tc.trip_id, c.* FROM {$classificationsTable} c
1050 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
1051 WHERE tc.trip_id IN ({$trip_ids_placeholder}) AND c.type = %s AND c.status = 'publish'
1052 ORDER BY tc.trip_id, tc.sort_order ASC, c.name ASC",
1053 ClassificationTypes::ACTIVITY, ...$trip_ids
1054 ));
1055
1056 foreach ($activities_raw as $act) {
1057 $trip_id = $act->trip_id;
1058 unset($act->trip_id);
1059 $activities_data[$trip_id][] = $act;
1060 }
1061 }
1062
1063 // Batch load categories
1064 // Using TripClassificationsTable for category relationships
1065 $cat_rel_table = $tripClassificationsTable;
1066 $categories_data = [];
1067 $catTableExists = Cache::tableExists($cat_rel_table, function() use ($wpdb, $cat_rel_table) {
1068 return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$cat_rel_table}'");
1069 });
1070
1071 if ($catTableExists) {
1072 $categories_raw = $wpdb->get_results($wpdb->prepare(
1073 "SELECT tc.trip_id, c.* FROM {$classificationsTable} c
1074 INNER JOIN {$cat_rel_table} tc ON c.id = tc.classification_id
1075 WHERE tc.trip_id IN ({$trip_ids_placeholder})
1076 AND c.type = %s AND c.status = 'publish'
1077 ORDER BY tc.trip_id, c.name ASC",
1078 ClassificationTypes::CATEGORY, ...$trip_ids
1079 ));
1080
1081 foreach ($categories_raw as $cat) {
1082 $trip_id = $cat->trip_id;
1083 unset($cat->trip_id);
1084 $categories_data[$trip_id][] = $cat;
1085 }
1086 }
1087
1088 $price_types_by_trip = $this->batchLoadPriceTypesByTripIds($trip_ids);
1089
1090 // Apply enriched data to each trip — use centralized TripPricingService
1091 foreach ($trips as $trip) {
1092 $trip->effective_price_min = \Yatra\Services\TripPricingService::getEffectivePrice($trip);
1093
1094 // Set relationships
1095 $trip->destinations = $destinations_data[$trip->id] ?? [];
1096 $trip->activities = $activities_data[$trip->id] ?? [];
1097 $trip->categories = $categories_data[$trip->id] ?? [];
1098 $trip->price_types = $price_types_by_trip[$trip->id] ?? [];
1099 }
1100 }
1101
1102 /**
1103 * Enrich trip data with additional computed fields (legacy single-trip method)
1104 */
1105 protected function enrichTripData(\stdClass $trip): void
1106 {
1107 global $wpdb;
1108
1109 // Compute effective pricing via centralized TripPricingService
1110 $trip->effective_price_min = \Yatra\Services\TripPricingService::getEffectivePrice($trip);
1111 }
1112
1113 /**
1114 * Load trip relationships (destinations, activities, categories)
1115 */
1116 protected function loadTripRelationships(\stdClass $trip): void
1117 {
1118 global $wpdb;
1119
1120 // Use new Classification tables
1121 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1122 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1123
1124 // Load destinations
1125 $destinations = $wpdb->get_results($wpdb->prepare(
1126 "SELECT c.* FROM {$classificationsTable} c
1127 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
1128 WHERE tc.trip_id = %d AND c.type = %s AND c.status = 'publish'
1129 ORDER BY tc.sort_order ASC, c.name ASC",
1130 $trip->id, ClassificationTypes::DESTINATION
1131 ));
1132 $trip->destinations = $destinations ?: [];
1133
1134 // Load activities
1135 $activities = $wpdb->get_results($wpdb->prepare(
1136 "SELECT c.* FROM {$classificationsTable} c
1137 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
1138 WHERE tc.trip_id = %d AND c.type = %s AND c.status = 'publish'
1139 ORDER BY tc.sort_order ASC, c.name ASC",
1140 $trip->id, ClassificationTypes::ACTIVITY
1141 ));
1142 $trip->activities = $activities ?: [];
1143
1144 // Load categories
1145 $categories = $wpdb->get_results($wpdb->prepare(
1146 "SELECT c.* FROM {$classificationsTable} c
1147 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
1148 WHERE tc.trip_id = %d AND c.type = %s AND c.status = 'publish'
1149 ORDER BY tc.sort_order ASC, c.name ASC",
1150 $trip->id, ClassificationTypes::CATEGORY
1151 ));
1152 $trip->categories = $categories ?: [];
1153
1154 // Load price types for traveler-based pricing (from trips table JSON)
1155 $trip->price_types = $this->getPriceTypes((int) $trip->id);
1156 }
1157
1158 /**
1159 * Find by slug
1160 */
1161 public function findBySlug(string $slug): ?\stdClass
1162 {
1163 $table = esc_sql($this->table);
1164 $query = "SELECT * FROM `{$table}` WHERE slug = %s";
1165
1166 if ($this->hasSoftDelete()) {
1167 $query .= " AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')";
1168 }
1169
1170 $result = $this->wpdb->get_row(
1171 $this->wpdb->prepare($query, $slug)
1172 );
1173
1174 return $result ?: null;
1175 }
1176
1177 /**
1178 * Find by ID with relationships
1179 */
1180 public function findWithRelations(int $id, bool $includeDeleted = false): ?\stdClass
1181 {
1182 $trip = $this->find($id, $includeDeleted);
1183
1184 if (!$trip) {
1185 return null;
1186 }
1187
1188 // Load destinations
1189 $trip->destinations = $this->getDestinations($id);
1190
1191 // Load activities
1192 $trip->activities = $this->getActivities($id);
1193
1194 // Load trip categories
1195 $trip->trip_category = $this->getTripCategories($id);
1196
1197 // Load price types
1198 $trip->price_types = $this->getPriceTypes($id);
1199
1200 // Load gallery images
1201 $trip->gallery_images = $this->getGalleryImages($id);
1202
1203 // Load downloads
1204 $trip->downloadable_items = $this->getDownloads($id);
1205
1206 // Load highlights
1207 $trip->highlights = $this->getHighlights($id);
1208
1209 // Load landmarks
1210 $trip->landmarks = $this->getLandmarks($id);
1211
1212 // Load FAQs
1213 $trip->faqs = $this->getFaqs($id);
1214
1215 // Load availability dates
1216 $trip->availability_dates = $this->getAvailabilityDates($id);
1217
1218 // Load itinerary days with entries
1219 $trip->itinerary_days = $this->getItineraryDays($id);
1220
1221 do_action('yatra_trip_loaded_with_relations', $trip);
1222
1223 return $trip;
1224 }
1225
1226 /**
1227 * Get destinations for a trip
1228 */
1229 public function getDestinations(int $tripId): array
1230 {
1231 global $wpdb;
1232
1233 // Use new Classification tables
1234 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1235 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1236
1237 return $wpdb->get_results(
1238 $wpdb->prepare(
1239 "SELECT tc.classification_id as id, tc.sort_order, tc.relationship_type, tc.is_featured, c.name, c.slug
1240 FROM {$tripClassificationsTable} tc
1241 LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id
1242 WHERE tc.trip_id = %d AND c.type = %s
1243 ORDER BY tc.sort_order ASC, tc.id ASC",
1244 $tripId, ClassificationTypes::DESTINATION
1245 )
1246 ) ?: [];
1247 }
1248
1249 /**
1250 * Get activities for a trip
1251 */
1252 public function getActivities(int $tripId): array
1253 {
1254 global $wpdb;
1255
1256 // Use new Classification tables
1257 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1258 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1259
1260 $results = $wpdb->get_results(
1261 $wpdb->prepare(
1262 "SELECT tc.*, c.name as activity_name, c.slug as activity_slug
1263 FROM {$tripClassificationsTable} tc
1264 LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id
1265 WHERE tc.trip_id = %d AND tc.classification_type = %s
1266 ORDER BY tc.sort_order ASC, tc.id ASC",
1267 $tripId, ClassificationTypes::ACTIVITY
1268 )
1269 ) ?: [];
1270
1271 // Filter out relationships where the activity doesn't exist in Classifications table
1272 $validResults = array_filter($results, function($result) {
1273 return !empty($result->activity_name) && !empty($result->activity_slug);
1274 });
1275
1276 return array_values($validResults);
1277 }
1278
1279 /**
1280 * Get trip categories for a trip
1281 */
1282 public function getTripCategories(int $tripId): array
1283 {
1284 global $wpdb;
1285
1286 // Use TripClassificationsTable for trip-category relationships
1287 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1288 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1289
1290 $sql = $wpdb->prepare(
1291 "SELECT tc.*, c.name as category_name, c.slug as category_slug
1292 FROM {$tripClassificationsTable} tc
1293 LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id
1294 WHERE tc.trip_id = %d AND tc.classification_type = %s
1295 ORDER BY tc.sort_order ASC, tc.id ASC",
1296 $tripId, ClassificationTypes::CATEGORY
1297 );
1298
1299 $results = $wpdb->get_results($sql) ?: [];
1300
1301 // Filter out relationships where the category doesn't exist in Classifications table
1302 $validResults = array_filter($results, function($result) {
1303 return !empty($result->category_name) && !empty($result->category_slug);
1304 });
1305
1306 return array_values($validResults);
1307 }
1308
1309 /**
1310 * Normalize decoded price_types JSON (trips.price_types column).
1311 *
1312 * @param mixed $json Raw column value or already-decoded array
1313 * @return array<int, array<string, mixed>>
1314 */
1315 protected function parsePriceTypesJson($json): array
1316 {
1317 if ($json === null || $json === '') {
1318 return [];
1319 }
1320
1321 $decoded = is_string($json) ? json_decode($json, true) : $json;
1322 if (!is_array($decoded)) {
1323 return [];
1324 }
1325
1326 return array_values(array_filter(array_map(function ($pt) {
1327 if (!is_array($pt)) {
1328 return null;
1329 }
1330 $normalized = [
1331 'category_id' => isset($pt['category_id']) ? (int) $pt['category_id'] : null,
1332 'original_price' => isset($pt['original_price']) ? (float) $pt['original_price'] : null,
1333 'discounted_price' => isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : null,
1334 'sale_price' => isset($pt['sale_price']) ? (float) $pt['sale_price'] : null,
1335 'label' => $pt['label'] ?? ($pt['title'] ?? null),
1336 'pricing_mode' => $pt['pricing_mode'] ?? 'per_person',
1337 'is_default' => !empty($pt['is_default']),
1338 ];
1339 if (isset($pt['category_label'])) {
1340 $normalized['category_label'] = $pt['category_label'];
1341 }
1342 if (isset($pt['description'])) {
1343 $normalized['description'] = $pt['description'];
1344 }
1345
1346 return $normalized;
1347 }, $decoded)));
1348 }
1349
1350 /**
1351 * Batch-load price_types for many trips (single query).
1352 *
1353 * @param int[] $trip_ids
1354 * @return array<int, array<int, array<string, mixed>>>
1355 */
1356 protected function batchLoadPriceTypesByTripIds(array $trip_ids): array
1357 {
1358 $trip_ids = array_values(array_unique(array_map('intval', array_filter($trip_ids))));
1359 if ($trip_ids === []) {
1360 return [];
1361 }
1362
1363 $table = esc_sql($this->table);
1364 $placeholders = implode(',', array_fill(0, count($trip_ids), '%d'));
1365 $sql = "SELECT id, price_types FROM `{$table}` WHERE id IN ({$placeholders})";
1366 $rows = $this->wpdb->get_results($this->wpdb->prepare($sql, ...$trip_ids)) ?: [];
1367
1368 $out = [];
1369 foreach ($rows as $row) {
1370 $out[(int) $row->id] = $this->parsePriceTypesJson($row->price_types ?? null);
1371 }
1372
1373 return $out;
1374 }
1375
1376 /**
1377 * Get price types for a trip
1378 */
1379 public function getPriceTypes(int $tripId): array
1380 {
1381 $table = esc_sql($this->table);
1382 $json = $this->wpdb->get_var(
1383 $this->wpdb->prepare("SELECT price_types FROM `{$table}` WHERE id = %d", $tripId)
1384 );
1385
1386 return $this->parsePriceTypesJson($json);
1387 }
1388
1389 /**
1390 * Get gallery images for a trip
1391 */
1392 public function getGalleryImages(int $tripId): array
1393 {
1394 global $wpdb;
1395
1396 // Use TripContentTable for gallery images
1397 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
1398
1399 $rows = $wpdb->get_results(
1400 $wpdb->prepare(
1401 "SELECT * FROM {$tripContentTable}
1402 WHERE trip_id = %d AND content_type = 'image'
1403 ORDER BY sort_order ASC, id ASC",
1404 $tripId
1405 )
1406 ) ?: [];
1407
1408 // Normalize to the shape the edit form expects
1409 return array_map(function ($row) {
1410 $metadata = [];
1411 if (!empty($row->metadata)) {
1412 $decoded = json_decode($row->metadata, true);
1413 if (is_array($decoded)) {
1414 $metadata = $decoded;
1415 }
1416 }
1417
1418 $imageId = $metadata['image_id'] ?? ($row->image_id ?? null);
1419 $altText = $metadata['alt_text'] ?? null;
1420 $caption = $metadata['caption'] ?? null;
1421 $dimensions = $metadata['dimensions'] ?? null;
1422
1423 return (object) [
1424 'id' => $imageId ? (int) $imageId : 0,
1425 'image_id' => $imageId ? (int) $imageId : 0,
1426 'url' => $row->content_url ?? '',
1427 'image_url' => $row->content_url ?? '',
1428 'thumbnail_url' => $row->thumbnail_url ?? '',
1429 'alt_text' => $altText ?? '',
1430 'caption' => $caption ?? '',
1431 'width' => is_array($dimensions) && isset($dimensions['width']) ? (int) $dimensions['width'] : null,
1432 'height' => is_array($dimensions) && isset($dimensions['height']) ? (int) $dimensions['height'] : null,
1433 'is_featured' => isset($row->is_featured) ? (bool) $row->is_featured : false,
1434 'order' => isset($row->sort_order) ? (int) $row->sort_order : 0,
1435 ];
1436 }, $rows);
1437 }
1438
1439 /**
1440 * Get highlights for a trip
1441 */
1442 public function getHighlights(int $tripId): array
1443 {
1444 global $wpdb;
1445
1446 // Use TripContentTable for highlights
1447 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
1448
1449 $rows = $wpdb->get_results(
1450 $wpdb->prepare(
1451 "SELECT * FROM {$tripContentTable}
1452 WHERE trip_id = %d AND content_type = 'highlight'
1453 ORDER BY sort_order ASC, id ASC",
1454 $tripId
1455 )
1456 ) ?: [];
1457
1458 // Normalize to UI shape
1459 return array_map(function ($row) {
1460 $metadata = [];
1461 if (!empty($row->metadata)) {
1462 $decoded = json_decode($row->metadata, true);
1463 if (is_array($decoded)) {
1464 $metadata = $decoded;
1465 }
1466 }
1467
1468 $imageId = $metadata['image_id'] ?? ($row->image_id ?? null);
1469 $icon = $metadata['icon'] ?? ($row->icon ?? null);
1470
1471 return (object) [
1472 'text' => $row->title ?? '',
1473 'description' => $row->description ?? '',
1474 'image_id' => $imageId ? (int) $imageId : 0,
1475 'icon' => $icon ?? '',
1476 'is_featured' => isset($row->is_featured) ? (bool) $row->is_featured : false,
1477 'order' => isset($row->sort_order) ? (int) $row->sort_order : 0,
1478 ];
1479 }, $rows);
1480 }
1481
1482 /**
1483 * Get landmarks for a trip
1484 */
1485 public function getLandmarks(int $tripId): array
1486 {
1487 global $wpdb;
1488
1489 // Use TripContentTable for landmarks
1490 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
1491
1492 $rows = $wpdb->get_results(
1493 $wpdb->prepare(
1494 "SELECT * FROM {$tripContentTable}
1495 WHERE trip_id = %d AND content_type = 'landmark'
1496 ORDER BY sort_order ASC, id ASC",
1497 $tripId
1498 )
1499 ) ?: [];
1500
1501 // Convert to simple array of landmark texts (like SingleTripController)
1502 $landmark_texts = [];
1503 foreach ($rows as $landmark) {
1504 if (!empty($landmark->title)) {
1505 $landmark_texts[] = $landmark->title;
1506 } elseif (!empty($landmark->description)) {
1507 $landmark_texts[] = $landmark->description;
1508 }
1509 }
1510
1511 return $landmark_texts;
1512 }
1513
1514 /**
1515 * Get FAQs for a trip
1516 */
1517 public function getFaqs(int $tripId): array
1518 {
1519 global $wpdb;
1520
1521 // Use TripContentTable for FAQs
1522 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
1523
1524 $rows = $wpdb->get_results(
1525 $wpdb->prepare(
1526 "SELECT * FROM {$tripContentTable}
1527 WHERE trip_id = %d AND content_type = 'faq'
1528 ORDER BY sort_order ASC, id ASC",
1529 $tripId
1530 )
1531 ) ?: [];
1532
1533 // Normalize to UI shape
1534 return array_map(function ($row) {
1535 $metadata = [];
1536 if (!empty($row->metadata)) {
1537 $decoded = json_decode($row->metadata, true);
1538 if (is_array($decoded)) {
1539 $metadata = $decoded;
1540 }
1541 }
1542 return (object) [
1543 'question' => $row->title ?? '',
1544 'answer' => $row->description ?? '',
1545 'category' => $metadata['category'] ?? '',
1546 'is_featured' => isset($row->is_featured) ? (bool) $row->is_featured : false,
1547 'order' => isset($row->sort_order) ? (int) $row->sort_order : 0,
1548 ];
1549 }, $rows);
1550 }
1551
1552 /**
1553 * Get availability dates for a trip
1554 */
1555 public function getAvailabilityDates(int $tripId): array
1556 {
1557 global $wpdb;
1558 $table = TripAvailabilityDatesTable::getTableName();
1559
1560 return $wpdb->get_results(
1561 $wpdb->prepare(
1562 "SELECT * FROM `{$table}`
1563 WHERE trip_id = %d
1564 ORDER BY departure_date ASC",
1565 $tripId
1566 )
1567 ) ?: [];
1568 }
1569
1570 /**
1571 * Get itinerary days with entries for a trip
1572 */
1573 public function getItineraryDays(int $tripId): array
1574 {
1575 global $wpdb;
1576
1577 // Use new table names for itinerary
1578 $tableDays = \Yatra\Database\Tables\TripItineraryDaysTable::getTableName();
1579 $tableEntries = \Yatra\Database\Tables\TripItineraryDayEntryTable::getTableName();
1580
1581 // Check if tables exist, return empty array if they don't
1582 $table_exists = $wpdb->get_var($wpdb->prepare(
1583 "SELECT COUNT(*) FROM information_schema.tables
1584 WHERE table_schema = %s AND table_name = %s",
1585 DB_NAME,
1586 $tableDays
1587 ));
1588
1589 if (!$table_exists) {
1590 // Tables don't exist yet, return empty array
1591 return [];
1592 }
1593
1594 // Get all days for this trip
1595 $days = $wpdb->get_results(
1596 $wpdb->prepare(
1597 "SELECT * FROM `{$tableDays}`
1598 WHERE trip_id = %d
1599 ORDER BY `order` ASC, day_number ASC",
1600 $tripId
1601 )
1602 ) ?: [];
1603
1604 // For each day, load its entries
1605 foreach ($days as $day) {
1606 $dayId = (int) $day->id;
1607
1608 // Get entries for this day
1609 $entries = $wpdb->get_results(
1610 $wpdb->prepare(
1611 "SELECT * FROM `{$tableEntries}`
1612 WHERE day_id = %d
1613 ORDER BY `order` ASC",
1614 $dayId
1615 )
1616 ) ?: [];
1617
1618 // Process each entry
1619 foreach ($entries as $entry) {
1620 // Decode included/excluded items JSON stored directly on the entry
1621 $entry->included_items = $this->decodeAmenityItems($entry->included_items ?? null);
1622 $entry->excluded_items = $this->decodeAmenityItems($entry->excluded_items ?? null);
1623
1624 // Images are stored in metadata in the new structure
1625 $entry->images = [];
1626 }
1627
1628 // Attach entries to day
1629 $day->entries = $entries;
1630 }
1631
1632 return $days;
1633 }
1634
1635 /**
1636 * Save destinations for a trip
1637 */
1638 public function saveDestinations(int $tripId, array $destinations): void
1639 {
1640 global $wpdb;
1641
1642 $table = TripClassificationsTable::getTableName();
1643 $classificationsTable = ClassificationsTable::getTableName();
1644
1645 // Delete existing destination relations
1646 $wpdb->delete(
1647 $table,
1648 [
1649 'trip_id' => $tripId,
1650 'classification_type' => ClassificationTypes::DESTINATION,
1651 ],
1652 ['%d', '%s']
1653 );
1654
1655 // Extract destination IDs from destination objects
1656 $destinationIds = [];
1657 foreach ($destinations as $destination) {
1658 if (is_array($destination) && isset($destination['id'])) {
1659 $destinationIds[] = (int) $destination['id'];
1660 } elseif (is_object($destination) && isset($destination->id)) {
1661 $destinationIds[] = (int) $destination->id;
1662 } elseif (is_numeric($destination)) {
1663 $destinationIds[] = (int) $destination;
1664 }
1665 }
1666
1667 // Validate that destinations exist before saving (same as activities)
1668 $validDestinationIds = [];
1669 if (!empty($destinationIds)) {
1670 $placeholders = implode(',', array_fill(0, count($destinationIds), '%d'));
1671 $existingDestinations = $wpdb->get_col(
1672 $wpdb->prepare(
1673 "SELECT id FROM {$classificationsTable}
1674 WHERE id IN ({$placeholders}) AND type = %s",
1675 array_merge($destinationIds, [ClassificationTypes::DESTINATION])
1676 )
1677 );
1678 $validDestinationIds = array_map('intval', $existingDestinations);
1679 }
1680
1681 // Also clean up any existing invalid destination relationships for this trip
1682 $deletedRows = $wpdb->query(
1683 $wpdb->prepare(
1684 "DELETE FROM {$table}
1685 WHERE trip_id = %d AND classification_type = %s
1686 AND classification_id NOT IN (
1687 SELECT id FROM {$classificationsTable} WHERE type = %s
1688 )",
1689 $tripId, ClassificationTypes::DESTINATION, ClassificationTypes::DESTINATION
1690 )
1691 );
1692
1693
1694 // Insert new destination relations
1695 if (!empty($validDestinationIds)) {
1696 foreach ($validDestinationIds as $index => $destinationId) {
1697 $wpdb->insert(
1698 $table,
1699 [
1700 'trip_id' => $tripId,
1701 'classification_id' => $destinationId,
1702 'classification_type' => ClassificationTypes::DESTINATION,
1703 'relationship_type' => $index === 0 ? 'primary' : 'secondary',
1704 'sort_order' => $index,
1705 'is_featured' => $index === 0 ? 1 : 0,
1706 ],
1707 ['%d', '%d', '%s', '%s', '%d', '%d']
1708 );
1709 }
1710 }
1711 }
1712
1713 /**
1714 * Save activities for a trip
1715 */
1716 public function saveActivities(int $tripId, array $activityIds): void
1717 {
1718 global $wpdb;
1719
1720 $table = TripClassificationsTable::getTableName();
1721 $classificationsTable = ClassificationsTable::getTableName();
1722
1723 // Validate that activities exist before saving
1724 $validActivityIds = [];
1725 if (!empty($activityIds)) {
1726 $placeholders = implode(',', array_fill(0, count($activityIds), '%d'));
1727 $existingActivities = $wpdb->get_col(
1728 $wpdb->prepare(
1729 "SELECT id FROM {$classificationsTable}
1730 WHERE id IN ({$placeholders}) AND type = %s",
1731 array_merge($activityIds, [ClassificationTypes::ACTIVITY])
1732 )
1733 );
1734 $validActivityIds = array_map('intval', $existingActivities);
1735 }
1736
1737 // Delete existing activity relations
1738 $wpdb->delete(
1739 $table,
1740 [
1741 'trip_id' => $tripId,
1742 'classification_type' => ClassificationTypes::ACTIVITY,
1743 ],
1744 ['%d', '%s']
1745 );
1746
1747 // Insert new activity relations (only for valid activities)
1748 if (!empty($validActivityIds)) {
1749 foreach ($validActivityIds as $index => $activityId) {
1750 $wpdb->insert(
1751 $table,
1752 [
1753 'trip_id' => $tripId,
1754 'classification_id' => (int) $activityId,
1755 'classification_type' => ClassificationTypes::ACTIVITY,
1756 'relationship_type' => $index === 0 ? 'primary' : 'secondary',
1757 'sort_order' => $index,
1758 'is_featured' => $index === 0 ? 1 : 0,
1759 ],
1760 ['%d', '%d', '%s', '%s', '%d', '%d']
1761 );
1762 }
1763 }
1764 }
1765
1766 /**
1767 * Save trip categories for a trip
1768 */
1769 public function saveTripCategories(int $tripId, array $categoryIds): void
1770 {
1771 global $wpdb;
1772
1773 $table = TripClassificationsTable::getTableName();
1774
1775 // Delete existing category relations
1776 $wpdb->delete(
1777 $table,
1778 [
1779 'trip_id' => $tripId,
1780 'classification_type' => ClassificationTypes::CATEGORY,
1781 ],
1782 ['%d', '%s']
1783 );
1784
1785 // Insert new categories
1786 if (!empty($categoryIds)) {
1787 foreach ($categoryIds as $index => $categoryId) {
1788 $wpdb->insert(
1789 $table,
1790 [
1791 'trip_id' => $tripId,
1792 'classification_id' => (int) $categoryId,
1793 'classification_type' => ClassificationTypes::CATEGORY,
1794 'relationship_type' => $index === 0 ? 'primary' : 'secondary',
1795 'sort_order' => $index,
1796 'is_featured' => $index === 0 ? 1 : 0,
1797 ],
1798 ['%d', '%d', '%s', '%s', '%d', '%d']
1799 );
1800 }
1801 }
1802 }
1803
1804 /**
1805 * Save price types for a trip
1806 */
1807 public function savePriceTypes(int $tripId, array $priceTypes): void
1808 {
1809 if (empty($priceTypes)) {
1810 // Explicitly clear stored per-category pricing so that deleting all
1811 // rows (even the last one) persists. Previously this early-returned,
1812 // leaving the old price_types JSON in place — the deletion silently
1813 // reverted on reload. Only the JSON is always cleared; the derived
1814 // scalar columns (original/discounted/sale) are reset solely for
1815 // traveler-based trips, because for regular pricing those columns
1816 // hold the actual price and must not be wiped.
1817 $table = TripsTable::getTableName();
1818 $pricingType = $this->wpdb->get_var(
1819 $this->wpdb->prepare("SELECT pricing_type FROM `{$table}` WHERE id = %d", $tripId)
1820 );
1821
1822 $data = ['price_types' => null];
1823 if ($pricingType === 'traveler_based') {
1824 $data['original_price'] = null;
1825 $data['discounted_price'] = null;
1826 $data['sale_price'] = null;
1827 }
1828
1829 $this->wpdb->update($table, $data, ['id' => $tripId]);
1830 return;
1831 }
1832
1833 // Ensure at most one default category is set (keep the first truthy one).
1834 $defaultFound = false;
1835 foreach ($priceTypes as &$pt) {
1836 if (!is_array($pt)) {
1837 continue;
1838 }
1839 $isDefault = !empty($pt['is_default']);
1840 if ($isDefault && !$defaultFound) {
1841 $defaultFound = true;
1842 $pt['is_default'] = true;
1843 } else {
1844 $pt['is_default'] = false;
1845 }
1846 }
1847 unset($pt);
1848
1849 // Compute minimal pricing values from provided price types
1850 $minOriginal = PHP_FLOAT_MAX;
1851 $minDiscounted = PHP_FLOAT_MAX;
1852 $minSale = PHP_FLOAT_MAX;
1853
1854 foreach ($priceTypes as $priceType) {
1855 $original = isset($priceType['original_price']) ? (float) $priceType['original_price'] : null;
1856 $discounted = isset($priceType['discounted_price']) ? (float) $priceType['discounted_price'] : null;
1857 $sale = isset($priceType['sale_price']) ? (float) $priceType['sale_price'] : null;
1858
1859 if ($original !== null && $original > 0 && $original < $minOriginal) {
1860 $minOriginal = $original;
1861 }
1862 if ($discounted !== null && $discounted > 0 && $discounted < $minDiscounted) {
1863 $minDiscounted = $discounted;
1864 }
1865 if ($sale !== null && $sale > 0 && $sale < $minSale) {
1866 $minSale = $sale;
1867 }
1868 }
1869
1870 // Normalize infinity values to null
1871 $minOriginal = ($minOriginal === PHP_FLOAT_MAX) ? null : $minOriginal;
1872 $minDiscounted = ($minDiscounted === PHP_FLOAT_MAX) ? null : $minDiscounted;
1873 $minSale = ($minSale === PHP_FLOAT_MAX) ? null : $minSale;
1874
1875 // Determine final prices to store on trips table
1876 $finalOriginal = $minOriginal;
1877 $finalDiscounted = $minDiscounted ?? null;
1878 $finalSale = $minSale ?? null;
1879
1880 // If no discounted/sale but original exists, keep it; else leave unchanged
1881 $data = [];
1882 $format = [];
1883
1884 // Persist full price_types JSON for reference (stored on trips table)
1885 $data['price_types'] = wp_json_encode($priceTypes);
1886 $format[] = '%s';
1887
1888 if ($finalOriginal !== null) {
1889 $data['original_price'] = $finalOriginal;
1890 $format[] = '%f';
1891 }
1892 if ($finalDiscounted !== null) {
1893 $data['discounted_price'] = $finalDiscounted;
1894 $format[] = '%f';
1895 }
1896 if ($finalSale !== null) {
1897 $data['sale_price'] = $finalSale;
1898 $format[] = '%f';
1899 }
1900
1901 if (!empty($data)) {
1902 $this->wpdb->update(
1903 TripsTable::getTableName(),
1904 $data,
1905 ['id' => $tripId],
1906 $format,
1907 ['%d']
1908 );
1909 }
1910 }
1911
1912 /**
1913 * Save highlights for a trip
1914 */
1915 public function saveHighlights(int $tripId, array $highlights): void
1916 {
1917 global $wpdb;
1918
1919 $table = TripContentTable::getTableName();
1920
1921 // Delete existing highlights
1922 $wpdb->delete(
1923 $table,
1924 [
1925 'trip_id' => $tripId,
1926 'content_type' => 'highlight',
1927 ],
1928 ['%d', '%s']
1929 );
1930
1931 if (!empty($highlights)) {
1932 foreach ($highlights as $index => $highlight) {
1933 $highlightText = is_string($highlight) ? $highlight : ($highlight['text'] ?? $highlight['highlight_text'] ?? '');
1934 if (empty($highlightText)) {
1935 continue;
1936 }
1937
1938 $metadata = [];
1939 if (is_array($highlight)) {
1940 if (!empty($highlight['icon'])) {
1941 $metadata['icon'] = $highlight['icon'];
1942 }
1943 if (!empty($highlight['image_id'])) {
1944 $metadata['image_id'] = (int) $highlight['image_id'];
1945 }
1946 }
1947
1948 $wpdb->insert(
1949 $table,
1950 [
1951 'trip_id' => $tripId,
1952 'content_type' => 'highlight',
1953 'title' => sanitize_text_field($highlightText),
1954 'description' => is_array($highlight) && !empty($highlight['description']) ? wp_kses_post($highlight['description']) : null,
1955 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1956 'sort_order' => $index,
1957 'is_featured' => is_array($highlight) && isset($highlight['is_featured']) ? (int) $highlight['is_featured'] : 0,
1958 ],
1959 ['%d', '%s', '%s', '%s', '%s', '%d', '%d']
1960 );
1961 }
1962 }
1963 }
1964
1965 /**
1966 * Save landmarks for a trip
1967 */
1968 public function saveLandmarks(int $tripId, array $landmarks): void
1969 {
1970 global $wpdb;
1971
1972 $table = TripContentTable::getTableName();
1973
1974 // Delete existing landmarks
1975 $wpdb->delete(
1976 $table,
1977 [
1978 'trip_id' => $tripId,
1979 'content_type' => 'landmark',
1980 ],
1981 ['%d', '%s']
1982 );
1983
1984 if (!empty($landmarks)) {
1985 foreach ($landmarks as $index => $landmark) {
1986 $landmarkText = is_string($landmark) ? $landmark : ($landmark['text'] ?? $landmark['landmark_text'] ?? '');
1987 if (empty($landmarkText)) {
1988 continue;
1989 }
1990
1991 $metadata = [];
1992 if (is_array($landmark)) {
1993 if (!empty($landmark['icon'])) {
1994 $metadata['icon'] = $landmark['icon'];
1995 }
1996 if (!empty($landmark['image_id'])) {
1997 $metadata['image_id'] = (int) $landmark['image_id'];
1998 }
1999 }
2000
2001 $wpdb->insert(
2002 $table,
2003 [
2004 'trip_id' => $tripId,
2005 'content_type' => 'landmark',
2006 'title' => sanitize_text_field($landmarkText),
2007 'description' => is_array($landmark) && !empty($landmark['description']) ? wp_kses_post($landmark['description']) : null,
2008 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
2009 'sort_order' => $index,
2010 'is_featured' => is_array($landmark) && isset($landmark['is_featured']) ? (int) $landmark['is_featured'] : 0,
2011 ],
2012 ['%d', '%s', '%s', '%s', '%s', '%d', '%d']
2013 );
2014 }
2015 }
2016 }
2017
2018 /**
2019 * Save gallery images for a trip
2020 */
2021 public function saveGalleryImages(int $tripId, array $galleryImages): void
2022 {
2023 global $wpdb;
2024
2025 $table = TripContentTable::getTableName();
2026
2027 // Delete existing gallery images
2028 $wpdb->delete(
2029 $table,
2030 [
2031 'trip_id' => $tripId,
2032 'content_type' => 'image',
2033 ],
2034 ['%d', '%s']
2035 );
2036
2037 // Insert new
2038 if (!empty($galleryImages)) {
2039 foreach ($galleryImages as $index => $image) {
2040 $imageUrl = is_string($image) ? $image : ($image['url'] ?? $image['image_url'] ?? '');
2041 if (empty($imageUrl)) {
2042 continue;
2043 }
2044
2045 $metadata = [];
2046 if (is_array($image)) {
2047 if (!empty($image['alt_text'])) {
2048 $metadata['alt_text'] = $image['alt_text'];
2049 }
2050 if (!empty($image['caption'])) {
2051 $metadata['caption'] = $image['caption'];
2052 }
2053 }
2054
2055 $wpdb->insert(
2056 $table,
2057 [
2058 'trip_id' => $tripId,
2059 'content_type' => 'image',
2060 'content_url' => esc_url_raw($imageUrl),
2061 'file_path' => is_array($image) ? ($image['file_path'] ?? null) : null,
2062 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
2063 'thumbnail_url' => is_array($image) ? ($image['thumbnail_url'] ?? null) : null,
2064 'sort_order' => $index,
2065 'is_featured' => is_array($image) && isset($image['is_featured']) ? (int) $image['is_featured'] : 0,
2066 ],
2067 ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d']
2068 );
2069 }
2070 }
2071 }
2072
2073 /**
2074 * Save FAQs for a trip
2075 */
2076 public function saveFaqs(int $tripId, array $faqs): void
2077 {
2078 global $wpdb;
2079
2080 $table = TripContentTable::getTableName();
2081
2082 // Delete existing FAQs
2083 $wpdb->delete(
2084 $table,
2085 [
2086 'trip_id' => $tripId,
2087 'content_type' => 'faq',
2088 ],
2089 ['%d', '%s']
2090 );
2091
2092 // Insert new FAQs
2093 if (!empty($faqs)) {
2094 foreach ($faqs as $index => $faq) {
2095 if (!is_array($faq) || empty($faq['question']) || empty($faq['answer'])) {
2096 continue;
2097 }
2098
2099 $metadata = [];
2100 if (!empty($faq['category'])) {
2101 $metadata['category'] = sanitize_text_field($faq['category']);
2102 }
2103
2104 $wpdb->insert(
2105 $table,
2106 [
2107 'trip_id' => $tripId,
2108 'content_type' => 'faq',
2109 'title' => sanitize_text_field($faq['question']),
2110 'description' => wp_kses_post($faq['answer']),
2111 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
2112 'sort_order' => $index,
2113 'is_featured' => isset($faq['is_featured']) ? (int) $faq['is_featured'] : 0,
2114 ],
2115 ['%d', '%s', '%s', '%s', '%s', '%d', '%d']
2116 );
2117 }
2118 }
2119 }
2120
2121 /**
2122 * Save entries for a specific day using upsert strategy
2123 */
2124 private function saveDayEntries(int $dayId, array $entries, array $existingEntries): void
2125 {
2126 global $wpdb;
2127 $tableEntries = TripItineraryDayEntryTable::getTableName();
2128
2129 // Create lookup map for existing entries
2130 $existingEntryMap = [];
2131 foreach ($existingEntries as $entry) {
2132 $key = $entry->title . '|' . ($entry->order ?? 0);
2133 $existingEntryMap[$key] = $entry;
2134 }
2135
2136 $processedEntryIds = [];
2137 foreach ($entries as $entryIndex => $entry) {
2138 if (!is_array($entry) || empty($entry['title'])) continue;
2139
2140 $entryKey = $entry['title'] . '|' . $entryIndex;
2141 $entryData = [
2142 'title' => sanitize_text_field($entry['title']),
2143 'description' => isset($entry['description']) ? wp_kses_post($entry['description']) : null,
2144 'item_type_id' => isset($entry['item_type_id']) ? (int) $entry['item_type_id'] : null,
2145 'item_id' => isset($entry['item_id']) ? (int) $entry['item_id'] : null,
2146 'item_type' => isset($entry['item_type']) ? sanitize_text_field($entry['item_type']) : null,
2147 'item_name' => isset($entry['item_name']) ? sanitize_text_field($entry['item_name']) : null,
2148 'item_icon' => isset($entry['item_icon']) ? sanitize_text_field($entry['item_icon']) : null,
2149 'time' => isset($entry['time']) ? sanitize_text_field($entry['time']) : null,
2150 'start_time' => isset($entry['start_time']) ? sanitize_text_field($entry['start_time']) : null,
2151 'end_time' => isset($entry['end_time']) ? sanitize_text_field($entry['end_time']) : null,
2152 'time_type' => isset($entry['time_type']) ? sanitize_text_field($entry['time_type']) : 'exact',
2153 'location' => isset($entry['location']) ? sanitize_text_field($entry['location']) : null,
2154 'duration' => isset($entry['duration']) ? sanitize_text_field($entry['duration']) : null,
2155 'cost' => isset($entry['cost']) ? floatval($entry['cost']) : null,
2156 'cost_per_person' => isset($entry['cost_per_person']) ? (int) $entry['cost_per_person'] : 0,
2157 'notes' => isset($entry['notes']) ? wp_kses_post($entry['notes']) : null,
2158 'included_items' => isset($entry['included_items']) ? wp_json_encode($entry['included_items']) : null,
2159 'excluded_items' => isset($entry['excluded_items']) ? wp_json_encode($entry['excluded_items']) : null,
2160 'gallery' => isset($entry['gallery']) ? wp_json_encode($entry['gallery']) : null,
2161 'video_url' => isset($entry['video_url']) ? esc_url_raw($entry['video_url']) : null,
2162 'status' => isset($entry['status']) ? sanitize_text_field($entry['status']) : 'publish',
2163 'order' => $entryIndex,
2164 'updated_at' => current_time('mysql'),
2165 ];
2166
2167 // Update existing entry or insert new
2168 if (isset($existingEntryMap[$entryKey])) {
2169 $existingEntry = $existingEntryMap[$entryKey];
2170 $wpdb->update($tableEntries, $entryData, ['id' => $existingEntry->id]);
2171 $processedEntryIds[] = $existingEntry->id;
2172 } else {
2173 $entryData['day_id'] = $dayId;
2174 $entryData['trip_id'] = $this->getTripIdByDayId($dayId);
2175 $entryData['created_at'] = current_time('mysql');
2176 $wpdb->insert($tableEntries, $entryData);
2177 $processedEntryIds[] = $wpdb->insert_id;
2178 }
2179 }
2180
2181 // Delete entries that are no longer present
2182 if (!empty($processedEntryIds)) {
2183 $placeholders = implode(',', array_fill(0, count($processedEntryIds), '%d'));
2184 $wpdb->query($wpdb->prepare(
2185 "DELETE FROM {$tableEntries} WHERE day_id = %d AND id NOT IN ({$placeholders})",
2186 $dayId,
2187 ...$processedEntryIds
2188 ));
2189 } else {
2190 // If no entries provided, delete all entries for this day
2191 $wpdb->delete($tableEntries, ['day_id' => $dayId], ['%d']);
2192 }
2193 }
2194
2195 /**
2196 * Get trip ID by day ID
2197 */
2198 private function getTripIdByDayId(int $dayId): int
2199 {
2200 global $wpdb;
2201 $tableDays = TripItineraryDaysTable::getTableName();
2202 return (int) $wpdb->get_var($wpdb->prepare(
2203 "SELECT trip_id FROM {$tableDays} WHERE id = %d",
2204 $dayId
2205 ));
2206 }
2207
2208 /**
2209 * Save availability dates for a trip.
2210 *
2211 * Previously a flat DELETE-THEN-INSERT: every row for the trip was nuked
2212 * and the incoming list rewritten. That made the trip-edit save lossy —
2213 * when the Trip form only knew about a subset of fields on each date
2214 * (e.g. it was loaded once, then the operator changed the seats on a
2215 * single date through the Availability tab, then re-saved the Trip with
2216 * the form's stale date payload), every field absent from the incoming
2217 * row was reset to its hard-coded default. That's why operator edits
2218 * to `seats_total` silently reverted to 20 after another trip-save:
2219 *
2220 * 1. Operator opens Trip → form loads `availability_dates` with
2221 * `seats_total = 20`.
2222 * 2. Operator switches to Availability tab → edits Sept 5 to
2223 * `seats_total = 14` via the dedicated specific-date endpoint
2224 * (writes directly to the row, so the row is now 14).
2225 * 3. Operator returns to the Trip form, edits something unrelated
2226 * (title / description), clicks Save. The form re-posts the date
2227 * list it loaded in step 1 — `seats_total = 20`.
2228 * 4. saveAvailabilityDates DELETEs everything, INSERTs the
2229 * form-supplied list → Sept 5 is back to 20 (or the literal `20`
2230 * default if the form didn't include seats_total at all).
2231 *
2232 * Fix: snapshot the existing rows before the DELETE and, when the
2233 * incoming row omits an editable field, fall back to the snapshot's
2234 * value instead of the schema default. The literal `20` default is
2235 * removed in favour of the trip's `max_travelers` (or 1 as a last
2236 * resort) so new dates inserted by truly-new entries don't pretend the
2237 * trip seats 20 people unless the trip actually says so.
2238 *
2239 * Match key for the snapshot: `departure_date` + `departure_time` (both
2240 * normalised), which is the same identity the Availability UI uses.
2241 */
2242 public function saveAvailabilityDates(int $tripId, array $availabilityDates): void
2243 {
2244 global $wpdb;
2245 $table = TripAvailabilityDatesTable::getTableName();
2246
2247 // Snapshot existing rows by date + time so partial incoming payloads
2248 // can be merged with persisted values instead of clobbering them.
2249 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from schema helper.
2250 $existingRows = $wpdb->get_results($wpdb->prepare(
2251 "SELECT * FROM {$table} WHERE trip_id = %d",
2252 $tripId
2253 ));
2254 $snapshot = [];
2255 if (is_array($existingRows)) {
2256 foreach ($existingRows as $row) {
2257 $key = self::availabilityRowKey(
2258 (string) ($row->departure_date ?? ''),
2259 (string) ($row->departure_time ?? '')
2260 );
2261 if ($key !== '') {
2262 $snapshot[$key] = $row;
2263 }
2264 }
2265 }
2266
2267 // Fallback capacity when both the incoming row and the snapshot
2268 // miss seats_total: read the trip's max_travelers once. The
2269 // legacy literal `20` was a phantom default that punished sites
2270 // whose actual trip capacity differs.
2271 $tripFallbackSeats = 0;
2272 $tripRow = $this->find($tripId);
2273 if (is_object($tripRow) && isset($tripRow->max_travelers)) {
2274 $tripFallbackSeats = (int) $tripRow->max_travelers;
2275 }
2276 if ($tripFallbackSeats <= 0) {
2277 $tripFallbackSeats = 1;
2278 }
2279
2280 // Delete existing
2281 $wpdb->delete($table, ['trip_id' => $tripId], ['%d']);
2282
2283 // Insert new
2284 if (!empty($availabilityDates)) {
2285 foreach ($availabilityDates as $date) {
2286 if (is_array($date) && !empty($date['departure_date'])) {
2287 $key = self::availabilityRowKey(
2288 (string) $date['departure_date'],
2289 (string) ($date['departure_time'] ?? '')
2290 );
2291 $existing = $key !== '' && isset($snapshot[$key]) ? $snapshot[$key] : null;
2292
2293 $seatsTotal = isset($date['seats_total'])
2294 ? (int) $date['seats_total']
2295 : ($existing ? (int) ($existing->seats_total ?? $tripFallbackSeats) : $tripFallbackSeats);
2296 $seatsAvailable = isset($date['seats_available'])
2297 ? (int) $date['seats_available']
2298 : ($existing ? (int) ($existing->seats_available ?? $seatsTotal) : $seatsTotal);
2299
2300 $arrivalDate = isset($date['arrival_date'])
2301 ? sanitize_text_field($date['arrival_date'])
2302 : (isset($date['return_date'])
2303 ? sanitize_text_field($date['return_date'])
2304 : ($existing ? $existing->arrival_date : null));
2305 $returnDate = isset($date['return_date'])
2306 ? sanitize_text_field($date['return_date'])
2307 : ($existing ? $existing->return_date : null);
2308 $arrivalTime = isset($date['arrival_time'])
2309 ? sanitize_text_field($date['arrival_time'])
2310 : ($existing ? $existing->arrival_time : null);
2311
2312 $originalPrice = isset($date['original_price'])
2313 ? (float) $date['original_price']
2314 : (isset($date['price_override'])
2315 ? (float) $date['price_override']
2316 : ($existing && $existing->original_price !== null ? (float) $existing->original_price : null));
2317 $discountedPrice = isset($date['discounted_price'])
2318 ? (float) $date['discounted_price']
2319 : ($existing && $existing->discounted_price !== null ? (float) $existing->discounted_price : null);
2320
2321 $fromLocation = isset($date['from_location']) ? sanitize_text_field($date['from_location']) : ($existing ? $existing->from_location : null);
2322 $toLocation = isset($date['to_location']) ? sanitize_text_field($date['to_location']) : ($existing ? $existing->to_location : null);
2323 $fromLat = isset($date['from_latitude']) && is_numeric($date['from_latitude'])
2324 ? (string) $date['from_latitude']
2325 : ($existing ? $existing->from_latitude : null);
2326 $fromLng = isset($date['from_longitude']) && is_numeric($date['from_longitude'])
2327 ? (string) $date['from_longitude']
2328 : ($existing ? $existing->from_longitude : null);
2329 $toLat = isset($date['to_latitude']) && is_numeric($date['to_latitude'])
2330 ? (string) $date['to_latitude']
2331 : ($existing ? $existing->to_latitude : null);
2332 $toLng = isset($date['to_longitude']) && is_numeric($date['to_longitude'])
2333 ? (string) $date['to_longitude']
2334 : ($existing ? $existing->to_longitude : null);
2335
2336 if (isset($date['is_blackout'])) {
2337 $status = $date['is_blackout'] ? 'blocked' : 'available';
2338 } elseif (isset($date['status'])) {
2339 $status = sanitize_text_field($date['status']);
2340 } elseif ($existing) {
2341 $status = (string) ($existing->status ?? 'available');
2342 } else {
2343 $status = 'available';
2344 }
2345
2346 $insertData = [
2347 'trip_id' => $tripId,
2348 'departure_date' => sanitize_text_field($date['departure_date']),
2349 'arrival_date' => $arrivalDate,
2350 'return_date' => $returnDate,
2351 'departure_time' => isset($date['departure_time']) ? sanitize_text_field($date['departure_time']) : ($existing ? $existing->departure_time : null),
2352 'arrival_time' => $arrivalTime,
2353 'seats_total' => $seatsTotal,
2354 'seats_available' => $seatsAvailable,
2355 'original_price' => $originalPrice,
2356 'discounted_price' => $discountedPrice,
2357 'from_location' => $fromLocation,
2358 'to_location' => $toLocation,
2359 'from_latitude' => $fromLat,
2360 'from_longitude' => $fromLng,
2361 'to_latitude' => $toLat,
2362 'to_longitude' => $toLng,
2363 'status' => $status,
2364 ];
2365
2366 $wpdb->insert(
2367 $table,
2368 $insertData,
2369 ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
2370 );
2371 }
2372 }
2373 }
2374 }
2375
2376 /**
2377 * Normalise a (date, time) pair to a single string key used to match
2378 * incoming availability rows against the pre-delete snapshot. Time is
2379 * left as-is for an exact comparison; empty time matches the "no
2380 * specific time" row.
2381 */
2382 private static function availabilityRowKey(string $date, string $time): string
2383 {
2384 $date = trim($date);
2385 if ($date === '') {
2386 return '';
2387 }
2388 return $date . '|' . trim($time);
2389 }
2390
2391 /**
2392 * Save attributes for a trip
2393 */
2394 public function saveAttributes(int $tripId, array $attributes): void
2395 {
2396 $tripAttributeRepository = new \Yatra\Repositories\TripAttributeRepository();
2397 $tripAttributeRepository->saveTripAttributes($tripId, $attributes);
2398 }
2399
2400 /**
2401 * Create trip with relationships
2402 */
2403 public function createWithRelations(array $data, array $relationships = []): int
2404 {
2405 // Extract relationship data
2406 $destinations = $relationships['destinations'] ?? [];
2407 $activities = $relationships['activities'] ?? [];
2408 $tripCategories = $relationships['trip_category'] ?? [];
2409 $priceTypes = $relationships['price_types'] ?? [];
2410 $highlights = $relationships['highlights'] ?? [];
2411 $landmarks = $relationships['landmarks'] ?? [];
2412 $galleryImages = $relationships['gallery_images'] ?? [];
2413 $faqs = $relationships['faqs'] ?? [];
2414 $downloadableItems = $relationships['downloadable_items'] ?? [];
2415 $itineraryDays = $relationships['itinerary_days'] ?? [];
2416 $availabilityDates = $relationships['availability_dates'] ?? [];
2417 $attributes = $relationships['attributes'] ?? [];
2418
2419 // Remove relationship data from main data (these should not be in the main table)
2420 unset(
2421 $data['destinations'],
2422 $data['activities'],
2423 $data['trip_category'],
2424 $data['highlights'],
2425 $data['landmarks'],
2426 $data['gallery_images'],
2427 $data['faqs'],
2428 $data['downloadable_items'],
2429 $data['itinerary_days'],
2430 $data['availability_dates'],
2431 $data['attributes']
2432 );
2433
2434 // Create main trip record
2435 $tripId = $this->create($data);
2436
2437 // Update relationships if provided
2438 if (!empty($destinations)) {
2439 $this->saveDestinations($tripId, $destinations);
2440 }
2441
2442 if (!empty($activities)) {
2443 $this->saveActivities($tripId, $activities);
2444 }
2445
2446 if (!empty($tripCategories)) {
2447 $this->saveTripCategories($tripId, $tripCategories);
2448 }
2449
2450 if (!empty($priceTypes)) {
2451 $this->savePriceTypes($tripId, $priceTypes);
2452 }
2453
2454 if (!empty($highlights)) {
2455 $this->saveHighlights($tripId, $highlights);
2456 }
2457
2458 if (!empty($landmarks)) {
2459 $this->saveLandmarks($tripId, $landmarks);
2460 }
2461
2462 if (!empty($galleryImages)) {
2463 $this->saveGalleryImages($tripId, $galleryImages);
2464 }
2465
2466 if (!empty($faqs)) {
2467 $this->saveFaqs($tripId, $faqs);
2468 }
2469
2470 // Always replace downloads (clear if empty array)
2471 $downloadRepo = new TripDownloadRepository();
2472 $downloadRepo->replaceForTrip($tripId, is_array($downloadableItems) ? $downloadableItems : []);
2473
2474 // ITINERARY SHOULD NEVER BE PROCESSED DURING TRIP CREATION
2475 // Itinerary should be created separately through dedicated itinerary endpoints
2476 // This ensures complete separation of concerns and prevents data loss
2477
2478 if (!empty($availabilityDates)) {
2479 $this->saveAvailabilityDates($tripId, $availabilityDates);
2480 }
2481
2482 if (!empty($attributes)) {
2483 $this->saveAttributes($tripId, $attributes);
2484 }
2485
2486 // Full bust after junction/related tables are written (create() already invalidated listings/stats).
2487 Cache::invalidateAfterTripWrite('update', $tripId);
2488
2489 do_action('yatra_trip_created_with_relations', $tripId, $relationships, $data);
2490
2491 return $tripId;
2492 }
2493
2494 /**
2495 * Update trip with relationships
2496 */
2497 public function updateWithRelations(int $id, array $data, array $relationships = []): bool
2498 {
2499 // Extract relationship data (excluding itinerary - handled separately)
2500 $destinations = $relationships['destinations'] ?? null;
2501 $activities = $relationships['activities'] ?? null;
2502 $tripCategories = $relationships['trip_category'] ?? null;
2503 $priceTypes = $relationships['price_types'] ?? null;
2504 $highlights = $relationships['highlights'] ?? null;
2505 $landmarks = $relationships['landmarks'] ?? null;
2506 $galleryImages = $relationships['gallery_images'] ?? null;
2507 $faqs = $relationships['faqs'] ?? null;
2508 $downloadableItems = $relationships['downloadable_items'] ?? null;
2509 // ITINERARY IS HANDLED SEPARATELY - NEVER PROCESSED HERE
2510 $availabilityDates = $relationships['availability_dates'] ?? null;
2511 $attributes = $relationships['attributes'] ?? null;
2512
2513 // Remove relationship data from main data (excluding itinerary - handled separately)
2514 unset(
2515 $data['destinations'],
2516 $data['activities'],
2517 $data['trip_category'],
2518 $data['price_types'],
2519 $data['highlights'],
2520 $data['landmarks'],
2521 $data['gallery_images'],
2522 $data['faqs'],
2523 // ITINERARY IS HANDLED SEPARATELY - DO NOT UNSET
2524 $data['availability_dates'],
2525 $data['attributes']
2526 );
2527
2528 // Update main trip record
2529 $result = $this->update($id, $data);
2530
2531 // Update relationships if provided
2532 if ($destinations !== null) {
2533 $this->saveDestinations($id, $destinations);
2534 }
2535
2536 if ($activities !== null) {
2537 $this->saveActivities($id, $activities);
2538 }
2539
2540 if ($tripCategories !== null) {
2541 $this->saveTripCategories($id, $tripCategories);
2542 }
2543
2544 if ($priceTypes !== null) {
2545 $this->savePriceTypes($id, $priceTypes);
2546 }
2547
2548 if ($highlights !== null) {
2549 $this->saveHighlights($id, $highlights);
2550 }
2551
2552 if ($landmarks !== null) {
2553 $this->saveLandmarks($id, $landmarks);
2554 }
2555
2556 if ($galleryImages !== null) {
2557 $this->saveGalleryImages($id, $galleryImages);
2558 }
2559
2560 if ($faqs !== null) {
2561 $this->saveFaqs($id, $faqs);
2562 }
2563
2564 if (is_array($downloadableItems)) {
2565 $downloadRepo = new TripDownloadRepository();
2566 $downloadRepo->replaceForTrip($id, $downloadableItems);
2567 }
2568
2569 // ITINERARY SHOULD NEVER BE PROCESSED DURING TRIP UPDATES
2570 // Itinerary updates should be handled separately through dedicated endpoints
2571 // This ensures complete separation of concerns and prevents data loss
2572
2573 if ($availabilityDates !== null) {
2574 $this->saveAvailabilityDates($id, $availabilityDates);
2575 }
2576
2577 if ($attributes !== null) {
2578 $this->saveAttributes($id, $attributes);
2579 }
2580
2581 if ($result) {
2582 // Ensure caches reflect relationship writes (update() runs before saves; this runs after).
2583 Cache::invalidateAfterTripWrite('update', $id);
2584 }
2585
2586 do_action('yatra_trip_updated_with_relations', $id, $relationships, $data);
2587
2588 return $result;
2589 }
2590
2591 /**
2592 * Soft delete a trip
2593 */
2594 public function softDelete(int $id, int $userId): bool
2595 {
2596 return $this->update($id, [
2597 'deleted_at' => current_time('mysql'),
2598 'deleted_by' => $userId,
2599 ]);
2600 }
2601
2602 /**
2603 * Restore a soft-deleted trip
2604 */
2605 public function restore(int $id): bool
2606 {
2607 return $this->update($id, [
2608 'deleted_at' => null,
2609 'deleted_by' => null,
2610 ]);
2611 }
2612
2613 /**
2614 * Get active trips (not deleted, with specified statuses)
2615 *
2616 * @param array $args Query arguments
2617 * @param array $statuses Array of statuses to filter by. Defaults to ['published']
2618 * @return array
2619 */
2620 public function getActive(array $args = [], array $statuses = ['publish']): array
2621 {
2622 $args['where']['deleted_at'] = null;
2623
2624 // If status is already set in where clause, respect it
2625 if (!isset($args['where']['status'])) {
2626 $args['where']['status'] = $statuses;
2627 }
2628
2629 return $this->all($args);
2630 }
2631
2632 /**
2633 * Find trips within a price range, considering all pricing types
2634 *
2635 * @param float $min_price Minimum price
2636 * @param float $max_price Maximum price
2637 * @param array $args Additional query arguments
2638 * @return array Array of trips
2639 */
2640 public function findByPriceRange(float $min_price = 0, float $max_price = 0, array $args = []): array
2641 {
2642 global $wpdb;
2643
2644 // DEBUG: Log method entry
2645 if (defined('WP_DEBUG') && WP_DEBUG) {
2646 }
2647
2648 // Base query to get all active trips
2649 $args['where']['deleted_at'] = null;
2650 if (!isset($args['where']['status'])) {
2651 $args['where']['status'] = ['publish'];
2652 }
2653
2654 // DEBUG: Log query args
2655 if (defined('WP_DEBUG') && WP_DEBUG) {
2656 }
2657
2658 // Get all active trips first
2659 $all_trips = $this->all($args);
2660
2661 if (empty($all_trips)) {
2662 return [];
2663 }
2664
2665 // Get trip IDs
2666 $trip_ids = array_map(function($trip) {
2667 return $trip->id;
2668 }, $all_trips);
2669
2670 // Get trip prices and filter by range (price types table removed)
2671 $filtered_trips = [];
2672
2673 // DEBUG: Log price filtering process
2674 if (defined('WP_DEBUG') && WP_DEBUG) {
2675 }
2676
2677 foreach ($all_trips as $trip) {
2678 $trip_id = $trip->id;
2679 $trip_min_price = PHP_FLOAT_MAX;
2680
2681 // Check trip's own price first
2682 if (!empty($trip->sale_price) && $trip->sale_price > 0) {
2683 $trip_min_price = min($trip_min_price, (float)$trip->sale_price);
2684 }
2685 if (!empty($trip->discounted_price) && $trip->discounted_price > 0) {
2686 $trip_min_price = min($trip_min_price, (float)$trip->discounted_price);
2687 }
2688 if (!empty($trip->original_price) && $trip->original_price > 0) {
2689 $trip_min_price = min($trip_min_price, (float)$trip->original_price);
2690 }
2691
2692 // If no valid price found, skip
2693 if ($trip_min_price === PHP_FLOAT_MAX) {
2694 continue;
2695 }
2696
2697 // Apply price range filter
2698 $passes_filter = ($min_price === 0 || $trip_min_price >= $min_price) &&
2699 ($max_price === 0 || $trip_min_price <= $max_price);
2700
2701 // DEBUG: Log individual trip filtering
2702 if (defined('WP_DEBUG') && WP_DEBUG) {
2703 }
2704
2705 if ($passes_filter) {
2706 $trip->min_price = $trip_min_price;
2707 $filtered_trips[] = $trip;
2708 }
2709 }
2710
2711 return $filtered_trips;
2712 }
2713
2714 /**
2715 * Build where clause (override to handle soft deletes)
2716 */
2717 protected function buildWhereClause(array $args): string
2718 {
2719 $where = parent::buildWhereClause($args);
2720
2721 // Add soft delete filter if not explicitly requested
2722 if (!isset($args['include_deleted']) || !$args['include_deleted']) {
2723 if ($where) {
2724 $where .= ' AND (deleted_at IS NULL OR deleted_at = \'0000-00-00 00:00:00\')';
2725 } else {
2726 $where = 'WHERE (deleted_at IS NULL OR deleted_at = \'0000-00-00 00:00:00\')';
2727 }
2728 }
2729
2730 return $where;
2731 }
2732
2733 /**
2734 * Count trips by status
2735 */
2736 public function countByStatus(string $status): int
2737 {
2738 $table = esc_sql($this->table);
2739 $count = $this->wpdb->get_var(
2740 $this->wpdb->prepare(
2741 "SELECT COUNT(*) FROM `{$table}`
2742 WHERE status = %s
2743 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')",
2744 $status
2745 )
2746 );
2747
2748 return (int) $count;
2749 }
2750
2751 /**
2752 * Search trips by keyword
2753 */
2754 public function search(string $keyword, array $args = []): array
2755 {
2756 $table = esc_sql($this->table);
2757 $where = $this->buildWhereClause($args);
2758 $order = $this->buildOrderClause($args);
2759 $limit = $this->buildLimitClause($args);
2760
2761 $searchTerm = '%' . $this->wpdb->esc_like($keyword) . '%';
2762
2763 // Build search condition
2764 $searchCondition = "(title LIKE %s OR description LIKE %s OR short_description LIKE %s)";
2765
2766 // If we have a WHERE clause, add AND; otherwise start with WHERE
2767 if (!empty($where)) {
2768 $whereClause = "{$where} AND {$searchCondition}";
2769 } else {
2770 $whereClause = "WHERE {$searchCondition}";
2771 }
2772
2773 $query = $this->wpdb->prepare(
2774 "SELECT * FROM `{$table}` {$whereClause} {$order} {$limit}",
2775 $searchTerm,
2776 $searchTerm,
2777 $searchTerm
2778 );
2779
2780 return $this->wpdb->get_results($query) ?: [];
2781 }
2782
2783 /**
2784 * Decode included/excluded items JSON column stored on itinerary entries
2785 */
2786 private function decodeAmenityItems($value): array
2787 {
2788 if (empty($value)) {
2789 return [];
2790 }
2791
2792 if (is_array($value)) {
2793 return $value;
2794 }
2795
2796 if (is_string($value)) {
2797 $decoded = json_decode($value, true);
2798 return is_array($decoded) ? $decoded : [];
2799 }
2800
2801 return [];
2802 }
2803
2804 /**
2805 * Human-readable label for one included_items JSON element (title, name, label, or plain string).
2806 */
2807 private function extractIncludedItemLabel($item): string
2808 {
2809 if (is_string($item)) {
2810 $t = sanitize_text_field($item);
2811
2812 return $t;
2813 }
2814 if (is_object($item)) {
2815 $item = (array) $item;
2816 }
2817 if (!is_array($item)) {
2818 return '';
2819 }
2820 foreach (['title', 'name', 'label', 'text'] as $k) {
2821 if (!empty($item[$k]) && is_scalar($item[$k])) {
2822 $t = sanitize_text_field((string) $item[$k]);
2823
2824 return $t;
2825 }
2826 }
2827
2828 return '';
2829 }
2830
2831 /**
2832 * Get trip title by ID
2833 */
2834 public function getTripTitle(int $tripId): string
2835 {
2836 global $wpdb;
2837 $trips_table = $this->getTableName();
2838
2839 return (string) $wpdb->get_var($wpdb->prepare(
2840 "SELECT title FROM {$trips_table} WHERE id = %d",
2841 $tripId
2842 )) ?: '';
2843 }
2844
2845 /**
2846 * Count all trips
2847 */
2848 public function countAllTrips(): int
2849 {
2850 global $wpdb;
2851 $trips_table = $this->getTableName();
2852
2853 return (int) $wpdb->get_var("SELECT COUNT(*) FROM `{$trips_table}`");
2854 }
2855
2856 /**
2857 * Get trip status counts
2858 */
2859 public function getTripStatusCounts(): array
2860 {
2861 global $wpdb;
2862 $trips_table = $this->getTableName();
2863
2864 return $wpdb->get_results("SELECT status, COUNT(*) as count FROM `{$trips_table}` GROUP BY status");
2865 }
2866
2867 /**
2868 * Get trip with destinations
2869 */
2870 public function getTripWithDestinations(int $tripId): ?\stdClass
2871 {
2872 global $wpdb;
2873 $trips_table = $this->getTableName();
2874
2875 // Use ClassificationsTable for destinations (type = 'destination')
2876 $trip_destinations_table = TripClassificationsTable::getTableName();
2877 $destinations_table = ClassificationsTable::getTableName();
2878
2879 return $wpdb->get_row($wpdb->prepare(
2880 "SELECT t.id, t.title, t.slug, t.status, t.pricing_type, t.original_price, t.sale_price,
2881 td.destination_id, d.name as destination_name, d.slug as destination_slug
2882 FROM {$trips_table} t
2883 LEFT JOIN {$trip_destinations_table} td ON td.trip_id = t.id
2884 LEFT JOIN {$destinations_table} d ON d.id = td.destination_id
2885 WHERE t.id = %d",
2886 $tripId
2887 ));
2888 }
2889
2890 /**
2891 * Get trip destinations
2892 */
2893 public function getTripDestinations(int $tripId): array
2894 {
2895 global $wpdb;
2896
2897 // Use new TripClassificationsTable for trip-destination relationships
2898 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
2899 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
2900
2901 $results = $wpdb->get_results($wpdb->prepare(
2902 "SELECT tc.trip_id, tc.classification_id, c.name, c.slug
2903 FROM {$tripClassificationsTable} tc
2904 LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id
2905 WHERE tc.trip_id = %d AND tc.classification_type = %s",
2906 $tripId, ClassificationTypes::DESTINATION
2907 ));
2908
2909 // Filter out destinations with missing classification data
2910 return array_filter($results, function($destination) {
2911 return !empty($destination->name) && !empty($destination->slug);
2912 });
2913 }
2914
2915 /**
2916 * Get trip activities
2917 */
2918 public function getTripActivities(int $tripId): array
2919 {
2920 global $wpdb;
2921
2922 // Use new TripClassificationsTable for trip-activity relationships
2923 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
2924 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
2925
2926 return $wpdb->get_results($wpdb->prepare(
2927 "SELECT tc.trip_id, c.id, c.name, c.slug
2928 FROM {$tripClassificationsTable} tc
2929 INNER JOIN {$classificationsTable} c ON c.id = tc.classification_id
2930 WHERE tc.trip_id = %d AND c.type = 'activity'",
2931 $tripId
2932 ));
2933 }
2934
2935 /**
2936 * Get trip with availability
2937 */
2938 public function getTripWithAvailability(int $tripId): ?\stdClass
2939 {
2940 global $wpdb;
2941 $trips_table = $this->getTableName();
2942
2943 // Use TripAvailabilityDatesTable for availability data
2944 $availability_table = \Yatra\Database\Tables\TripAvailabilityDatesTable::getTableName();
2945
2946 return $wpdb->get_row($wpdb->prepare(
2947 "SELECT t.*, a.departure_date, a.seats_total, a.seats_reserved AS seats_booked, a.status as availability_status
2948 FROM {$trips_table} t
2949 LEFT JOIN {$availability_table} a ON a.trip_id = t.id
2950 WHERE t.id = %d",
2951 $tripId
2952 ));
2953 }
2954
2955 /**
2956 * Get price range statistics for published trips.
2957 *
2958 * IMPORTANT: the previous SQL-only implementation only considered
2959 * the legacy `original_price` / `discounted_price` / `sale_price`
2960 * columns. For trips using traveler-based pricing (per-category
2961 * prices stored in the `price_types` JSON column), those legacy
2962 * columns are often empty or stale — so the computed max was lower
2963 * than the actually-displayed price, and the price-range slider
2964 * cut off above the real maximum (eg. trip cost €3755 but slider
2965 * stopped at €3731). Worse, the trip then couldn't be filtered
2966 * into the results because the effective-price WHERE excluded it.
2967 *
2968 * Fix: walk the published trips once in PHP and let
2969 * `TripPricingService` decide each trip's effective price using
2970 * the same logic the listing/single-trip pages use to DISPLAY the
2971 * price. We also track the per-trip MIN/MAX across categories so
2972 * the slider bounds cover every traveler tier (not only the
2973 * default / cheapest).
2974 *
2975 * @return object Object with min_price and max_price properties
2976 */
2977 public function getPriceRangeStats(): object
2978 {
2979 $table = $this->getTableName();
2980 $rows = $this->wpdb->get_results(
2981 "SELECT id, original_price, discounted_price, sale_price, price_types
2982 FROM {$table}
2983 WHERE status IN ('publish', 'published')
2984 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')"
2985 ) ?: [];
2986
2987 $minPrice = null;
2988 $maxPrice = null;
2989
2990 foreach ($rows as $row) {
2991 $tripPrices = $this->collectTripDisplayPrices($row);
2992 foreach ($tripPrices as $price) {
2993 if ($price <= 0) {
2994 continue;
2995 }
2996 if ($minPrice === null || $price < $minPrice) {
2997 $minPrice = $price;
2998 }
2999 if ($maxPrice === null || $price > $maxPrice) {
3000 $maxPrice = $price;
3001 }
3002 }
3003 }
3004
3005 // Fold in per-date / per-rule / per-departure price overrides so the
3006 // slider bounds span every price a customer can actually be charged,
3007 // not just the trips-table base. See collectPriceOverridePoints().
3008 foreach ($this->collectPriceOverridePoints() as $price) {
3009 if ($price <= 0) {
3010 continue;
3011 }
3012 if ($minPrice === null || $price < $minPrice) {
3013 $minPrice = $price;
3014 }
3015 if ($maxPrice === null || $price > $maxPrice) {
3016 $maxPrice = $price;
3017 }
3018 }
3019
3020 return (object) [
3021 'min_price' => $minPrice,
3022 'max_price' => $maxPrice,
3023 ];
3024 }
3025
3026 /**
3027 * Collect every price a single trip might display to a user.
3028 *
3029 * Returns BOTH the legacy "regular" effective price AND every
3030 * per-category effective price from `price_types`. Used by
3031 * `getPriceRangeStats()` so the price-range slider's bounds cover
3032 * the full range a customer could see — eg. for a traveler-based
3033 * trip with Adult €3755 / Child €1500 / Infant €100, the array
3034 * includes all three so the slider stretches from €100 to €3755.
3035 *
3036 * @param object $row Raw trip row (must include `price_types`,
3037 * `original_price`, `discounted_price`,
3038 * `sale_price`).
3039 * @return float[]
3040 */
3041 protected function collectTripDisplayPrices(object $row): array
3042 {
3043 $prices = [];
3044
3045 // Legacy regular pricing.
3046 $legacyPrice = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($row);
3047 if ($legacyPrice > 0) {
3048 $prices[] = $legacyPrice;
3049 }
3050
3051 // Per-category prices (traveler-based pricing). Decode whether
3052 // `price_types` is stored as a JSON string (raw DB row) or an
3053 // already-decoded array (in-memory trip object).
3054 $priceTypes = $row->price_types ?? null;
3055 if (is_string($priceTypes) && $priceTypes !== '') {
3056 $decoded = json_decode($priceTypes, true);
3057 if (is_array($decoded)) {
3058 $priceTypes = $decoded;
3059 }
3060 }
3061 if (is_array($priceTypes)) {
3062 foreach ($priceTypes as $pt) {
3063 $price = \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt);
3064 if ($price > 0) {
3065 $prices[] = $price;
3066 }
3067 }
3068 }
3069
3070 return $prices;
3071 }
3072
3073 /**
3074 * Effective price points that OVERRIDE a trip's base price for specific
3075 * dates, recurring rules, or individual departures.
3076 *
3077 * The price a customer actually sees, filters on, and pays is not always
3078 * the trips-table base price: it can be overridden per
3079 * - specific availability date (yatra_trip_availability_dates)
3080 * - recurring availability rule (yatra_trip_availability_rules)
3081 * - individual departure (yatra_trip_departures)
3082 * each carrying its own original/discounted price and per-category
3083 * (traveler-based) pricing JSON. The price-range slider bounds must span
3084 * these too — otherwise a trip whose highest bookable price lives only on
3085 * an override (e.g. a peak-season date priced €3,055 while the base is
3086 * lower) is unreachable on the filter even though customers can book it.
3087 *
3088 * Effective-price semantics mirror the customer-facing display exactly by
3089 * reusing collectTripDisplayPrices(): the discounted/sale price when set,
3090 * otherwise the original — the same amount shown and charged. Non-bookable
3091 * rows (blocked / cancelled / past dates, inactive rules) are excluded so
3092 * they can't push the slider max beyond any price a customer can reach.
3093 *
3094 * @return array<int,float> Effective override prices (each > 0).
3095 */
3096 protected function collectPriceOverridePoints(): array
3097 {
3098 $prices = [];
3099 $today = current_time('Y-m-d');
3100
3101 // Only overrides that belong to a genuinely available (published, not
3102 // soft-deleted) trip may influence the bounds — otherwise a stray
3103 // override on a draft/trashed trip leaks a phantom max that no
3104 // customer-visible trip carries. Mirrors the trips-table filter the
3105 // base bound uses.
3106 $tripsTable = $this->getTableName();
3107 $publishedJoin = "INNER JOIN {$tripsTable} t ON t.id = o.trip_id"
3108 . " AND t.status IN ('publish', 'published')"
3109 . " AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')";
3110
3111 // 1) Specific availability dates — same column shape as a trip row, so
3112 // collectTripDisplayPrices() resolves them identically.
3113 $datesTable = \Yatra\Database\Tables\TripAvailabilityDatesTable::getTableName();
3114 $dateRows = $this->wpdb->get_results($this->wpdb->prepare(
3115 "SELECT o.original_price, o.discounted_price, NULL AS sale_price, o.price_types
3116 FROM {$datesTable} o
3117 {$publishedJoin}
3118 WHERE COALESCE(o.is_blocked, 0) = 0
3119 AND o.status NOT IN ('cancelled', 'blocked', 'closed')
3120 AND (o.departure_date IS NULL OR o.departure_date >= %s)",
3121 $today
3122 )) ?: [];
3123 foreach ($dateRows as $row) {
3124 foreach ($this->collectTripDisplayPrices($row) as $p) {
3125 $prices[] = $p;
3126 }
3127 }
3128
3129 // 2) Recurring rules (active only — mirrors the `status = 'active'`
3130 // filter RecurringAvailabilityRepository uses when generating
3131 // availability). `traveler_pricing` holds the per-category JSON; a
3132 // fixed `price_override` is an absolute per-booking price. A
3133 // percentage override is a relative adjustment to the trip base —
3134 // already spanned by the base bound — so it adds no new absolute max.
3135 $rulesTable = \Yatra\Database\Tables\TripAvailabilityRulesTable::getTableName();
3136 $ruleRows = $this->wpdb->get_results(
3137 "SELECT o.original_price, o.sale_price, NULL AS discounted_price,
3138 o.price_override, o.price_type, o.traveler_pricing AS price_types
3139 FROM {$rulesTable} o
3140 {$publishedJoin}
3141 WHERE o.status = 'active'"
3142 ) ?: [];
3143 foreach ($ruleRows as $row) {
3144 foreach ($this->collectTripDisplayPrices($row) as $p) {
3145 $prices[] = $p;
3146 }
3147 if ($row->price_override !== null && (string) $row->price_type !== 'percentage') {
3148 $po = (float) $row->price_override;
3149 if ($po > 0) {
3150 $prices[] = $po;
3151 }
3152 }
3153 }
3154
3155 // 3) Departures — `price_override` is an absolute price;
3156 // `price_by_traveler_type` is the per-category JSON.
3157 $depTable = \Yatra\Database\Tables\DeparturesTable::getTableName();
3158 $depRows = $this->wpdb->get_results($this->wpdb->prepare(
3159 "SELECT o.price_override AS original_price, NULL AS discounted_price,
3160 NULL AS sale_price, o.price_by_traveler_type AS price_types
3161 FROM {$depTable} o
3162 {$publishedJoin}
3163 WHERE o.status NOT IN ('cancelled')
3164 AND (COALESCE(o.start_date, o.`date`) IS NULL OR COALESCE(o.start_date, o.`date`) >= %s)",
3165 $today
3166 )) ?: [];
3167 foreach ($depRows as $row) {
3168 foreach ($this->collectTripDisplayPrices($row) as $p) {
3169 $prices[] = $p;
3170 }
3171 }
3172
3173 return $prices;
3174 }
3175
3176 /**
3177 * Count trips by difficulty level
3178 *
3179 * @param int $difficultyLevelId Difficulty level ID
3180 * @return int Number of trips with this difficulty level
3181 */
3182 public function countByDifficultyLevel(int $difficultyLevelId): int
3183 {
3184 $table = $this->getTableName();
3185
3186 // Use ClassificationsTable for difficulty levels (type = 'difficulty')
3187 $difficultyTable = ClassificationsTable::getTableName();
3188
3189 return (int) $this->wpdb->get_var($this->wpdb->prepare(
3190 "SELECT COUNT(*) FROM {$table} t
3191 LEFT JOIN {$difficultyTable} dl ON (t.difficulty_level = dl.id OR t.difficulty_level = dl.slug OR t.difficulty_level = dl.name)
3192 WHERE dl.id = %d AND t.status IN ('publish','published')",
3193 $difficultyLevelId
3194 ));
3195 }
3196
3197 /**
3198 * Check if reviews table exists
3199 *
3200 * @return bool True if reviews table exists
3201 */
3202 public function reviewsTableExists(): bool
3203 {
3204 // Use ReviewsTable for reviews
3205 $reviewsTable = ReviewsTable::getTableName();
3206 return (bool) $this->wpdb->get_var(
3207 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
3208 WHERE TABLE_SCHEMA = DATABASE()
3209 AND TABLE_NAME = '{$reviewsTable}'"
3210 );
3211 }
3212
3213 /**
3214 * Count trips by minimum rating
3215 *
3216 * @param int $minRating Minimum rating
3217 * @return int Number of trips with this rating or above
3218 */
3219 public function countByMinRating(int $minRating): int
3220 {
3221 $table = $this->getTableName();
3222
3223 // Use ReviewsTable for reviews
3224 $reviewsTable = ReviewsTable::getTableName();
3225
3226 return (int) $this->wpdb->get_var($this->wpdb->prepare(
3227 "SELECT COUNT(DISTINCT t.id)
3228 FROM {$reviewsTable} r
3229 INNER JOIN {$table} t ON r.trip_id = t.id
3230 WHERE r.rating >= %d AND t.status IN ('publish','published')",
3231 $minRating
3232 ));
3233 }
3234
3235 /**
3236 * Count trips by category
3237 *
3238 * @param int $categoryId Category ID
3239 * @return int Number of trips in this category
3240 */
3241 public function countByCategory(int $categoryId): int
3242 {
3243 $table = $this->getTableName();
3244
3245 // Use TripClassificationsTable for trip-category relationships
3246 $categoryTable = TripClassificationsTable::getTableName();
3247
3248 $c = ClassificationsTable::getTableName();
3249
3250 return (int) $this->wpdb->get_var($this->wpdb->prepare(
3251 "SELECT COUNT(DISTINCT t.id) FROM {$table} t
3252 INNER JOIN {$categoryTable} ttc ON t.id = ttc.trip_id AND ttc.is_active = 1
3253 INNER JOIN {$c} cls ON cls.id = ttc.classification_id AND cls.type = %s
3254 WHERE ttc.classification_id = %d
3255 AND t.status IN ('publish', 'published')
3256 AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')",
3257 ClassificationTypes::CATEGORY,
3258 $categoryId
3259 ));
3260 }
3261
3262 /**
3263 * Count trips by destination
3264 *
3265 * @param int $destinationId Destination ID
3266 * @return int Number of trips to this destination
3267 */
3268 public function countByDestination(int $destinationId): int
3269 {
3270 $table = $this->getTableName();
3271 $tc = TripClassificationsTable::getTableName();
3272 $c = ClassificationsTable::getTableName();
3273
3274 return (int) $this->wpdb->get_var($this->wpdb->prepare(
3275 "SELECT COUNT(DISTINCT t.id) FROM {$table} t
3276 INNER JOIN {$tc} ttc ON t.id = ttc.trip_id AND ttc.is_active = 1
3277 INNER JOIN {$c} cls ON cls.id = ttc.classification_id AND cls.type = %s
3278 WHERE ttc.classification_id = %d
3279 AND t.status IN ('publish', 'published')
3280 AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')",
3281 ClassificationTypes::DESTINATION,
3282 $destinationId
3283 ));
3284 }
3285
3286 /**
3287 * Count trips by activity
3288 *
3289 * @param int $activityId Activity ID
3290 * @return int Number of trips with this activity
3291 */
3292 public function countByActivity(int $activityId): int
3293 {
3294 $table = $this->getTableName();
3295 $tc = TripClassificationsTable::getTableName();
3296 $c = ClassificationsTable::getTableName();
3297
3298 return (int) $this->wpdb->get_var($this->wpdb->prepare(
3299 "SELECT COUNT(DISTINCT t.id) FROM {$table} t
3300 INNER JOIN {$tc} ttc ON t.id = ttc.trip_id AND ttc.is_active = 1
3301 INNER JOIN {$c} cls ON cls.id = ttc.classification_id AND cls.type = %s
3302 WHERE ttc.classification_id = %d
3303 AND t.status IN ('publish', 'published')
3304 AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')",
3305 ClassificationTypes::ACTIVITY,
3306 $activityId
3307 ));
3308 }
3309
3310 /**
3311 * Get popular trips for cache warming
3312 *
3313 * @param int $limit Number of trips to return
3314 * @return array Array of popular trip IDs
3315 */
3316 public function getPopularTrips(int $limit = 20): array
3317 {
3318 global $wpdb;
3319 $tripsTable = $this->getTableName();
3320
3321 // Using hardcoded table name since there's no dedicated repository for this table
3322 $bookingsTable = BookingsTable::getTableName();
3323
3324 return $wpdb->get_results("
3325 SELECT t.id
3326 FROM {$tripsTable} t
3327 LEFT JOIN {$bookingsTable} b ON b.trip_id = t.id
3328 WHERE t.status = 'publish'
3329 GROUP BY t.id
3330 ORDER BY COUNT(b.id) DESC
3331 LIMIT {$limit}
3332 ") ?: [];
3333 }
3334
3335 /**
3336 * Get price statistics for filter sidebar
3337 */
3338 public function getPriceStats(): ?object
3339 {
3340 $table = $this->getTableName();
3341
3342 // Bounds must cover EVERY price a customer can actually filter on.
3343 // The search filter matches a trip if its legacy effective price OR
3344 // ANY per-category price in `price_types` falls in range (see the
3345 // price clause in the listing query), so the slider's min/max have to
3346 // span the same set — otherwise a traveler-based trip whose highest
3347 // tier (eg. Adult €3,055) lives only in `price_types` becomes
3348 // unreachable when the column-only max stops short (eg. €3,045).
3349 //
3350 // Reuse `collectTripDisplayPrices()` — the single source of truth also
3351 // used by `getPriceRangeStats()` and the listing/single-trip DISPLAY —
3352 // so bound == display == filter for both regular and traveler-based
3353 // pricing.
3354 $rows = $this->wpdb->get_results(
3355 "SELECT id, original_price, discounted_price, sale_price, price_types
3356 FROM {$table}
3357 WHERE status IN ('publish', 'published')
3358 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')"
3359 ) ?: [];
3360
3361 $minPrice = null;
3362 $maxPrice = null;
3363 $sum = 0.0;
3364 $count = 0;
3365
3366 foreach ($rows as $row) {
3367 foreach ($this->collectTripDisplayPrices($row) as $price) {
3368 if ($price <= 0) {
3369 continue;
3370 }
3371 if ($minPrice === null || $price < $minPrice) {
3372 $minPrice = $price;
3373 }
3374 if ($maxPrice === null || $price > $maxPrice) {
3375 $maxPrice = $price;
3376 }
3377 $sum += $price;
3378 $count++;
3379 }
3380 }
3381
3382 // Fold in per-date / per-rule / per-departure price overrides so the
3383 // slider spans every price a customer can actually be charged (bounds
3384 // only — the average stays a base-trip figure). See
3385 // collectPriceOverridePoints().
3386 foreach ($this->collectPriceOverridePoints() as $price) {
3387 if ($price <= 0) {
3388 continue;
3389 }
3390 if ($minPrice === null || $price < $minPrice) {
3391 $minPrice = $price;
3392 }
3393 if ($maxPrice === null || $price > $maxPrice) {
3394 $maxPrice = $price;
3395 }
3396 }
3397
3398 if ($minPrice === null || $maxPrice === null) {
3399 return null;
3400 }
3401
3402 // Widen to whole units so the template's `(int)` cast on the bounds
3403 // can never truncate a fractional boundary out of range (eg. a
3404 // €3,055.50 max would otherwise int-cast to 3055 and exclude it).
3405 return (object) [
3406 'min_price' => (float) floor($minPrice),
3407 'max_price' => (float) ceil($maxPrice),
3408 'avg_price' => $count > 0 ? (float) ($sum / $count) : 0.0,
3409 ];
3410 }
3411
3412 /**
3413 * Distinct accommodation_type values on published trips with counts.
3414 *
3415 * @return list<object{name: string, trip_count: int}>
3416 */
3417 public function getAccommodationTypes(): array
3418 {
3419 if (!$this->tripTableHasColumn('accommodation_type')) {
3420 return [];
3421 }
3422 $table = $this->getTableName();
3423 $rows = $this->wpdb->get_results(
3424 "SELECT TRIM(accommodation_type) AS name, COUNT(*) AS trip_count
3425 FROM {$table}
3426 WHERE status IN ('publish', 'published')
3427 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')
3428 AND accommodation_type IS NOT NULL AND TRIM(accommodation_type) <> ''
3429 GROUP BY TRIM(accommodation_type)
3430 ORDER BY trip_count DESC, name ASC"
3431 ) ?: [];
3432
3433 $out = [];
3434 foreach ($rows as $r) {
3435 $out[] = (object) [
3436 'name' => (string) $r->name,
3437 'trip_count' => (int) $r->trip_count,
3438 ];
3439 }
3440
3441 return $out;
3442 }
3443
3444 /**
3445 * Included item titles from trip.included_items JSON, aggregated by trip count.
3446 *
3447 * @return list<object{service_name: string, trip_count: int}>
3448 */
3449 public function getIncludedServices(): array
3450 {
3451 if (!$this->tripTableHasColumn('included_items')) {
3452 return [];
3453 }
3454 $table = $this->getTableName();
3455 $jsons = $this->wpdb->get_col(
3456 "SELECT included_items FROM {$table}
3457 WHERE status IN ('publish', 'published')
3458 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')
3459 AND included_items IS NOT NULL
3460 AND included_items <> ''
3461 AND included_items <> '[]'"
3462 ) ?: [];
3463
3464 $counts = [];
3465 foreach ($jsons as $json) {
3466 $decoded = json_decode((string) $json, true);
3467 if (!is_array($decoded)) {
3468 continue;
3469 }
3470 $list = $decoded;
3471 if (isset($decoded['items']) && is_array($decoded['items'])) {
3472 $list = $decoded['items'];
3473 }
3474 foreach ($list as $item) {
3475 $title = $this->extractIncludedItemLabel($item);
3476 if ($title === '') {
3477 continue;
3478 }
3479 $counts[$title] = ($counts[$title] ?? 0) + 1;
3480 }
3481 }
3482 arsort($counts, SORT_NUMERIC);
3483 $out = [];
3484 foreach ($counts as $name => $c) {
3485 $out[] = (object) ['service_name' => $name, 'trip_count' => (int) $c];
3486 }
3487
3488 return $out;
3489 }
3490
3491 /**
3492 * Min/max duration_days among published trips (for search UI). Falls back to 1–30 when empty.
3493 *
3494 * @return array{min: int, max: int}
3495 */
3496 public function getDurationDaysBounds(): array
3497 {
3498 if (!$this->tripTableHasColumn('duration_days')) {
3499 return ['min' => 1, 'max' => 30];
3500 }
3501 $table = $this->getTableName();
3502 $row = $this->wpdb->get_row(
3503 "SELECT
3504 MIN(NULLIF(CAST(duration_days AS UNSIGNED), 0)) AS min_days,
3505 MAX(CAST(duration_days AS UNSIGNED)) AS max_days
3506 FROM {$table}
3507 WHERE status IN ('publish', 'published')
3508 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')"
3509 );
3510 $min = (int) ($row->min_days ?? 1);
3511 $max = (int) ($row->max_days ?? 1);
3512 if ($min < 1) {
3513 $min = 1;
3514 }
3515 if ($max < $min) {
3516 $max = $min;
3517 }
3518 // Sensible upper bound for dual slider UX
3519 if ($max > 365) {
3520 $max = 365;
3521 }
3522
3523 return ['min' => $min, 'max' => $max];
3524 }
3525
3526 /**
3527 * Get duration options (placeholder)
3528 *
3529 * @return array
3530 */
3531 public function getDurationOptions(): array
3532 {
3533 return [];
3534 }
3535
3536 /**
3537 * Get group size options (placeholder)
3538 *
3539 * @return array
3540 */
3541 public function getGroupSizeOptions(): array
3542 {
3543 return [];
3544 }
3545
3546 /**
3547 * Get physical grades (placeholder)
3548 *
3549 * @return array
3550 */
3551 public function getPhysicalGrades(): array
3552 {
3553 return [];
3554 }
3555
3556 /**
3557 * Get trip types
3558 */
3559 public function getTripTypes(): array
3560 {
3561 return [
3562 (object) ['value' => 'single_day', 'label' => __('Single day', 'yatra')],
3563 (object) ['value' => 'multi_day', 'label' => __('Multi-day', 'yatra')],
3564 (object) ['value' => 'flexible', 'label' => __('Flexible', 'yatra')],
3565 ];
3566 }
3567
3568 /**
3569 * Count trips by trip type
3570 */
3571 public function countByTripType(string $tripType): int
3572 {
3573 global $wpdb;
3574 $table = $this->getTableName();
3575
3576 return (int) $wpdb->get_var($wpdb->prepare(
3577 "SELECT COUNT(*) FROM {$table}
3578 WHERE trip_type = %s AND status = 'publish'",
3579 $tripType
3580 ));
3581 }
3582
3583 /**
3584 * Count trips with discounts
3585 */
3586 public function countByDiscount(): int
3587 {
3588 $table = $this->getTableName();
3589 return (int) $this->wpdb->get_var(
3590 "SELECT COUNT(*) FROM {$table}
3591 WHERE status = 'publish' AND (discounted_price IS NOT NULL OR sale_price IS NOT NULL)"
3592 );
3593 }
3594
3595 /**
3596 * Count trips with early bird offers
3597 */
3598 public function countByEarlyBird(): int
3599 {
3600 $table = $this->getTableName();
3601
3602 // Check if column exists
3603 $column_exists = (int) $this->wpdb->get_var(
3604 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
3605 WHERE TABLE_SCHEMA = DATABASE()
3606 AND TABLE_NAME = '{$table}'
3607 AND COLUMN_NAME = 'early_bird_discount_enabled'"
3608 );
3609
3610 if ($column_exists) {
3611 return (int) $this->wpdb->get_var(
3612 "SELECT COUNT(*) FROM {$table}
3613 WHERE status = 'publish' AND early_bird_discount_enabled = 1"
3614 );
3615 }
3616
3617 return 0;
3618 }
3619
3620 /**
3621 * Count trips with last minute deals
3622 */
3623 public function countByLastMinute(): int
3624 {
3625 $table = $this->getTableName();
3626
3627 // Check if column exists
3628 $column_exists = (int) $this->wpdb->get_var(
3629 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
3630 WHERE TABLE_SCHEMA = DATABASE()
3631 AND TABLE_NAME = '{$table}'
3632 AND COLUMN_NAME = 'last_minute_discount_enabled'"
3633 );
3634
3635 if ($column_exists) {
3636 return (int) $this->wpdb->get_var(
3637 "SELECT COUNT(*) FROM {$table}
3638 WHERE status = 'publish' AND last_minute_discount_enabled = 1"
3639 );
3640 }
3641
3642 return 0;
3643 }
3644
3645 /**
3646 * Count trips with instant booking
3647 */
3648 public function countByInstantBooking(): int
3649 {
3650 $table = $this->getTableName();
3651
3652 // Check if column exists
3653 $column_exists = (int) $this->wpdb->get_var(
3654 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
3655 WHERE TABLE_SCHEMA = DATABASE()
3656 AND TABLE_NAME = '{$table}'
3657 AND COLUMN_NAME = 'instant_booking'"
3658 );
3659
3660 if ($column_exists) {
3661 return (int) $this->wpdb->get_var(
3662 "SELECT COUNT(*) FROM {$table}
3663 WHERE status = 'publish' AND instant_booking = 1"
3664 );
3665 }
3666
3667 return 0;
3668 }
3669
3670 /**
3671 * Count trips with flexible dates
3672 */
3673 public function countByFlexibleDates(): int
3674 {
3675 $table = $this->getTableName();
3676
3677 // Check if column exists
3678 $column_exists = (int) $this->wpdb->get_var(
3679 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
3680 WHERE TABLE_SCHEMA = DATABASE()
3681 AND TABLE_NAME = '{$table}'
3682 AND COLUMN_NAME = 'flexible_dates'"
3683 );
3684
3685 if ($column_exists) {
3686 return (int) $this->wpdb->get_var(
3687 "SELECT COUNT(*) FROM {$table}
3688 WHERE status = 'publish' AND flexible_dates = 1"
3689 );
3690 }
3691
3692 return 0;
3693 }
3694
3695 /**
3696 * Count trips requiring deposit
3697 */
3698 public function countByDepositRequired(): int
3699 {
3700 $table = $this->getTableName();
3701
3702 // Check if column exists
3703 $column_exists = (int) $this->wpdb->get_var(
3704 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
3705 WHERE TABLE_SCHEMA = DATABASE()
3706 AND TABLE_NAME = '{$table}'
3707 AND COLUMN_NAME = 'deposit_required'"
3708 );
3709
3710 if ($column_exists) {
3711 return (int) $this->wpdb->get_var(
3712 "SELECT COUNT(*) FROM {$table}
3713 WHERE status = 'publish' AND deposit_required = 1"
3714 );
3715 }
3716
3717 return 0;
3718 }
3719
3720 /**
3721 * Count family friendly trips
3722 */
3723 public function countByFamilyFriendly(): int
3724 {
3725 $table = $this->getTableName();
3726 return (int) $this->wpdb->get_var(
3727 "SELECT COUNT(*) FROM {$table}
3728 WHERE status = 'publish' AND (age_min IS NULL OR age_min <= 5)"
3729 );
3730 }
3731
3732 /**
3733 * Count kids friendly trips
3734 */
3735 public function countByKidsFriendly(): int
3736 {
3737 $table = $this->getTableName();
3738 return (int) $this->wpdb->get_var(
3739 "SELECT COUNT(*) FROM {$table}
3740 WHERE status = 'publish' AND (age_min IS NULL OR age_min <= 12)"
3741 );
3742 }
3743
3744 /**
3745 * Count senior friendly trips
3746 */
3747 public function countBySeniorFriendly(): int
3748 {
3749 $table = $this->getTableName();
3750 return (int) $this->wpdb->get_var(
3751 "SELECT COUNT(*) FROM {$table}
3752 WHERE status = 'publish' AND (age_max IS NULL OR age_max >= 65)"
3753 );
3754 }
3755
3756 /**
3757 * Count adults only trips
3758 */
3759 public function countByAdultsOnly(): int
3760 {
3761 $table = $this->getTableName();
3762 return (int) $this->wpdb->get_var(
3763 "SELECT COUNT(*) FROM {$table}
3764 WHERE status = 'publish' AND age_min >= 18"
3765 );
3766 }
3767
3768 /**
3769 * Get all destinations for search dropdown
3770 * Returns destinations that have associated trips
3771 *
3772 * @return array Array of destination objects
3773 */
3774 public function getAllDestinationsForSearch(): array
3775 {
3776 global $wpdb;
3777
3778 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
3779 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
3780
3781 // Get destinations - try multiple status values
3782 $destinations = $wpdb->get_results("
3783 SELECT DISTINCT c.* FROM {$classificationsTable} c
3784 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
3785 WHERE c.type = 'destination' AND c.status IN ('publish', 'active', 'draft')
3786 ORDER BY c.name ASC
3787 ");
3788
3789 // If no destinations found, try without status filter
3790 if (empty($destinations)) {
3791 $destinations = $wpdb->get_results("
3792 SELECT DISTINCT c.* FROM {$classificationsTable} c
3793 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
3794 WHERE c.type = 'destination'
3795 ORDER BY c.name ASC
3796 ");
3797 }
3798
3799 return $destinations ?: [];
3800 }
3801
3802 /**
3803 * Get all activities for search dropdown
3804 * Returns activities that have associated trips
3805 *
3806 * @return array Array of activity objects
3807 */
3808 public function getAllActivitiesForSearch(): array
3809 {
3810 global $wpdb;
3811
3812 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
3813 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
3814
3815 // Get activities - try multiple status values
3816 $activities = $wpdb->get_results("
3817 SELECT DISTINCT c.* FROM {$classificationsTable} c
3818 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
3819 WHERE c.type = 'activity' AND c.status IN ('publish', 'active', 'draft')
3820 ORDER BY c.name ASC
3821 ");
3822
3823 // If no activities found, try without status filter
3824 if (empty($activities)) {
3825 $activities = $wpdb->get_results("
3826 SELECT DISTINCT c.* FROM {$classificationsTable} c
3827 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
3828 WHERE c.type = 'activity'
3829 ORDER BY c.name ASC
3830 ");
3831 }
3832
3833 return $activities ?: [];
3834 }
3835 }
3836