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

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

929 lines 34.1 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\Services;
6
7 use Yatra\Repositories\TripRepository;
8 use Yatra\Repositories\DestinationRepository;
9 use Yatra\Repositories\ActivityRepository;
10 use Yatra\Repositories\TripCategoryRepository;
11 use Yatra\Repositories\ReviewRepository;
12 use Yatra\Utils\Cache;
13 use Yatra\Utils\Logger;
14
15 /**
16 * Trip Listing Service
17 *
18 * Handles business logic for trip listing pages with filtering, pagination,
19 * and data enrichment. Follows single responsibility principle by separating
20 * business logic from data access and presentation layers.
21 */
22 class TripListingService extends BaseService
23 {
24 private TripRepository $tripRepository;
25 private DestinationRepository $destinationRepository;
26 private ActivityRepository $activityRepository;
27 private TripCategoryRepository $categoryRepository;
28 private ReviewRepository $reviewRepository;
29
30 protected function getRepository(): TripRepository
31 {
32 return $this->tripRepository;
33 }
34
35 public function __construct(
36 ?TripRepository $tripRepository = null,
37 ?DestinationRepository $destinationRepository = null,
38 ?ActivityRepository $activityRepository = null,
39 ?TripCategoryRepository $categoryRepository = null,
40 ?ReviewRepository $reviewRepository = null
41 ) {
42 $this->tripRepository = $tripRepository ?? new TripRepository();
43 $this->destinationRepository = $destinationRepository ?? new DestinationRepository();
44 $this->activityRepository = $activityRepository ?? new ActivityRepository();
45 $this->categoryRepository = $categoryRepository ?? new TripCategoryRepository();
46 $this->reviewRepository = $reviewRepository ?? new ReviewRepository();
47 }
48
49 /**
50 * Get filtered and paginated trip listings with metadata and caching
51 *
52 * @param array $requestParams Raw request parameters
53 * @return array Formatted trip listing data
54 */
55 public function getFilteredTrips(array $requestParams = []): array
56 {
57 $startTime = microtime(true);
58
59 // Sanitize and validate input parameters
60 $filters = $this->sanitizeFilters($requestParams);
61
62 // Extract pagination parameters (default per page = WordPress Reading > "Blog pages show at most")
63 $page = max(1, (int) ($requestParams['page'] ?? 1));
64 $defaultPerPage = \yatra_get_posts_per_page();
65 $maxCap = max((int) apply_filters('yatra_trip_listing_max_per_page', 100), $defaultPerPage);
66 $perPage = (int) ($requestParams['per_page'] ?? $defaultPerPage);
67 $perPage = max(1, min($maxCap, $perPage));
68
69 // Create cache key for this specific request
70 $cacheKey = $this->generateCacheKey($filters, $page, $perPage);
71
72 Logger::debug("Trip listing request started", [
73 'filters' => $filters,
74 'page' => $page,
75 'per_page' => $perPage,
76 'cache_key' => substr($cacheKey, 0, 50) . '...'
77 ]);
78
79 $result = $this->tripRepository->withQueryCache(
80 $cacheKey,
81 function () use ($filters, $page, $perPage) {
82 return $this->buildTripListingResult($filters, $page, $perPage);
83 },
84 Cache::DURATION_QUERY_RESULT
85 );
86
87 $executionTime = microtime(true) - $startTime;
88 Logger::debug("Trip listing request completed", [
89 'execution_time' => $executionTime,
90 'total_trips' => $result['total'],
91 'returned_trips' => count($result['trips'])
92 ]);
93
94 return $result;
95 }
96
97 /**
98 * Sanitize and validate filter parameters
99 *
100 * @param array $params Raw parameters
101 * @return array Sanitized filters
102 */
103 private function sanitizeFilters(array $params): array
104 {
105 $filters = [];
106
107 // Keyword search (trip archive GET param `s`, same as shortcode input name)
108 if (!empty($params['s']) && is_string($params['s'])) {
109 $q = sanitize_text_field($params['s']);
110 if (strlen($q) > 0) {
111 $filters['search'] = $q;
112 $filters['s'] = $q;
113 }
114 }
115
116 // Sidebar / URL: classification IDs (OR within each group)
117 foreach (['categories' => 'category_ids', 'destinations' => 'destination_ids', 'activities' => 'activity_ids'] as $paramKey => $filterKey) {
118 if (empty($params[$paramKey])) {
119 continue;
120 }
121 $raw = $params[$paramKey];
122 $ids = is_array($raw) ? $raw : [$raw];
123 $ids = array_values(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0));
124 if ($ids !== []) {
125 $filters[$filterKey] = $ids;
126 }
127 }
128
129 // Destination filter (slug) — used when no destination_ids from checkboxes
130 if (empty($filters['destination_ids']) && !empty($params['destination'])) {
131 $filters['destination'] = sanitize_text_field((string) $params['destination']);
132 }
133
134 // Activity filter (slug)
135 if (empty($filters['activity_ids']) && !empty($params['activity'])) {
136 $filters['activity'] = sanitize_text_field((string) $params['activity']);
137 }
138
139 // Category / trip type (slug from search bar or legacy trip_category param)
140 if (empty($filters['category_ids'])) {
141 $cat = $params['trip_category'] ?? $params['category'] ?? '';
142 if (!empty($cat) && is_string($cat)) {
143 $filters['trip_category'] = sanitize_text_field($cat);
144 }
145 }
146
147 // Price range filters
148 if (isset($params['price_min']) && is_numeric($params['price_min']) && (float) $params['price_min'] > 0) {
149 $filters['price_min'] = (float) $params['price_min'];
150 }
151
152 if (isset($params['price_max']) && is_numeric($params['price_max']) && (float) $params['price_max'] > 0) {
153 $filters['price_max'] = (float) $params['price_max'];
154 }
155
156 // Duration filters (explicit min/max take precedence over preset `duration`)
157 $hasDurMin = isset($params['duration_min']) && is_numeric($params['duration_min']) && (int) $params['duration_min'] > 0;
158 $hasDurMax = isset($params['duration_max']) && is_numeric($params['duration_max']) && (int) $params['duration_max'] > 0;
159 if ($hasDurMin) {
160 $filters['duration_min'] = (int) $params['duration_min'];
161 }
162 if ($hasDurMax) {
163 $filters['duration_max'] = (int) $params['duration_max'];
164 }
165 if (!$hasDurMin && !$hasDurMax && !empty($params['duration']) && is_string($params['duration'])) {
166 $preset = sanitize_text_field($params['duration']);
167 // Horizontal search bar: numeric range e.g. 2-14
168 if (preg_match('/^(\d+)-(\d+)$/', $preset, $m)) {
169 $filters['duration_min'] = max(1, (int) $m[1]);
170 $filters['duration_max'] = max((int) $m[1], (int) $m[2]);
171 $filters['duration'] = $preset;
172 } elseif ($preset === '1-3') {
173 $filters['duration_min'] = 1;
174 $filters['duration_max'] = 3;
175 $filters['duration'] = $preset;
176 } elseif ($preset === '4-7') {
177 $filters['duration_min'] = 4;
178 $filters['duration_max'] = 7;
179 $filters['duration'] = $preset;
180 } elseif ($preset === '8-14') {
181 $filters['duration_min'] = 8;
182 $filters['duration_max'] = 14;
183 $filters['duration'] = $preset;
184 } elseif ($preset === '15+') {
185 $filters['duration_min'] = 15;
186 $filters['duration_max'] = 3650;
187 $filters['duration'] = $preset;
188 } elseif ($preset !== '') {
189 $filters['duration'] = $preset;
190 }
191 }
192
193 // Availability date: keep only a valid Y-m-d calendar date. Trips are
194 // then filtered to those with a departure on that date (see
195 // TripRepository::findWithFilters).
196 if (!empty($params['available_date']) && is_string($params['available_date'])) {
197 $date = \Yatra\Helpers\TripListingFilterBuilder::normalizeAvailableDate($params['available_date']);
198 if ($date !== '') {
199 $filters['available_date'] = $date;
200 }
201 }
202
203 // Horizontal search "budget" presets (min-max or min+) → price range when explicit prices not set
204 if (
205 empty($filters['price_min']) && empty($filters['price_max'])
206 && !empty($params['budget']) && is_string($params['budget'])
207 ) {
208 $b = sanitize_text_field($params['budget']);
209 if (preg_match('/^(\d+)-(\d+)$/', $b, $m)) {
210 $filters['price_min'] = (float) $m[1];
211 $filters['price_max'] = (float) $m[2];
212 } elseif (preg_match('/^(\d+)\+$/', $b, $m)) {
213 $filters['price_min'] = (float) $m[1];
214 }
215 }
216
217 // Rating: star checkboxes use minimum selected threshold
218 $ratingCandidates = [];
219 if (!empty($params['rating']) && is_array($params['rating'])) {
220 foreach ($params['rating'] as $r) {
221 if (is_numeric($r) && (int) $r > 0) {
222 $ratingCandidates[] = (int) $r;
223 }
224 }
225 }
226 if ($ratingCandidates !== []) {
227 $filters['rating_min'] = (float) min($ratingCandidates);
228 $filters['rating'] = $ratingCandidates;
229 } elseif (isset($params['rating_min']) && is_numeric($params['rating_min']) && (float) $params['rating_min'] > 0) {
230 $filters['rating_min'] = (float) $params['rating_min'];
231 }
232
233 // Difficulty filter - sidebar uses IDs; search shortcode uses slug
234 if (isset($params['difficulty'])) {
235 if (is_array($params['difficulty'])) {
236 $diff = array_values(array_filter(array_map('intval', $params['difficulty']), static fn (int $id): bool => $id > 0));
237 if ($diff !== []) {
238 $filters['difficulty'] = $diff;
239 }
240 } elseif (is_numeric($params['difficulty']) && (int) $params['difficulty'] > 0) {
241 $filters['difficulty'] = [(int) $params['difficulty']];
242 } elseif (is_string($params['difficulty']) && trim($params['difficulty']) !== '') {
243 $slug = sanitize_text_field($params['difficulty']);
244 $diffRow = (new \Yatra\Repositories\DifficultyLevelRepository())->findBySlug($slug);
245 if ($diffRow && isset($diffRow->id)) {
246 $filters['difficulty'] = [(int) $diffRow->id];
247 }
248 }
249 }
250
251 // Trip duration type (DB column trip_type: single_day | multi_day | flexible)
252 if (!empty($params['trip_type']) && is_string($params['trip_type'])) {
253 $tt = sanitize_text_field($params['trip_type']);
254 if (in_array($tt, ['single_day', 'multi_day', 'flexible'], true)) {
255 $filters['trip_type'] = $tt;
256 }
257 }
258
259 $filters['special_offers'] = $this->sanitizeStringList($params['special_offers'] ?? []);
260 $filters['booking_options'] = $this->sanitizeStringList($params['booking_options'] ?? []);
261 $filters['age_suitability'] = $this->sanitizeStringList($params['age_suitability'] ?? []);
262
263 if (!empty($params['accommodation']) && is_array($params['accommodation'])) {
264 $filters['accommodation'] = array_values(array_filter(array_map('sanitize_text_field', $params['accommodation'])));
265 }
266
267 if (!empty($params['included_services']) && is_array($params['included_services'])) {
268 $filters['included_services'] = array_values(array_filter(array_map('sanitize_text_field', $params['included_services'])));
269 }
270
271 // Attribute filters
272 if (isset($params['attributes']) && is_array($params['attributes'])) {
273 $filters['attributes'] = [];
274 foreach ($params['attributes'] as $attributeId => $attributeValue) {
275 if (!is_numeric($attributeId) || (int) $attributeId <= 0) {
276 continue;
277 }
278 $aid = (int) $attributeId;
279 if (is_array($attributeValue)) {
280 if (isset($attributeValue['min']) || isset($attributeValue['max'])) {
281 $range = [];
282 if (isset($attributeValue['min']) && is_numeric($attributeValue['min'])) {
283 $range['min'] = (float) $attributeValue['min'];
284 }
285 if (isset($attributeValue['max']) && is_numeric($attributeValue['max'])) {
286 $range['max'] = (float) $attributeValue['max'];
287 }
288 if ($range !== []) {
289 $filters['attributes'][$aid] = $range;
290 }
291 } else {
292 $vals = array_map('sanitize_text_field', array_filter($attributeValue, static fn ($v) => $v !== null && $v !== ''));
293 if ($vals !== []) {
294 $filters['attributes'][$aid] = $vals;
295 }
296 }
297 } else {
298 $sanitizedValue = sanitize_text_field((string) $attributeValue);
299 if ($sanitizedValue !== '') {
300 $filters['attributes'][$aid] = $sanitizedValue;
301 }
302 }
303 }
304 }
305
306 // Sort parameter
307 if (!empty($params['sort'])) {
308 $allowedSorts = ['most_popular', 'price_low', 'price_high', 'rating_high', 'duration_short', 'duration_long'];
309 $sort = sanitize_text_field((string) $params['sort']);
310 if (in_array($sort, $allowedSorts, true)) {
311 $filters['sort'] = $sort;
312 }
313 }
314
315 // Template / URL aliases (sidebar uses offers[], booking[], age[], services[])
316 if (!empty($filters['special_offers'])) {
317 $filters['offers'] = $filters['special_offers'];
318 }
319 if (!empty($filters['booking_options'])) {
320 $filters['booking'] = $filters['booking_options'];
321 }
322 if (!empty($filters['age_suitability'])) {
323 $filters['age'] = $filters['age_suitability'];
324 }
325 if (!empty($filters['included_services'])) {
326 $filters['services'] = $filters['included_services'];
327 }
328 if (!empty($filters['trip_category'])) {
329 $filters['category'] = $filters['trip_category'];
330 }
331
332 return $filters;
333 }
334
335 /**
336 * @param mixed $raw
337 * @return list<string>
338 */
339 private function sanitizeStringList($raw): array
340 {
341 if ($raw === null || $raw === '') {
342 return [];
343 }
344 $items = is_array($raw) ? $raw : [$raw];
345 $out = [];
346 foreach ($items as $item) {
347 $s = sanitize_text_field((string) $item);
348 if ($s !== '') {
349 $out[] = $s;
350 }
351 }
352 return array_values(array_unique($out));
353 }
354
355 /**
356 * Generate cache key for trip listing request
357 */
358 private function generateCacheKey(array $filters, int $page, int $perPage): string
359 {
360 $keyData = [
361 'filters' => $filters,
362 'page' => $page,
363 'per_page' => $perPage
364 ];
365
366 return 'trip_listing_' . md5(serialize($keyData));
367 }
368
369 /**
370 * Build trip listing result
371 */
372 private function buildTripListingResult(array $filters, int $page, int $perPage): array
373 {
374 // Apply attribute filtering if present
375 if (!empty($filters['attributes'])) {
376 $attributeFilters = $filters['attributes'];
377 unset($filters['attributes']); // Remove from regular filters
378
379 // Apply attribute filtering to repository
380 $filters = $this->tripRepository->filterByAttributes($filters, $attributeFilters);
381 }
382
383 // Get filtered trips from repository
384 $tripResult = $this->tripRepository->findWithFilters($filters, $page, $perPage);
385
386 // average_rating + review_count are aggregated in the listing
387 // SQL ({@see TripRepository::findWithFilters()} — `AVG(r.rating)`
388 // / `COUNT(DISTINCT r.id)` over the LEFT-JOINed approved-reviews
389 // rows), so the listing card has everything it needs to render
390 // the star block without an extra round-trip per trip.
391 //
392 // A previous version called `findApprovedByTripId(LIMIT 10)`
393 // here for every displayed trip — an N+1 with a JOIN to
394 // wp_users — and recomputed the average from the truncated
395 // list. That was both slow and *less accurate* than the SQL
396 // aggregate (capped at 10 reviews per trip). Drop it.
397 //
398 // `reviews` stays present-but-empty for templates that probe
399 // the property defensively (`is_array($trip->reviews)`).
400 foreach ($tripResult['trips'] as $trip) {
401 if (!isset($trip->reviews)) {
402 $trip->reviews = [];
403 }
404 $trip->review_count = (int) ($trip->review_count ?? 0);
405 $trip->average_rating = (float) ($trip->average_rating ?? 0);
406 }
407
408 // Get filter options for UI (cached separately)
409 $filterOptions = $this->getFilterOptions();
410
411 return [
412 'trips' => $tripResult['trips'],
413 'total' => $tripResult['total'],
414 'pages' => $tripResult['pages'],
415 'page' => $tripResult['page'],
416 'per_page' => $tripResult['per_page'],
417 'filters' => $this->stripInternalFilterKeys($filters),
418 'destinations' => $filterOptions['destinations'],
419 'activities' => $filterOptions['activities'],
420 'attributes' => $filterOptions['attributes']
421 ];
422 }
423
424 /**
425 * Get filter options for UI
426 */
427 private function getFilterOptions(): array
428 {
429 return $this->tripRepository->withQueryCache(
430 'trip_listing_filter_options',
431 function () {
432 return [
433 'destinations' => $this->destinationRepository->getPublished(),
434 'activities' => $this->activityRepository->getPublished(),
435 'attributes' => $this->getAvailableAttributes(),
436 ];
437 },
438 Cache::DURATION_DESTINATION_DATA
439 );
440 }
441
442 /**
443 * Get reviews for a specific trip (same as SingleTripController)
444 *
445 * @param int $trip_id Trip ID
446 * @return array Reviews
447 */
448 private function getReviewsForTrip(int $trip_id): array
449 {
450 // Check if reviews table exists
451 if (!$this->reviewRepository->tableExists()) {
452 return [];
453 }
454
455 return $this->reviewRepository->findApprovedByTripId($trip_id);
456 }
457
458 /**
459 * Calculate average rating from reviews (same as SingleTripController)
460 *
461 * @param array $reviews Reviews array
462 * @return float Average rating
463 */
464 private function calculateAverageRating(array $reviews): float
465 {
466 if (empty($reviews)) {
467 return 0.0;
468 }
469
470 $total = 0;
471 foreach ($reviews as $review) {
472 $total += (float) ($review->rating ?? 0);
473 }
474
475 return round($total / count($reviews), 1);
476 }
477
478 /**
479 * Get available attributes for filtering
480 */
481 private function getAvailableAttributes(): array
482 {
483 $attributeRepository = new \Yatra\Repositories\AttributeRepository();
484 return $attributeRepository->getAvailableAttributes();
485 }
486
487 /**
488 * Get trip statistics for analytics
489 */
490 private function stripInternalFilterKeys(array $filters): array
491 {
492 unset(
493 $filters['attribute_filters'],
494 $filters['search'],
495 $filters['category_ids'],
496 $filters['destination_ids'],
497 $filters['activity_ids']
498 );
499
500 return $filters;
501 }
502
503 public function getTripStatistics(): array
504 {
505 return $this->tripRepository->withQueryCache(
506 'trip_listing_statistics',
507 function () {
508 return $this->calculateTripStatistics();
509 },
510 Cache::DURATION_STATS
511 );
512 }
513
514 /**
515 * Calculate trip statistics
516 */
517 private function calculateTripStatistics(): array
518 {
519 $startTime = microtime(true);
520
521 $stats = [
522 'total_published_trips' => $this->tripRepository->count(['status' => 'publish']),
523 'total_destinations' => $this->destinationRepository->count(['status' => 'publish']),
524 'total_activities' => $this->activityRepository->count(['status' => 'publish']),
525 'price_range' => $this->tripRepository->getPriceRange(),
526 'duration_range' => $this->tripRepository->getDurationRange()
527 ];
528
529 $executionTime = microtime(true) - $startTime;
530 Logger::debug("Trip statistics calculated", [
531 'execution_time' => $executionTime,
532 'stats' => $stats
533 ]);
534
535 return $stats;
536 }
537
538 /**
539 * Clear trip listing caches
540 */
541 public function clearCache(): void
542 {
543 Cache::clearByPrefix('trip_listing_');
544 Logger::info("Trip listing caches cleared");
545 }
546
547 /**
548 * Get trips for taxonomy context (destination/activity pages)
549 *
550 * @param string $taxonomyType 'destination' or 'activity'
551 * @param string $slug Taxonomy slug
552 * @param int|null $limit Number of trips to return; null uses WordPress posts per page.
553 * @return array
554 */
555 public function getTripsByTaxonomy(string $taxonomyType, string $slug, ?int $limit = null): array
556 {
557 $limit = $limit ?? \yatra_get_posts_per_page();
558 $limit = max(1, $limit);
559
560 $filters = [$taxonomyType => $slug];
561
562 $result = $this->tripRepository->findWithFilters($filters, 1, $limit);
563
564 // Rating + count come from the listing SQL aggregate; see
565 // {@see self::buildTripListingResult()} for why we don't reload
566 // approved reviews per trip here (was an N+1).
567 foreach ($result['trips'] as $trip) {
568 if (!isset($trip->reviews)) {
569 $trip->reviews = [];
570 }
571 $trip->review_count = (int) ($trip->review_count ?? 0);
572 $trip->average_rating = (float) ($trip->average_rating ?? 0);
573 }
574
575 return [
576 'trips' => $result['trips'],
577 'total' => $result['total']
578 ];
579 }
580
581 /**
582 * Validate and process search parameters
583 *
584 * @param array $params Search parameters
585 * @return bool True if valid search request
586 */
587 public function isValidSearchRequest(array $params): bool
588 {
589 if (!empty($params['s'])) {
590 return true;
591 }
592 $meaningfulParams = [
593 'destination', 'activity', 'trip_category', 'category', 'price_min', 'price_max',
594 'duration_min', 'duration_max', 'duration', 'rating_min', 'difficulty', 'sort',
595 'categories', 'destinations', 'activities', 'trip_type', 'rating',
596 'special_offers', 'booking_options', 'age_suitability', 'offers', 'booking', 'age',
597 ];
598
599 foreach ($meaningfulParams as $param) {
600 if (!empty($params[$param])) {
601 return true;
602 }
603 }
604
605 return false;
606 }
607
608 /**
609 * Get filter data for trip listing templates
610 *
611 * @return array All filter options with counts
612 */
613 public function getFilterData(): array
614 {
615 return $this->tripRepository->withQueryCache(
616 'trip_listing_filter_data_v2',
617 function () {
618 return $this->buildFilterData();
619 },
620 3600
621 );
622 }
623
624 /**
625 * Build filter data from repositories
626 */
627 private function buildFilterData(): array
628 {
629 return [
630 'price_stats' => $this->tripRepository->getPriceStats(),
631 'trip_types' => $this->getTripTypeOptions(),
632 'difficulty_levels' => $this->getDifficultyLevelOptions(),
633 'ratings' => $this->getRatingOptions(),
634 'categories' => $this->getCategoryOptions(),
635 'destinations' => $this->getDestinationOptions(),
636 'activities' => $this->getActivityOptions(),
637 'accommodations' => $this->tripRepository->getAccommodationTypes(),
638 'included_services' => $this->tripRepository->getIncludedServices(),
639 'durations' => $this->tripRepository->getDurationOptions(),
640 'group_sizes' => $this->tripRepository->getGroupSizeOptions(),
641 'physical_grades' => $this->tripRepository->getPhysicalGrades(),
642 'special_offers' => $this->getSpecialOffers(),
643 'age_restrictions' => $this->getAgeRestrictions(),
644 'booking_options' => $this->getBookingOptions()
645 ];
646 }
647
648 /**
649 * Get trip type options with counts
650 */
651 private function getTripTypeOptions(): array
652 {
653 $tripTypes = $this->tripRepository->getTripTypes();
654 $result = [];
655
656 foreach ($tripTypes as $type) {
657 $count = $this->tripRepository->countByTripType($type->value);
658 $result[] = (object) [
659 'value' => $type->value,
660 'label' => $type->label,
661 'count' => $count
662 ];
663 }
664
665 return $result;
666 }
667
668 /**
669 * Get difficulty level options with counts
670 */
671 private function getDifficultyLevelOptions(): array
672 {
673 $levels = $this->categoryRepository->getDifficultyLevels();
674 $result = [];
675
676 foreach ($levels as $level) {
677 $count = $this->tripRepository->countByDifficultyLevel((int) $level->id);
678 $result[] = (object) [
679 'slug' => $level->slug,
680 'name' => $level->name,
681 'id' => (int) $level->id,
682 'count' => $count
683 ];
684 }
685
686 return $result;
687 }
688
689 /**
690 * Get rating options with counts
691 */
692 private function getRatingOptions(): array
693 {
694 $result = [];
695
696 // Check if reviews table exists
697 if (!$this->reviewRepository->tableExists()) {
698 return $result;
699 }
700
701 for ($rating = 5; $rating >= 1; $rating--) {
702 $count = $this->tripRepository->countByMinRating($rating);
703 $result[] = (object) [
704 'rating' => $rating,
705 'count' => $count,
706 'label' => sprintf('%d %s', $rating, _n('Star', 'Stars', $rating, 'yatra'))
707 ];
708 }
709
710 return $result;
711 }
712
713 /**
714 * Get category options with counts
715 */
716 private function getCategoryOptions(): array
717 {
718 $categories = $this->categoryRepository->getPublishedCategories();
719 $result = [];
720
721 foreach ($categories as $category) {
722 $count = $this->tripRepository->countByCategory((int) $category->id);
723 $result[] = (object) [
724 'id' => $category->id,
725 'name' => $category->name,
726 'slug' => $category->slug,
727 'count' => $count
728 ];
729 }
730
731 return $result;
732 }
733
734 /**
735 * Get destination options with counts
736 */
737 private function getDestinationOptions(): array
738 {
739 $destinations = $this->destinationRepository->getPublished();
740 $result = [];
741
742 foreach ($destinations as $destination) {
743 $count = $this->tripRepository->countByDestination((int) $destination->id);
744 $result[] = (object) [
745 'id' => $destination->id,
746 'name' => $destination->name,
747 'slug' => $destination->slug,
748 'count' => $count
749 ];
750 }
751
752 return $result;
753 }
754
755 /**
756 * Get activity options with counts
757 */
758 private function getActivityOptions(): array
759 {
760 $activities = $this->activityRepository->getPublished();
761 $result = [];
762
763 foreach ($activities as $activity) {
764 $count = $this->tripRepository->countByActivity((int) $activity->id);
765 $result[] = (object) [
766 'id' => $activity->id,
767 'name' => $activity->name,
768 'slug' => $activity->slug,
769 'count' => $count
770 ];
771 }
772
773 return $result;
774 }
775
776 /**
777 * Get special offers with counts
778 */
779 private function getSpecialOffers(): array
780 {
781 $special_offers = [];
782
783 // Check for discounted trips
784 $discount_count = $this->tripRepository->countByDiscount();
785 if ($discount_count > 0) {
786 $special_offers[] = (object) ['value' => 'discount', 'label' => __('Discount Available', 'yatra'), 'count' => $discount_count];
787 }
788
789 // Check for early bird offers
790 $early_bird_count = $this->tripRepository->countByEarlyBird();
791 if ($early_bird_count > 0) {
792 $special_offers[] = (object) ['value' => 'early-bird', 'label' => __('Early Bird Offer', 'yatra'), 'count' => $early_bird_count];
793 }
794
795 // Check for last minute deals
796 $last_minute_count = $this->tripRepository->countByLastMinute();
797 if ($last_minute_count > 0) {
798 $special_offers[] = (object) ['value' => 'last-minute', 'label' => __('Last Minute Deal', 'yatra'), 'count' => $last_minute_count];
799 }
800
801 // Check for instant booking
802 $instant_count = $this->tripRepository->countByInstantBooking();
803 if ($instant_count > 0) {
804 $special_offers[] = (object) ['value' => 'instant-booking', 'label' => __('Instant Booking', 'yatra'), 'count' => $instant_count];
805 }
806
807 // Check for flexible dates
808 $flexible_count = $this->tripRepository->countByFlexibleDates();
809 if ($flexible_count > 0) {
810 $special_offers[] = (object) ['value' => 'flexible-dates', 'label' => __('Flexible Dates', 'yatra'), 'count' => $flexible_count];
811 }
812
813 // Check for deposit options
814 $deposit_count = $this->tripRepository->countByDepositRequired();
815 if ($deposit_count > 0) {
816 $special_offers[] = (object) ['value' => 'deposit-available', 'label' => __('Pay Later Available', 'yatra'), 'count' => $deposit_count];
817 }
818
819 return $special_offers;
820 }
821
822 /**
823 * Get age restrictions with counts
824 */
825 private function getAgeRestrictions(): array
826 {
827 $age_options = [];
828
829 // Check for family friendly (no age restrictions or low minimum age)
830 $family_count = $this->tripRepository->countByFamilyFriendly();
831 if ($family_count > 0) {
832 $age_options[] = (object) ['value' => 'family-friendly', 'label' => __('Family Friendly', 'yatra'), 'count' => $family_count];
833 }
834
835 // Check for kids suitable (age_min <= 12)
836 $kids_count = $this->tripRepository->countByKidsFriendly();
837 if ($kids_count > 0) {
838 $age_options[] = (object) ['value' => 'kids-friendly', 'label' => __('Kids Friendly', 'yatra'), 'count' => $kids_count];
839 }
840
841 // Check for senior friendly (no upper age limit or high limit)
842 $senior_count = $this->tripRepository->countBySeniorFriendly();
843 if ($senior_count > 0) {
844 $age_options[] = (object) ['value' => 'senior-friendly', 'label' => __('Senior Friendly', 'yatra'), 'count' => $senior_count];
845 }
846
847 // Check for adults only (age_min >= 18)
848 $adults_count = $this->tripRepository->countByAdultsOnly();
849 if ($adults_count > 0) {
850 $age_options[] = (object) ['value' => 'adults-only', 'label' => __('Adults Only', 'yatra'), 'count' => $adults_count];
851 }
852
853 return $age_options;
854 }
855
856 /**
857 * Get booking options with counts
858 */
859 private function getBookingOptions(): array
860 {
861 $booking_options = [];
862
863 // Check for instant booking
864 $instant_count = $this->tripRepository->countByInstantBooking();
865 if ($instant_count > 0) {
866 $booking_options[] = (object) ['value' => 'instant', 'label' => __('Instant Confirmation', 'yatra'), 'count' => $instant_count];
867 }
868
869 // Check for flexible dates
870 $flexible_count = $this->tripRepository->countByFlexibleDates();
871 if ($flexible_count > 0) {
872 $booking_options[] = (object) ['value' => 'flexible', 'label' => __('Flexible Dates', 'yatra'), 'count' => $flexible_count];
873 }
874
875 // Check for deposit options
876 $deposit_count = $this->tripRepository->countByDepositRequired();
877 if ($deposit_count > 0) {
878 $booking_options[] = (object) ['value' => 'pay-later', 'label' => __('Reserve Now, Pay Later', 'yatra'), 'count' => $deposit_count];
879 }
880
881 return $booking_options;
882 }
883
884 /**
885 * Get price statistics for trips
886 *
887 * @return object|null Price stats with min and max prices
888 */
889 public function getPriceStats(): ?object
890 {
891 return $this->tripRepository->getPriceStats();
892 }
893
894 /**
895 * Dynamic budget tier values for the horizontal search bar (maps to ?budget= via sanitizeFilters).
896 *
897 * @return list<object{value: string, label: string}>
898 */
899 public function getSearchBudgetPresets(): array
900 {
901 $stats = $this->tripRepository->getPriceStats();
902 $min = $stats ? (int) floor((float) $stats->min_price) : 0;
903 $max = $stats ? (int) ceil((float) $stats->max_price) : 0;
904 if ($max <= $min) {
905 $max = $min + 1000;
906 }
907 $bands = 5;
908 $step = (int) max(1, ceil(($max - $min) / $bands));
909 $out = [];
910 for ($i = 0; $i < $bands; $i++) {
911 $lo = $min + $i * $step;
912 if ($lo > $max) {
913 break;
914 }
915 $hi = ($i === $bands - 1) ? $max : min($max, $lo + $step - 1);
916 if ($hi < $lo) {
917 $hi = $lo;
918 }
919 $sym = yatra_get_currency_symbol(get_option('yatra_currency', 'USD'));
920 $out[] = (object) [
921 'value' => $lo . '-' . $hi,
922 'label' => trim($sym . number_format_i18n($lo) . '' . $sym . number_format_i18n($hi)),
923 ];
924 }
925
926 return $out;
927 }
928 }
929