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