# yatra/3.0.16/app/Services/TripListingService.php

Yatra – Travel Booking &amp; Tour Operator Software, version 3.0.16. 929 lines.

- Page: https://pluginprobe.com/plugins/yatra/3.0.16/code/app/Services/TripListingService.php
- Raw: https://pluginprobe.com/plugins/yatra/3.0.16/raw/app/Services/TripListingService.php
- Modified: 2026-08-31T13:28:04+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/yatra/3.0.16/code/app/Services/TripListingService.php#L10-L20`.

```php
<?php

declare(strict_types=1);

namespace Yatra\Services;

use Yatra\Repositories\TripRepository;
use Yatra\Repositories\DestinationRepository;
use Yatra\Repositories\ActivityRepository;
use Yatra\Repositories\TripCategoryRepository;
use Yatra\Repositories\ReviewRepository;
use Yatra\Utils\Cache;
use Yatra\Utils\Logger;

/**
 * Trip Listing Service
 * 
 * Handles business logic for trip listing pages with filtering, pagination,
 * and data enrichment. Follows single responsibility principle by separating
 * business logic from data access and presentation layers.
 */
class TripListingService extends BaseService
{
    private TripRepository $tripRepository;
    private DestinationRepository $destinationRepository;
    private ActivityRepository $activityRepository;
    private TripCategoryRepository $categoryRepository;
    private ReviewRepository $reviewRepository;

    protected function getRepository(): TripRepository
    {
        return $this->tripRepository;
    }

    public function __construct(
        ?TripRepository $tripRepository = null,
        ?DestinationRepository $destinationRepository = null,
        ?ActivityRepository $activityRepository = null,
        ?TripCategoryRepository $categoryRepository = null,
        ?ReviewRepository $reviewRepository = null
    ) {
        $this->tripRepository = $tripRepository ?? new TripRepository();
        $this->destinationRepository = $destinationRepository ?? new DestinationRepository();
        $this->activityRepository = $activityRepository ?? new ActivityRepository();
        $this->categoryRepository = $categoryRepository ?? new TripCategoryRepository();
        $this->reviewRepository = $reviewRepository ?? new ReviewRepository();
    }

    /**
     * Get filtered and paginated trip listings with metadata and caching
     *
     * @param array $requestParams Raw request parameters
     * @return array Formatted trip listing data
     */
    public function getFilteredTrips(array $requestParams = []): array
    {
        $startTime = microtime(true);
        
        // Sanitize and validate input parameters
        $filters = $this->sanitizeFilters($requestParams);
        
        // Extract pagination parameters (default per page = WordPress Reading > "Blog pages show at most")
        $page = max(1, (int) ($requestParams['page'] ?? 1));
        $defaultPerPage = \yatra_get_posts_per_page();
        $maxCap = max((int) apply_filters('yatra_trip_listing_max_per_page', 100), $defaultPerPage);
        $perPage = (int) ($requestParams['per_page'] ?? $defaultPerPage);
        $perPage = max(1, min($maxCap, $perPage));
        
        // Create cache key for this specific request
        $cacheKey = $this->generateCacheKey($filters, $page, $perPage);
        
        Logger::debug("Trip listing request started", [
            'filters' => $filters,
            'page' => $page,
            'per_page' => $perPage,
            'cache_key' => substr($cacheKey, 0, 50) . '...'
        ]);
        
        $result = $this->tripRepository->withQueryCache(
            $cacheKey,
            function () use ($filters, $page, $perPage) {
                return $this->buildTripListingResult($filters, $page, $perPage);
            },
            Cache::DURATION_QUERY_RESULT
        );
        
        $executionTime = microtime(true) - $startTime;
        Logger::debug("Trip listing request completed", [
            'execution_time' => $executionTime,
            'total_trips' => $result['total'],
            'returned_trips' => count($result['trips'])
        ]);
        
        return $result;
    }

    /**
     * Sanitize and validate filter parameters
     *
     * @param array $params Raw parameters
     * @return array Sanitized filters
     */
    private function sanitizeFilters(array $params): array
    {
        $filters = [];

        // Keyword search (trip archive GET param `s`, same as shortcode input name)
        if (!empty($params['s']) && is_string($params['s'])) {
            $q = sanitize_text_field($params['s']);
            if (strlen($q) > 0) {
                $filters['search'] = $q;
                $filters['s'] = $q;
            }
        }

        // Sidebar / URL: classification IDs (OR within each group)
        foreach (['categories' => 'category_ids', 'destinations' => 'destination_ids', 'activities' => 'activity_ids'] as $paramKey => $filterKey) {
            if (empty($params[$paramKey])) {
                continue;
            }
            $raw = $params[$paramKey];
            $ids = is_array($raw) ? $raw : [$raw];
            $ids = array_values(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0));
            if ($ids !== []) {
                $filters[$filterKey] = $ids;
            }
        }

        // Destination filter (slug) — used when no destination_ids from checkboxes
        if (empty($filters['destination_ids']) && !empty($params['destination'])) {
            $filters['destination'] = sanitize_text_field((string) $params['destination']);
        }

        // Activity filter (slug)
        if (empty($filters['activity_ids']) && !empty($params['activity'])) {
            $filters['activity'] = sanitize_text_field((string) $params['activity']);
        }

        // Category / trip type (slug from search bar or legacy trip_category param)
        if (empty($filters['category_ids'])) {
            $cat = $params['trip_category'] ?? $params['category'] ?? '';
            if (!empty($cat) && is_string($cat)) {
                $filters['trip_category'] = sanitize_text_field($cat);
            }
        }

        // Price range filters
        if (isset($params['price_min']) && is_numeric($params['price_min']) && (float) $params['price_min'] > 0) {
            $filters['price_min'] = (float) $params['price_min'];
        }

        if (isset($params['price_max']) && is_numeric($params['price_max']) && (float) $params['price_max'] > 0) {
            $filters['price_max'] = (float) $params['price_max'];
        }

        // Duration filters (explicit min/max take precedence over preset `duration`)
        $hasDurMin = isset($params['duration_min']) && is_numeric($params['duration_min']) && (int) $params['duration_min'] > 0;
        $hasDurMax = isset($params['duration_max']) && is_numeric($params['duration_max']) && (int) $params['duration_max'] > 0;
        if ($hasDurMin) {
            $filters['duration_min'] = (int) $params['duration_min'];
        }
        if ($hasDurMax) {
            $filters['duration_max'] = (int) $params['duration_max'];
        }
        if (!$hasDurMin && !$hasDurMax && !empty($params['duration']) && is_string($params['duration'])) {
            $preset = sanitize_text_field($params['duration']);
            // Horizontal search bar: numeric range e.g. 2-14
            if (preg_match('/^(\d+)-(\d+)$/', $preset, $m)) {
                $filters['duration_min'] = max(1, (int) $m[1]);
                $filters['duration_max'] = max((int) $m[1], (int) $m[2]);
                $filters['duration'] = $preset;
            } elseif ($preset === '1-3') {
                $filters['duration_min'] = 1;
                $filters['duration_max'] = 3;
                $filters['duration'] = $preset;
            } elseif ($preset === '4-7') {
                $filters['duration_min'] = 4;
                $filters['duration_max'] = 7;
                $filters['duration'] = $preset;
            } elseif ($preset === '8-14') {
                $filters['duration_min'] = 8;
                $filters['duration_max'] = 14;
                $filters['duration'] = $preset;
            } elseif ($preset === '15+') {
                $filters['duration_min'] = 15;
                $filters['duration_max'] = 3650;
                $filters['duration'] = $preset;
            } elseif ($preset !== '') {
                $filters['duration'] = $preset;
            }
        }

        // Availability date: keep only a valid Y-m-d calendar date. Trips are
        // then filtered to those with a departure on that date (see
        // TripRepository::findWithFilters).
        if (!empty($params['available_date']) && is_string($params['available_date'])) {
            $date = \Yatra\Helpers\TripListingFilterBuilder::normalizeAvailableDate($params['available_date']);
            if ($date !== '') {
                $filters['available_date'] = $date;
            }
        }

        // Horizontal search "budget" presets (min-max or min+) → price range when explicit prices not set
        if (
            empty($filters['price_min']) && empty($filters['price_max'])
            && !empty($params['budget']) && is_string($params['budget'])
        ) {
            $b = sanitize_text_field($params['budget']);
            if (preg_match('/^(\d+)-(\d+)$/', $b, $m)) {
                $filters['price_min'] = (float) $m[1];
                $filters['price_max'] = (float) $m[2];
            } elseif (preg_match('/^(\d+)\+$/', $b, $m)) {
                $filters['price_min'] = (float) $m[1];
            }
        }

        // Rating: star checkboxes use minimum selected threshold
        $ratingCandidates = [];
        if (!empty($params['rating']) && is_array($params['rating'])) {
            foreach ($params['rating'] as $r) {
                if (is_numeric($r) && (int) $r > 0) {
                    $ratingCandidates[] = (int) $r;
                }
            }
        }
        if ($ratingCandidates !== []) {
            $filters['rating_min'] = (float) min($ratingCandidates);
            $filters['rating'] = $ratingCandidates;
        } elseif (isset($params['rating_min']) && is_numeric($params['rating_min']) && (float) $params['rating_min'] > 0) {
            $filters['rating_min'] = (float) $params['rating_min'];
        }

        // Difficulty filter - sidebar uses IDs; search shortcode uses slug
        if (isset($params['difficulty'])) {
            if (is_array($params['difficulty'])) {
                $diff = array_values(array_filter(array_map('intval', $params['difficulty']), static fn (int $id): bool => $id > 0));
                if ($diff !== []) {
                    $filters['difficulty'] = $diff;
                }
            } elseif (is_numeric($params['difficulty']) && (int) $params['difficulty'] > 0) {
                $filters['difficulty'] = [(int) $params['difficulty']];
            } elseif (is_string($params['difficulty']) && trim($params['difficulty']) !== '') {
                $slug = sanitize_text_field($params['difficulty']);
                $diffRow = (new \Yatra\Repositories\DifficultyLevelRepository())->findBySlug($slug);
                if ($diffRow && isset($diffRow->id)) {
                    $filters['difficulty'] = [(int) $diffRow->id];
                }
            }
        }

        // Trip duration type (DB column trip_type: single_day | multi_day | flexible)
        if (!empty($params['trip_type']) && is_string($params['trip_type'])) {
            $tt = sanitize_text_field($params['trip_type']);
            if (in_array($tt, ['single_day', 'multi_day', 'flexible'], true)) {
                $filters['trip_type'] = $tt;
            }
        }

        $filters['special_offers'] = $this->sanitizeStringList($params['special_offers'] ?? []);
        $filters['booking_options'] = $this->sanitizeStringList($params['booking_options'] ?? []);
        $filters['age_suitability'] = $this->sanitizeStringList($params['age_suitability'] ?? []);

        if (!empty($params['accommodation']) && is_array($params['accommodation'])) {
            $filters['accommodation'] = array_values(array_filter(array_map('sanitize_text_field', $params['accommodation'])));
        }

        if (!empty($params['included_services']) && is_array($params['included_services'])) {
            $filters['included_services'] = array_values(array_filter(array_map('sanitize_text_field', $params['included_services'])));
        }

        // Attribute filters
        if (isset($params['attributes']) && is_array($params['attributes'])) {
            $filters['attributes'] = [];
            foreach ($params['attributes'] as $attributeId => $attributeValue) {
                if (!is_numeric($attributeId) || (int) $attributeId <= 0) {
                    continue;
                }
                $aid = (int) $attributeId;
                if (is_array($attributeValue)) {
                    if (isset($attributeValue['min']) || isset($attributeValue['max'])) {
                        $range = [];
                        if (isset($attributeValue['min']) && is_numeric($attributeValue['min'])) {
                            $range['min'] = (float) $attributeValue['min'];
                        }
                        if (isset($attributeValue['max']) && is_numeric($attributeValue['max'])) {
                            $range['max'] = (float) $attributeValue['max'];
                        }
                        if ($range !== []) {
                            $filters['attributes'][$aid] = $range;
                        }
                    } else {
                        $vals = array_map('sanitize_text_field', array_filter($attributeValue, static fn ($v) => $v !== null && $v !== ''));
                        if ($vals !== []) {
                            $filters['attributes'][$aid] = $vals;
                        }
                    }
                } else {
                    $sanitizedValue = sanitize_text_field((string) $attributeValue);
                    if ($sanitizedValue !== '') {
                        $filters['attributes'][$aid] = $sanitizedValue;
                    }
                }
            }
        }

        // Sort parameter
        if (!empty($params['sort'])) {
            $allowedSorts = ['most_popular', 'price_low', 'price_high', 'rating_high', 'duration_short', 'duration_long'];
            $sort = sanitize_text_field((string) $params['sort']);
            if (in_array($sort, $allowedSorts, true)) {
                $filters['sort'] = $sort;
            }
        }

        // Template / URL aliases (sidebar uses offers[], booking[], age[], services[])
        if (!empty($filters['special_offers'])) {
            $filters['offers'] = $filters['special_offers'];
        }
        if (!empty($filters['booking_options'])) {
            $filters['booking'] = $filters['booking_options'];
        }
        if (!empty($filters['age_suitability'])) {
            $filters['age'] = $filters['age_suitability'];
        }
        if (!empty($filters['included_services'])) {
            $filters['services'] = $filters['included_services'];
        }
        if (!empty($filters['trip_category'])) {
            $filters['category'] = $filters['trip_category'];
        }

        return $filters;
    }

    /**
     * @param mixed $raw
     * @return list<string>
     */
    private function sanitizeStringList($raw): array
    {
        if ($raw === null || $raw === '') {
            return [];
        }
        $items = is_array($raw) ? $raw : [$raw];
        $out = [];
        foreach ($items as $item) {
            $s = sanitize_text_field((string) $item);
            if ($s !== '') {
                $out[] = $s;
            }
        }
        return array_values(array_unique($out));
    }

    /**
     * Generate cache key for trip listing request
     */
    private function generateCacheKey(array $filters, int $page, int $perPage): string
    {
        $keyData = [
            'filters' => $filters,
            'page' => $page,
            'per_page' => $perPage
        ];
        
        return 'trip_listing_' . md5(serialize($keyData));
    }

    /**
     * Build trip listing result
     */
    private function buildTripListingResult(array $filters, int $page, int $perPage): array
    {
        // Apply attribute filtering if present
        if (!empty($filters['attributes'])) {
            $attributeFilters = $filters['attributes'];
            unset($filters['attributes']); // Remove from regular filters
            
            // Apply attribute filtering to repository
            $filters = $this->tripRepository->filterByAttributes($filters, $attributeFilters);
        }
        
        // Get filtered trips from repository
        $tripResult = $this->tripRepository->findWithFilters($filters, $page, $perPage);

        // average_rating + review_count are aggregated in the listing
        // SQL ({@see TripRepository::findWithFilters()} — `AVG(r.rating)`
        // / `COUNT(DISTINCT r.id)` over the LEFT-JOINed approved-reviews
        // rows), so the listing card has everything it needs to render
        // the star block without an extra round-trip per trip.
        //
        // A previous version called `findApprovedByTripId(LIMIT 10)`
        // here for every displayed trip — an N+1 with a JOIN to
        // wp_users — and recomputed the average from the truncated
        // list. That was both slow and *less accurate* than the SQL
        // aggregate (capped at 10 reviews per trip). Drop it.
        //
        // `reviews` stays present-but-empty for templates that probe
        // the property defensively (`is_array($trip->reviews)`).
        foreach ($tripResult['trips'] as $trip) {
            if (!isset($trip->reviews)) {
                $trip->reviews = [];
            }
            $trip->review_count = (int) ($trip->review_count ?? 0);
            $trip->average_rating = (float) ($trip->average_rating ?? 0);
        }

        // Get filter options for UI (cached separately)
        $filterOptions = $this->getFilterOptions();

        return [
            'trips' => $tripResult['trips'],
            'total' => $tripResult['total'],
            'pages' => $tripResult['pages'],
            'page' => $tripResult['page'],
            'per_page' => $tripResult['per_page'],
            'filters' => $this->stripInternalFilterKeys($filters),
            'destinations' => $filterOptions['destinations'],
            'activities' => $filterOptions['activities'],
            'attributes' => $filterOptions['attributes']
        ];
    }

    /**
     * Get filter options for UI
     */
    private function getFilterOptions(): array
    {
        return $this->tripRepository->withQueryCache(
            'trip_listing_filter_options',
            function () {
                return [
                    'destinations' => $this->destinationRepository->getPublished(),
                    'activities' => $this->activityRepository->getPublished(),
                    'attributes' => $this->getAvailableAttributes(),
                ];
            },
            Cache::DURATION_DESTINATION_DATA
        );
    }

    /**
     * Get reviews for a specific trip (same as SingleTripController)
     *
     * @param int $trip_id Trip ID
     * @return array Reviews
     */
    private function getReviewsForTrip(int $trip_id): array
    {
        // Check if reviews table exists
        if (!$this->reviewRepository->tableExists()) {
            return [];
        }

        return $this->reviewRepository->findApprovedByTripId($trip_id);
    }

    /**
     * Calculate average rating from reviews (same as SingleTripController)
     *
     * @param array $reviews Reviews array
     * @return float Average rating
     */
    private function calculateAverageRating(array $reviews): float
    {
        if (empty($reviews)) {
            return 0.0;
        }

        $total = 0;
        foreach ($reviews as $review) {
            $total += (float) ($review->rating ?? 0);
        }

        return round($total / count($reviews), 1);
    }

    /**
     * Get available attributes for filtering
     */
    private function getAvailableAttributes(): array
    {
        $attributeRepository = new \Yatra\Repositories\AttributeRepository();
        return $attributeRepository->getAvailableAttributes();
    }

    /**
     * Get trip statistics for analytics
     */
    private function stripInternalFilterKeys(array $filters): array
    {
        unset(
            $filters['attribute_filters'],
            $filters['search'],
            $filters['category_ids'],
            $filters['destination_ids'],
            $filters['activity_ids']
        );

        return $filters;
    }

    public function getTripStatistics(): array
    {
        return $this->tripRepository->withQueryCache(
            'trip_listing_statistics',
            function () {
                return $this->calculateTripStatistics();
            },
            Cache::DURATION_STATS
        );
    }

    /**
     * Calculate trip statistics
     */
    private function calculateTripStatistics(): array
    {
        $startTime = microtime(true);
        
        $stats = [
            'total_published_trips' => $this->tripRepository->count(['status' => 'publish']),
            'total_destinations' => $this->destinationRepository->count(['status' => 'publish']),
            'total_activities' => $this->activityRepository->count(['status' => 'publish']),
            'price_range' => $this->tripRepository->getPriceRange(),
            'duration_range' => $this->tripRepository->getDurationRange()
        ];
        
        $executionTime = microtime(true) - $startTime;
        Logger::debug("Trip statistics calculated", [
            'execution_time' => $executionTime,
            'stats' => $stats
        ]);
        
        return $stats;
    }

    /**
     * Clear trip listing caches
     */
    public function clearCache(): void
    {
        Cache::clearByPrefix('trip_listing_');
        Logger::info("Trip listing caches cleared");
    }

    /**
     * Get trips for taxonomy context (destination/activity pages)
     *
     * @param string $taxonomyType 'destination' or 'activity'
     * @param string $slug Taxonomy slug
     * @param int|null $limit Number of trips to return; null uses WordPress posts per page.
     * @return array
     */
    public function getTripsByTaxonomy(string $taxonomyType, string $slug, ?int $limit = null): array
    {
        $limit = $limit ?? \yatra_get_posts_per_page();
        $limit = max(1, $limit);

        $filters = [$taxonomyType => $slug];

        $result = $this->tripRepository->findWithFilters($filters, 1, $limit);

        // Rating + count come from the listing SQL aggregate; see
        // {@see self::buildTripListingResult()} for why we don't reload
        // approved reviews per trip here (was an N+1).
        foreach ($result['trips'] as $trip) {
            if (!isset($trip->reviews)) {
                $trip->reviews = [];
            }
            $trip->review_count = (int) ($trip->review_count ?? 0);
            $trip->average_rating = (float) ($trip->average_rating ?? 0);
        }

        return [
            'trips' => $result['trips'],
            'total' => $result['total']
        ];
    }

    /**
     * Validate and process search parameters
     *
     * @param array $params Search parameters
     * @return bool True if valid search request
     */
    public function isValidSearchRequest(array $params): bool
    {
        if (!empty($params['s'])) {
            return true;
        }
        $meaningfulParams = [
            'destination', 'activity', 'trip_category', 'category', 'price_min', 'price_max',
            'duration_min', 'duration_max', 'duration', 'rating_min', 'difficulty', 'sort',
            'categories', 'destinations', 'activities', 'trip_type', 'rating',
            'special_offers', 'booking_options', 'age_suitability', 'offers', 'booking', 'age',
        ];

        foreach ($meaningfulParams as $param) {
            if (!empty($params[$param])) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get filter data for trip listing templates
     * 
     * @return array All filter options with counts
     */
    public function getFilterData(): array
    {
        return $this->tripRepository->withQueryCache(
            'trip_listing_filter_data_v2',
            function () {
                return $this->buildFilterData();
            },
            3600
        );
    }

    /**
     * Build filter data from repositories
     */
    private function buildFilterData(): array
    {
        return [
            'price_stats' => $this->tripRepository->getPriceStats(),
            'trip_types' => $this->getTripTypeOptions(),
            'difficulty_levels' => $this->getDifficultyLevelOptions(),
            'ratings' => $this->getRatingOptions(),
            'categories' => $this->getCategoryOptions(),
            'destinations' => $this->getDestinationOptions(),
            'activities' => $this->getActivityOptions(),
            'accommodations' => $this->tripRepository->getAccommodationTypes(),
            'included_services' => $this->tripRepository->getIncludedServices(),
            'durations' => $this->tripRepository->getDurationOptions(),
            'group_sizes' => $this->tripRepository->getGroupSizeOptions(),
            'physical_grades' => $this->tripRepository->getPhysicalGrades(),
            'special_offers' => $this->getSpecialOffers(),
            'age_restrictions' => $this->getAgeRestrictions(),
            'booking_options' => $this->getBookingOptions()
        ];
    }

    /**
     * Get trip type options with counts
     */
    private function getTripTypeOptions(): array
    {
        $tripTypes = $this->tripRepository->getTripTypes();
        $result = [];

        foreach ($tripTypes as $type) {
            $count = $this->tripRepository->countByTripType($type->value);
            $result[] = (object) [
                'value' => $type->value,
                'label' => $type->label,
                'count' => $count
            ];
        }

        return $result;
    }

    /**
     * Get difficulty level options with counts
     */
    private function getDifficultyLevelOptions(): array
    {
        $levels = $this->categoryRepository->getDifficultyLevels();
        $result = [];

        foreach ($levels as $level) {
            $count = $this->tripRepository->countByDifficultyLevel((int) $level->id);
            $result[] = (object) [
                'slug' => $level->slug,
                'name' => $level->name,
                'id' => (int) $level->id,
                'count' => $count
            ];
        }

        return $result;
    }

    /**
     * Get rating options with counts
     */
    private function getRatingOptions(): array
    {
        $result = [];
        
        // Check if reviews table exists
        if (!$this->reviewRepository->tableExists()) {
            return $result;
        }

        for ($rating = 5; $rating >= 1; $rating--) {
            $count = $this->tripRepository->countByMinRating($rating);
            $result[] = (object) [
                'rating' => $rating,
                'count' => $count,
                'label' => sprintf('%d %s', $rating, _n('Star', 'Stars', $rating, 'yatra'))
            ];
        }

        return $result;
    }

    /**
     * Get category options with counts
     */
    private function getCategoryOptions(): array
    {
        $categories = $this->categoryRepository->getPublishedCategories();
        $result = [];

        foreach ($categories as $category) {
            $count = $this->tripRepository->countByCategory((int) $category->id);
            $result[] = (object) [
                'id' => $category->id,
                'name' => $category->name,
                'slug' => $category->slug,
                'count' => $count
            ];
        }

        return $result;
    }

    /**
     * Get destination options with counts
     */
    private function getDestinationOptions(): array
    {
        $destinations = $this->destinationRepository->getPublished();
        $result = [];

        foreach ($destinations as $destination) {
            $count = $this->tripRepository->countByDestination((int) $destination->id);
            $result[] = (object) [
                'id' => $destination->id,
                'name' => $destination->name,
                'slug' => $destination->slug,
                'count' => $count
            ];
        }

        return $result;
    }

    /**
     * Get activity options with counts
     */
    private function getActivityOptions(): array
    {
        $activities = $this->activityRepository->getPublished();
        $result = [];

        foreach ($activities as $activity) {
            $count = $this->tripRepository->countByActivity((int) $activity->id);
            $result[] = (object) [
                'id' => $activity->id,
                'name' => $activity->name,
                'slug' => $activity->slug,
                'count' => $count
            ];
        }

        return $result;
    }

    /**
     * Get special offers with counts
     */
    private function getSpecialOffers(): array
    {
        $special_offers = [];
        
        // Check for discounted trips
        $discount_count = $this->tripRepository->countByDiscount();
        if ($discount_count > 0) {
            $special_offers[] = (object) ['value' => 'discount', 'label' => __('Discount Available', 'yatra'), 'count' => $discount_count];
        }
        
        // Check for early bird offers
        $early_bird_count = $this->tripRepository->countByEarlyBird();
        if ($early_bird_count > 0) {
            $special_offers[] = (object) ['value' => 'early-bird', 'label' => __('Early Bird Offer', 'yatra'), 'count' => $early_bird_count];
        }
        
        // Check for last minute deals
        $last_minute_count = $this->tripRepository->countByLastMinute();
        if ($last_minute_count > 0) {
            $special_offers[] = (object) ['value' => 'last-minute', 'label' => __('Last Minute Deal', 'yatra'), 'count' => $last_minute_count];
        }
        
        // Check for instant booking
        $instant_count = $this->tripRepository->countByInstantBooking();
        if ($instant_count > 0) {
            $special_offers[] = (object) ['value' => 'instant-booking', 'label' => __('Instant Booking', 'yatra'), 'count' => $instant_count];
        }
        
        // Check for flexible dates
        $flexible_count = $this->tripRepository->countByFlexibleDates();
        if ($flexible_count > 0) {
            $special_offers[] = (object) ['value' => 'flexible-dates', 'label' => __('Flexible Dates', 'yatra'), 'count' => $flexible_count];
        }
        
        // Check for deposit options
        $deposit_count = $this->tripRepository->countByDepositRequired();
        if ($deposit_count > 0) {
            $special_offers[] = (object) ['value' => 'deposit-available', 'label' => __('Pay Later Available', 'yatra'), 'count' => $deposit_count];
        }
        
        return $special_offers;
    }

    /**
     * Get age restrictions with counts
     */
    private function getAgeRestrictions(): array
    {
        $age_options = [];
        
        // Check for family friendly (no age restrictions or low minimum age)
        $family_count = $this->tripRepository->countByFamilyFriendly();
        if ($family_count > 0) {
            $age_options[] = (object) ['value' => 'family-friendly', 'label' => __('Family Friendly', 'yatra'), 'count' => $family_count];
        }
        
        // Check for kids suitable (age_min <= 12)
        $kids_count = $this->tripRepository->countByKidsFriendly();
        if ($kids_count > 0) {
            $age_options[] = (object) ['value' => 'kids-friendly', 'label' => __('Kids Friendly', 'yatra'), 'count' => $kids_count];
        }
        
        // Check for senior friendly (no upper age limit or high limit)
        $senior_count = $this->tripRepository->countBySeniorFriendly();
        if ($senior_count > 0) {
            $age_options[] = (object) ['value' => 'senior-friendly', 'label' => __('Senior Friendly', 'yatra'), 'count' => $senior_count];
        }
        
        // Check for adults only (age_min >= 18)
        $adults_count = $this->tripRepository->countByAdultsOnly();
        if ($adults_count > 0) {
            $age_options[] = (object) ['value' => 'adults-only', 'label' => __('Adults Only', 'yatra'), 'count' => $adults_count];
        }
        
        return $age_options;
    }

    /**
     * Get booking options with counts
     */
    private function getBookingOptions(): array
    {
        $booking_options = [];
        
        // Check for instant booking
        $instant_count = $this->tripRepository->countByInstantBooking();
        if ($instant_count > 0) {
            $booking_options[] = (object) ['value' => 'instant', 'label' => __('Instant Confirmation', 'yatra'), 'count' => $instant_count];
        }
        
        // Check for flexible dates
        $flexible_count = $this->tripRepository->countByFlexibleDates();
        if ($flexible_count > 0) {
            $booking_options[] = (object) ['value' => 'flexible', 'label' => __('Flexible Dates', 'yatra'), 'count' => $flexible_count];
        }
        
        // Check for deposit options
        $deposit_count = $this->tripRepository->countByDepositRequired();
        if ($deposit_count > 0) {
            $booking_options[] = (object) ['value' => 'pay-later', 'label' => __('Reserve Now, Pay Later', 'yatra'), 'count' => $deposit_count];
        }
        
        return $booking_options;
    }

    /**
     * Get price statistics for trips
     * 
     * @return object|null Price stats with min and max prices
     */
    public function getPriceStats(): ?object
    {
        return $this->tripRepository->getPriceStats();
    }

    /**
     * Dynamic budget tier values for the horizontal search bar (maps to ?budget= via sanitizeFilters).
     *
     * @return list<object{value: string, label: string}>
     */
    public function getSearchBudgetPresets(): array
    {
        $stats = $this->tripRepository->getPriceStats();
        $min = $stats ? (int) floor((float) $stats->min_price) : 0;
        $max = $stats ? (int) ceil((float) $stats->max_price) : 0;
        if ($max <= $min) {
            $max = $min + 1000;
        }
        $bands = 5;
        $step = (int) max(1, ceil(($max - $min) / $bands));
        $out = [];
        for ($i = 0; $i < $bands; $i++) {
            $lo = $min + $i * $step;
            if ($lo > $max) {
                break;
            }
            $hi = ($i === $bands - 1) ? $max : min($max, $lo + $step - 1);
            if ($hi < $lo) {
                $hi = $lo;
            }
            $sym = yatra_get_currency_symbol(get_option('yatra_currency', 'USD'));
            $out[] = (object) [
                'value' => $lo . '-' . $hi,
                'label' => trim($sym . number_format_i18n($lo) . ' – ' . $sym . number_format_i18n($hi)),
            ];
        }

        return $out;
    }
}

```
