PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.8
Yatra – Travel Booking & Tour Operator Software v3.0.2.8
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.2.8, at app/Services/TripListingService.php

912 lines 33.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 // 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 // Load reviews for each trip (same approach as SingleTripController)
377 $tripsWithReviews = [];
378 foreach ($tripResult['trips'] as $trip) {
379 // Load reviews for this trip
380 $trip->reviews = $this->getReviewsForTrip((int) $trip->id);
381
382 // Calculate rating stats (same as SingleTripController)
383 $trip->average_rating = $this->calculateAverageRating($trip->reviews);
384 $trip->review_count = count($trip->reviews);
385
386 $tripsWithReviews[] = $trip;
387 }
388
389 // Get filter options for UI (cached separately)
390 $filterOptions = $this->getFilterOptions();
391
392 return [
393 'trips' => $tripsWithReviews,
394 'total' => $tripResult['total'],
395 'pages' => $tripResult['pages'],
396 'page' => $tripResult['page'],
397 'per_page' => $tripResult['per_page'],
398 'filters' => $this->stripInternalFilterKeys($filters),
399 'destinations' => $filterOptions['destinations'],
400 'activities' => $filterOptions['activities'],
401 'attributes' => $filterOptions['attributes']
402 ];
403 }
404
405 /**
406 * Get filter options for UI
407 */
408 private function getFilterOptions(): array
409 {
410 return $this->tripRepository->withQueryCache(
411 'trip_listing_filter_options',
412 function () {
413 return [
414 'destinations' => $this->destinationRepository->getPublished(),
415 'activities' => $this->activityRepository->getPublished(),
416 'attributes' => $this->getAvailableAttributes(),
417 ];
418 },
419 Cache::DURATION_DESTINATION_DATA
420 );
421 }
422
423 /**
424 * Get reviews for a specific trip (same as SingleTripController)
425 *
426 * @param int $trip_id Trip ID
427 * @return array Reviews
428 */
429 private function getReviewsForTrip(int $trip_id): array
430 {
431 // Check if reviews table exists
432 if (!$this->reviewRepository->tableExists()) {
433 return [];
434 }
435
436 return $this->reviewRepository->findApprovedByTripId($trip_id);
437 }
438
439 /**
440 * Calculate average rating from reviews (same as SingleTripController)
441 *
442 * @param array $reviews Reviews array
443 * @return float Average rating
444 */
445 private function calculateAverageRating(array $reviews): float
446 {
447 if (empty($reviews)) {
448 return 0.0;
449 }
450
451 $total = 0;
452 foreach ($reviews as $review) {
453 $total += (float) ($review->rating ?? 0);
454 }
455
456 return round($total / count($reviews), 1);
457 }
458
459 /**
460 * Get available attributes for filtering
461 */
462 private function getAvailableAttributes(): array
463 {
464 $attributeRepository = new \Yatra\Repositories\AttributeRepository();
465 return $attributeRepository->getAvailableAttributes();
466 }
467
468 /**
469 * Get trip statistics for analytics
470 */
471 private function stripInternalFilterKeys(array $filters): array
472 {
473 unset(
474 $filters['attribute_filters'],
475 $filters['search'],
476 $filters['category_ids'],
477 $filters['destination_ids'],
478 $filters['activity_ids']
479 );
480
481 return $filters;
482 }
483
484 public function getTripStatistics(): array
485 {
486 return $this->tripRepository->withQueryCache(
487 'trip_listing_statistics',
488 function () {
489 return $this->calculateTripStatistics();
490 },
491 Cache::DURATION_STATS
492 );
493 }
494
495 /**
496 * Calculate trip statistics
497 */
498 private function calculateTripStatistics(): array
499 {
500 $startTime = microtime(true);
501
502 $stats = [
503 'total_published_trips' => $this->tripRepository->count(['status' => 'publish']),
504 'total_destinations' => $this->destinationRepository->count(['status' => 'publish']),
505 'total_activities' => $this->activityRepository->count(['status' => 'publish']),
506 'price_range' => $this->tripRepository->getPriceRange(),
507 'duration_range' => $this->tripRepository->getDurationRange()
508 ];
509
510 $executionTime = microtime(true) - $startTime;
511 Logger::debug("Trip statistics calculated", [
512 'execution_time' => $executionTime,
513 'stats' => $stats
514 ]);
515
516 return $stats;
517 }
518
519 /**
520 * Clear trip listing caches
521 */
522 public function clearCache(): void
523 {
524 Cache::clearByPrefix('trip_listing_');
525 Logger::info("Trip listing caches cleared");
526 }
527
528 /**
529 * Get trips for taxonomy context (destination/activity pages)
530 *
531 * @param string $taxonomyType 'destination' or 'activity'
532 * @param string $slug Taxonomy slug
533 * @param int|null $limit Number of trips to return; null uses WordPress posts per page.
534 * @return array
535 */
536 public function getTripsByTaxonomy(string $taxonomyType, string $slug, ?int $limit = null): array
537 {
538 $limit = $limit ?? \yatra_get_posts_per_page();
539 $limit = max(1, $limit);
540
541 $filters = [$taxonomyType => $slug];
542
543 $result = $this->tripRepository->findWithFilters($filters, 1, $limit);
544
545 // Load reviews for each trip (same approach as getFilteredTrips)
546 $tripsWithReviews = [];
547 foreach ($result['trips'] as $trip) {
548 // Load reviews for this trip
549 $trip->reviews = $this->getReviewsForTrip((int) $trip->id);
550
551 // Calculate rating stats (same as SingleTripController)
552 $trip->average_rating = $this->calculateAverageRating($trip->reviews);
553 $trip->review_count = count($trip->reviews);
554
555 $tripsWithReviews[] = $trip;
556 }
557
558 return [
559 'trips' => $tripsWithReviews,
560 'total' => $result['total']
561 ];
562 }
563
564 /**
565 * Validate and process search parameters
566 *
567 * @param array $params Search parameters
568 * @return bool True if valid search request
569 */
570 public function isValidSearchRequest(array $params): bool
571 {
572 if (!empty($params['s'])) {
573 return true;
574 }
575 $meaningfulParams = [
576 'destination', 'activity', 'trip_category', 'category', 'price_min', 'price_max',
577 'duration_min', 'duration_max', 'duration', 'rating_min', 'difficulty', 'sort',
578 'categories', 'destinations', 'activities', 'trip_type', 'rating',
579 'special_offers', 'booking_options', 'age_suitability', 'offers', 'booking', 'age',
580 ];
581
582 foreach ($meaningfulParams as $param) {
583 if (!empty($params[$param])) {
584 return true;
585 }
586 }
587
588 return false;
589 }
590
591 /**
592 * Get filter data for trip listing templates
593 *
594 * @return array All filter options with counts
595 */
596 public function getFilterData(): array
597 {
598 return $this->tripRepository->withQueryCache(
599 'trip_listing_filter_data_v2',
600 function () {
601 return $this->buildFilterData();
602 },
603 3600
604 );
605 }
606
607 /**
608 * Build filter data from repositories
609 */
610 private function buildFilterData(): array
611 {
612 return [
613 'price_stats' => $this->tripRepository->getPriceStats(),
614 'trip_types' => $this->getTripTypeOptions(),
615 'difficulty_levels' => $this->getDifficultyLevelOptions(),
616 'ratings' => $this->getRatingOptions(),
617 'categories' => $this->getCategoryOptions(),
618 'destinations' => $this->getDestinationOptions(),
619 'activities' => $this->getActivityOptions(),
620 'accommodations' => $this->tripRepository->getAccommodationTypes(),
621 'included_services' => $this->tripRepository->getIncludedServices(),
622 'durations' => $this->tripRepository->getDurationOptions(),
623 'group_sizes' => $this->tripRepository->getGroupSizeOptions(),
624 'physical_grades' => $this->tripRepository->getPhysicalGrades(),
625 'special_offers' => $this->getSpecialOffers(),
626 'age_restrictions' => $this->getAgeRestrictions(),
627 'booking_options' => $this->getBookingOptions()
628 ];
629 }
630
631 /**
632 * Get trip type options with counts
633 */
634 private function getTripTypeOptions(): array
635 {
636 $tripTypes = $this->tripRepository->getTripTypes();
637 $result = [];
638
639 foreach ($tripTypes as $type) {
640 $count = $this->tripRepository->countByTripType($type->value);
641 $result[] = (object) [
642 'value' => $type->value,
643 'label' => $type->label,
644 'count' => $count
645 ];
646 }
647
648 return $result;
649 }
650
651 /**
652 * Get difficulty level options with counts
653 */
654 private function getDifficultyLevelOptions(): array
655 {
656 $levels = $this->categoryRepository->getDifficultyLevels();
657 $result = [];
658
659 foreach ($levels as $level) {
660 $count = $this->tripRepository->countByDifficultyLevel((int) $level->id);
661 $result[] = (object) [
662 'slug' => $level->slug,
663 'name' => $level->name,
664 'id' => (int) $level->id,
665 'count' => $count
666 ];
667 }
668
669 return $result;
670 }
671
672 /**
673 * Get rating options with counts
674 */
675 private function getRatingOptions(): array
676 {
677 $result = [];
678
679 // Check if reviews table exists
680 if (!$this->reviewRepository->tableExists()) {
681 return $result;
682 }
683
684 for ($rating = 5; $rating >= 1; $rating--) {
685 $count = $this->tripRepository->countByMinRating($rating);
686 $result[] = (object) [
687 'rating' => $rating,
688 'count' => $count,
689 'label' => sprintf('%d %s', $rating, _n('Star', 'Stars', $rating, 'yatra'))
690 ];
691 }
692
693 return $result;
694 }
695
696 /**
697 * Get category options with counts
698 */
699 private function getCategoryOptions(): array
700 {
701 $categories = $this->categoryRepository->getPublishedCategories();
702 $result = [];
703
704 foreach ($categories as $category) {
705 $count = $this->tripRepository->countByCategory((int) $category->id);
706 $result[] = (object) [
707 'id' => $category->id,
708 'name' => $category->name,
709 'slug' => $category->slug,
710 'count' => $count
711 ];
712 }
713
714 return $result;
715 }
716
717 /**
718 * Get destination options with counts
719 */
720 private function getDestinationOptions(): array
721 {
722 $destinations = $this->destinationRepository->getPublished();
723 $result = [];
724
725 foreach ($destinations as $destination) {
726 $count = $this->tripRepository->countByDestination((int) $destination->id);
727 $result[] = (object) [
728 'id' => $destination->id,
729 'name' => $destination->name,
730 'slug' => $destination->slug,
731 'count' => $count
732 ];
733 }
734
735 return $result;
736 }
737
738 /**
739 * Get activity options with counts
740 */
741 private function getActivityOptions(): array
742 {
743 $activities = $this->activityRepository->getPublished();
744 $result = [];
745
746 foreach ($activities as $activity) {
747 $count = $this->tripRepository->countByActivity((int) $activity->id);
748 $result[] = (object) [
749 'id' => $activity->id,
750 'name' => $activity->name,
751 'slug' => $activity->slug,
752 'count' => $count
753 ];
754 }
755
756 return $result;
757 }
758
759 /**
760 * Get special offers with counts
761 */
762 private function getSpecialOffers(): array
763 {
764 $special_offers = [];
765
766 // Check for discounted trips
767 $discount_count = $this->tripRepository->countByDiscount();
768 if ($discount_count > 0) {
769 $special_offers[] = (object) ['value' => 'discount', 'label' => __('Discount Available', 'yatra'), 'count' => $discount_count];
770 }
771
772 // Check for early bird offers
773 $early_bird_count = $this->tripRepository->countByEarlyBird();
774 if ($early_bird_count > 0) {
775 $special_offers[] = (object) ['value' => 'early-bird', 'label' => __('Early Bird Offer', 'yatra'), 'count' => $early_bird_count];
776 }
777
778 // Check for last minute deals
779 $last_minute_count = $this->tripRepository->countByLastMinute();
780 if ($last_minute_count > 0) {
781 $special_offers[] = (object) ['value' => 'last-minute', 'label' => __('Last Minute Deal', 'yatra'), 'count' => $last_minute_count];
782 }
783
784 // Check for instant booking
785 $instant_count = $this->tripRepository->countByInstantBooking();
786 if ($instant_count > 0) {
787 $special_offers[] = (object) ['value' => 'instant-booking', 'label' => __('Instant Booking', 'yatra'), 'count' => $instant_count];
788 }
789
790 // Check for flexible dates
791 $flexible_count = $this->tripRepository->countByFlexibleDates();
792 if ($flexible_count > 0) {
793 $special_offers[] = (object) ['value' => 'flexible-dates', 'label' => __('Flexible Dates', 'yatra'), 'count' => $flexible_count];
794 }
795
796 // Check for deposit options
797 $deposit_count = $this->tripRepository->countByDepositRequired();
798 if ($deposit_count > 0) {
799 $special_offers[] = (object) ['value' => 'deposit-available', 'label' => __('Pay Later Available', 'yatra'), 'count' => $deposit_count];
800 }
801
802 return $special_offers;
803 }
804
805 /**
806 * Get age restrictions with counts
807 */
808 private function getAgeRestrictions(): array
809 {
810 $age_options = [];
811
812 // Check for family friendly (no age restrictions or low minimum age)
813 $family_count = $this->tripRepository->countByFamilyFriendly();
814 if ($family_count > 0) {
815 $age_options[] = (object) ['value' => 'family-friendly', 'label' => __('Family Friendly', 'yatra'), 'count' => $family_count];
816 }
817
818 // Check for kids suitable (age_min <= 12)
819 $kids_count = $this->tripRepository->countByKidsFriendly();
820 if ($kids_count > 0) {
821 $age_options[] = (object) ['value' => 'kids-friendly', 'label' => __('Kids Friendly', 'yatra'), 'count' => $kids_count];
822 }
823
824 // Check for senior friendly (no upper age limit or high limit)
825 $senior_count = $this->tripRepository->countBySeniorFriendly();
826 if ($senior_count > 0) {
827 $age_options[] = (object) ['value' => 'senior-friendly', 'label' => __('Senior Friendly', 'yatra'), 'count' => $senior_count];
828 }
829
830 // Check for adults only (age_min >= 18)
831 $adults_count = $this->tripRepository->countByAdultsOnly();
832 if ($adults_count > 0) {
833 $age_options[] = (object) ['value' => 'adults-only', 'label' => __('Adults Only', 'yatra'), 'count' => $adults_count];
834 }
835
836 return $age_options;
837 }
838
839 /**
840 * Get booking options with counts
841 */
842 private function getBookingOptions(): array
843 {
844 $booking_options = [];
845
846 // Check for instant booking
847 $instant_count = $this->tripRepository->countByInstantBooking();
848 if ($instant_count > 0) {
849 $booking_options[] = (object) ['value' => 'instant', 'label' => __('Instant Confirmation', 'yatra'), 'count' => $instant_count];
850 }
851
852 // Check for flexible dates
853 $flexible_count = $this->tripRepository->countByFlexibleDates();
854 if ($flexible_count > 0) {
855 $booking_options[] = (object) ['value' => 'flexible', 'label' => __('Flexible Dates', 'yatra'), 'count' => $flexible_count];
856 }
857
858 // Check for deposit options
859 $deposit_count = $this->tripRepository->countByDepositRequired();
860 if ($deposit_count > 0) {
861 $booking_options[] = (object) ['value' => 'pay-later', 'label' => __('Reserve Now, Pay Later', 'yatra'), 'count' => $deposit_count];
862 }
863
864 return $booking_options;
865 }
866
867 /**
868 * Get price statistics for trips
869 *
870 * @return object|null Price stats with min and max prices
871 */
872 public function getPriceStats(): ?object
873 {
874 return $this->tripRepository->getPriceStats();
875 }
876
877 /**
878 * Dynamic budget tier values for the horizontal search bar (maps to ?budget= via sanitizeFilters).
879 *
880 * @return list<object{value: string, label: string}>
881 */
882 public function getSearchBudgetPresets(): array
883 {
884 $stats = $this->tripRepository->getPriceStats();
885 $min = $stats ? (int) floor((float) $stats->min_price) : 0;
886 $max = $stats ? (int) ceil((float) $stats->max_price) : 0;
887 if ($max <= $min) {
888 $max = $min + 1000;
889 }
890 $bands = 5;
891 $step = (int) max(1, ceil(($max - $min) / $bands));
892 $out = [];
893 for ($i = 0; $i < $bands; $i++) {
894 $lo = $min + $i * $step;
895 if ($lo > $max) {
896 break;
897 }
898 $hi = ($i === $bands - 1) ? $max : min($max, $lo + $step - 1);
899 if ($hi < $lo) {
900 $hi = $lo;
901 }
902 $sym = yatra_get_currency_symbol(get_option('yatra_currency', 'USD'));
903 $out[] = (object) [
904 'value' => $lo . '-' . $hi,
905 'label' => trim($sym . number_format_i18n($lo) . ' – ' . $sym . number_format_i18n($hi)),
906 ];
907 }
908
909 return $out;
910 }
911 }
912