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

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

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