PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5.1
Yatra – Travel Booking & Tour Operator Software v3.0.5.1
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 2.0.1 All 84 releases
yatra / app / Services / TripListingService.php

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

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