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

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

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