PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 3.0.16 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 All 85 releases
yatra / app / Repositories / TripRepository.php

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

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