PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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.4, at app/Repositories/TripRepository.php

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