| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Yatra\Repositories; |
| 6 |
|
| 7 |
use Yatra\Database\Tables\BookingsTable; |
| 8 |
use Yatra\Database\Tables\ClassificationsTable; |
| 9 |
use Yatra\Database\Tables\ReviewsTable; |
| 10 |
use Yatra\Database\Tables\TripClassificationsTable; |
| 11 |
use Yatra\Database\Tables\TripContentTable; |
| 12 |
use Yatra\Database\Tables\TripItineraryDayEntryTable; |
| 13 |
use Yatra\Database\Tables\TripItineraryDaysTable; |
| 14 |
use Yatra\Database\Tables\TripsTable; |
| 15 |
use Yatra\Repositories\TripDownloadRepository; |
| 16 |
use Yatra\Repositories\AttributeRepository; |
| 17 |
use Yatra\Models\Trip; |
| 18 |
use Yatra\Utils\Cache; |
| 19 |
use Yatra\Utils\QueryCache; |
| 20 |
use Yatra\Database\Tables\TripAvailabilityDatesTable; |
| 21 |
use Yatra\Database\Tables\TripAvailabilityRulesTable; |
| 22 |
use Yatra\Constants\ClassificationTypes; |
| 23 |
|
| 24 |
/** |
| 25 |
* Trip Repository |
| 26 |
* Handles database operations for trips with comprehensive field support |
| 27 |
* |
| 28 |
* Expert-level repository design: |
| 29 |
* - Relationship management (destinations, activities) |
| 30 |
* - JSON field handling |
| 31 |
* - Soft delete support |
| 32 |
* - Optimized queries with proper indexing |
| 33 |
*/ |
| 34 |
class TripRepository extends BaseRepository |
| 35 |
{ |
| 36 |
/** |
| 37 |
* Get bookings count map for given trip IDs. |
| 38 |
* |
| 39 |
* The trips table has a `bookings_count` column but it is not reliably maintained. |
| 40 |
* For list views, compute counts from the bookings table in one grouped query. |
| 41 |
* |
| 42 |
* @param int[] $tripIds |
| 43 |
* @param string[]|null $excludeStatuses |
| 44 |
* @return array<int,int> map trip_id => count |
| 45 |
*/ |
| 46 |
public function getBookingsCountMap(array $tripIds, ?array $excludeStatuses = null): array |
| 47 |
{ |
| 48 |
$tripIds = array_values(array_filter(array_map('intval', $tripIds))); |
| 49 |
if (empty($tripIds)) { |
| 50 |
return []; |
| 51 |
} |
| 52 |
|
| 53 |
// Default: ignore cancelled/failed bookings in counts (can be overridden) |
| 54 |
$excludeStatuses = $excludeStatuses ?? apply_filters( |
| 55 |
'yatra_trip_bookings_count_exclude_statuses', |
| 56 |
['cancelled', 'failed'], |
| 57 |
$tripIds |
| 58 |
); |
| 59 |
$excludeStatuses = is_array($excludeStatuses) ? array_values(array_filter(array_map('strval', $excludeStatuses))) : []; |
| 60 |
|
| 61 |
$bookingsTable = BookingsTable::getTableName(); |
| 62 |
|
| 63 |
$idPlaceholders = implode(',', array_fill(0, count($tripIds), '%d')); |
| 64 |
$where = "trip_id IN ({$idPlaceholders})"; |
| 65 |
$params = $tripIds; |
| 66 |
|
| 67 |
if (!empty($excludeStatuses)) { |
| 68 |
$stPlaceholders = implode(',', array_fill(0, count($excludeStatuses), '%s')); |
| 69 |
$where .= " AND status NOT IN ({$stPlaceholders})"; |
| 70 |
$params = array_merge($params, $excludeStatuses); |
| 71 |
} |
| 72 |
|
| 73 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- uses $wpdb->prepare with placeholders |
| 74 |
$sql = $this->wpdb->prepare( |
| 75 |
"SELECT trip_id, COUNT(*) AS cnt |
| 76 |
FROM {$bookingsTable} |
| 77 |
WHERE {$where} |
| 78 |
GROUP BY trip_id", |
| 79 |
$params |
| 80 |
); |
| 81 |
|
| 82 |
$rows = $this->wpdb->get_results($sql); |
| 83 |
$map = []; |
| 84 |
foreach ((array) $rows as $row) { |
| 85 |
$tId = (int) ($row->trip_id ?? 0); |
| 86 |
if ($tId > 0) { |
| 87 |
$map[$tId] = (int) ($row->cnt ?? 0); |
| 88 |
} |
| 89 |
} |
| 90 |
|
| 91 |
return $map; |
| 92 |
} |
| 93 |
|
| 94 |
/** |
| 95 |
* Cache for table existence checks to avoid repeated SHOW TABLES queries |
| 96 |
*/ |
| 97 |
private static array $tableExistsCache = []; |
| 98 |
|
| 99 |
/** |
| 100 |
* Rich text fields specific to trips |
| 101 |
*/ |
| 102 |
protected array $richTextFields = ['description']; |
| 103 |
|
| 104 |
/** |
| 105 |
* Integer fields specific to trips |
| 106 |
*/ |
| 107 |
protected array $integerFields = [ |
| 108 |
'id', |
| 109 |
'created_by', |
| 110 |
'updated_by', |
| 111 |
'difficulty_level', |
| 112 |
'duration_days', |
| 113 |
'duration_nights', |
| 114 |
'duration_hours', |
| 115 |
'booking_window_days', |
| 116 |
'booking_deadline_hours', |
| 117 |
'min_travelers', |
| 118 |
'max_travelers', |
| 119 |
'group_size', |
| 120 |
'age_min', |
| 121 |
'age_max', |
| 122 |
'version', |
| 123 |
'views_count', |
| 124 |
'bookings_count', |
| 125 |
'reviews_count' |
| 126 |
]; |
| 127 |
|
| 128 |
/** |
| 129 |
* JSON fields specific to trips |
| 130 |
*/ |
| 131 |
protected array $jsonFields = [ |
| 132 |
'included_items', |
| 133 |
'excluded_items', |
| 134 |
'frontend_tabs', |
| 135 |
'testimonial_review_ids', |
| 136 |
'custom_fields', |
| 137 |
'price_types', |
| 138 |
]; |
| 139 |
|
| 140 |
/** |
| 141 |
* Publish trips whose scheduled_publish_date has passed |
| 142 |
*/ |
| 143 |
public function publishScheduledTrips(string $now): void |
| 144 |
{ |
| 145 |
$table = esc_sql($this->table); |
| 146 |
$affected = $this->wpdb->query( |
| 147 |
$this->wpdb->prepare( |
| 148 |
"UPDATE {$table} |
| 149 |
SET status = 'publish', scheduled_publish_date = NULL, updated_at = %s |
| 150 |
WHERE scheduled_publish_date IS NOT NULL |
| 151 |
AND scheduled_publish_date <= %s |
| 152 |
AND status <> 'publish'", |
| 153 |
$now, |
| 154 |
$now |
| 155 |
) |
| 156 |
); |
| 157 |
if ($affected !== false && (int) $affected > 0) { |
| 158 |
Cache::invalidateAfterBulkTripTableWrites(); |
| 159 |
} |
| 160 |
} |
| 161 |
|
| 162 |
/** |
| 163 |
* Archive trips whose scheduled_unpublish_date has passed |
| 164 |
*/ |
| 165 |
public function archiveScheduledTrips(string $now): void |
| 166 |
{ |
| 167 |
$table = esc_sql($this->table); |
| 168 |
$affected = $this->wpdb->query( |
| 169 |
$this->wpdb->prepare( |
| 170 |
"UPDATE {$table} |
| 171 |
SET status = 'archived', scheduled_unpublish_date = NULL, updated_at = %s |
| 172 |
WHERE scheduled_unpublish_date IS NOT NULL |
| 173 |
AND scheduled_unpublish_date <= %s |
| 174 |
AND status = 'publish'", |
| 175 |
$now, |
| 176 |
$now |
| 177 |
) |
| 178 |
); |
| 179 |
if ($affected !== false && (int) $affected > 0) { |
| 180 |
Cache::invalidateAfterBulkTripTableWrites(); |
| 181 |
} |
| 182 |
} |
| 183 |
|
| 184 |
/** |
| 185 |
* Enable trips when seasonal_auto_enable is set and enable date reached |
| 186 |
*/ |
| 187 |
public function enableSeasonalTrips(string $today, string $now): void |
| 188 |
{ |
| 189 |
$table = esc_sql($this->table); |
| 190 |
$affected = $this->wpdb->query( |
| 191 |
$this->wpdb->prepare( |
| 192 |
"UPDATE {$table} |
| 193 |
SET status = 'publish', updated_at = %s |
| 194 |
WHERE seasonal_auto_enable = 1 |
| 195 |
AND seasonal_enable_date IS NOT NULL |
| 196 |
AND seasonal_enable_date <= %s |
| 197 |
AND status <> 'publish'", |
| 198 |
$now, |
| 199 |
$today |
| 200 |
) |
| 201 |
); |
| 202 |
if ($affected !== false && (int) $affected > 0) { |
| 203 |
Cache::invalidateAfterBulkTripTableWrites(); |
| 204 |
} |
| 205 |
} |
| 206 |
|
| 207 |
/** |
| 208 |
* Disable trips when seasonal_auto_enable is set and disable date reached |
| 209 |
*/ |
| 210 |
public function disableSeasonalTrips(string $today, string $now): void |
| 211 |
{ |
| 212 |
$table = esc_sql($this->table); |
| 213 |
$affected = $this->wpdb->query( |
| 214 |
$this->wpdb->prepare( |
| 215 |
"UPDATE {$table} |
| 216 |
SET status = 'archived', updated_at = %s |
| 217 |
WHERE seasonal_auto_enable = 1 |
| 218 |
AND seasonal_disable_date IS NOT NULL |
| 219 |
AND seasonal_disable_date <= %s |
| 220 |
AND status = 'publish'", |
| 221 |
$now, |
| 222 |
$today |
| 223 |
) |
| 224 |
); |
| 225 |
if ($affected !== false && (int) $affected > 0) { |
| 226 |
Cache::invalidateAfterBulkTripTableWrites(); |
| 227 |
} |
| 228 |
} |
| 229 |
|
| 230 |
/** |
| 231 |
* Get table name |
| 232 |
*/ |
| 233 |
protected function getTableName(): string |
| 234 |
{ |
| 235 |
return TripsTable::getTableName(); |
| 236 |
} |
| 237 |
|
| 238 |
/** |
| 239 |
* Cached single trip row (same key family as {@see CacheService::cacheTrip} consumers). |
| 240 |
*/ |
| 241 |
public function findByIdCached(int $id): ?\stdClass |
| 242 |
{ |
| 243 |
return $this->cacheQueryResult( |
| 244 |
Cache::PREFIX_TRIP_DATA . $id, |
| 245 |
function () use ($id): ?\stdClass { |
| 246 |
return $this->find($id); |
| 247 |
}, |
| 248 |
Cache::DURATION_TRIP_DATA |
| 249 |
); |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* Trip row with relationships loaded — used where {@see TripService::getWithRelationsCached} applied. |
| 254 |
*/ |
| 255 |
public function findWithRelationsCached(int $id): ?\stdClass |
| 256 |
{ |
| 257 |
$key = Cache::PREFIX_QUERY_RESULT . 'trip_with_relations_' . $id; |
| 258 |
|
| 259 |
return $this->cacheQueryResult($key, function () use ($id): ?\stdClass { |
| 260 |
$trip = $this->find($id); |
| 261 |
if ($trip) { |
| 262 |
$this->loadTripRelationships($trip); |
| 263 |
} |
| 264 |
|
| 265 |
return $trip; |
| 266 |
}, Cache::DURATION_TRIP_DATA); |
| 267 |
} |
| 268 |
|
| 269 |
/** |
| 270 |
* Create trip row; {@see afterWrite} handles cache; fires action `yatra_trip_created` once per insert. |
| 271 |
*/ |
| 272 |
public function create(array $data): int |
| 273 |
{ |
| 274 |
$id = parent::create($data); |
| 275 |
do_action('yatra_trip_created', $id); |
| 276 |
|
| 277 |
return $id; |
| 278 |
} |
| 279 |
|
| 280 |
/** |
| 281 |
* Override update method to provide proper field formats |
| 282 |
*/ |
| 283 |
public function update(int $id, array $data): bool |
| 284 |
{ |
| 285 |
$data = $this->sanitizeData($data); |
| 286 |
$data['updated_at'] = current_time('mysql'); |
| 287 |
|
| 288 |
// Build format array based on field types |
| 289 |
$formats = []; |
| 290 |
foreach ($data as $key => $value) { |
| 291 |
if (in_array($key, $this->integerFields, true)) { |
| 292 |
$formats[] = '%d'; |
| 293 |
} elseif (in_array($key, ['created_at', 'updated_at'], true)) { |
| 294 |
$formats[] = '%s'; |
| 295 |
} elseif (in_array($key, ['original_price', 'discounted_price', 'sale_price', 'deposit_amount', 'deposit_percentage', 'avg_rating', 'revenue_total', 'conversion_rate'], true)) { |
| 296 |
$formats[] = '%f'; |
| 297 |
} elseif (in_array($key, ['transportation_included', 'is_featured', 'seasonal_auto_enable', 'has_default_time_slots'], true)) { |
| 298 |
$formats[] = '%d'; // boolean as integer |
| 299 |
} else { |
| 300 |
$formats[] = '%s'; // default to string |
| 301 |
} |
| 302 |
} |
| 303 |
|
| 304 |
$result = $this->wpdb->update( |
| 305 |
$this->getTableName(), |
| 306 |
$data, |
| 307 |
['id' => $id], |
| 308 |
$formats, |
| 309 |
['%d'] |
| 310 |
); |
| 311 |
|
| 312 |
if ($result !== false) { |
| 313 |
$this->afterWrite('update', $id, []); |
| 314 |
} |
| 315 |
|
| 316 |
return $result !== false; |
| 317 |
} |
| 318 |
|
| 319 |
/** |
| 320 |
* Invalidate trip/listing caches. Trip created hook is fired from {@see create()} after relations |
| 321 |
* are irrelevant for cache; update/delete hooks fire here. |
| 322 |
* |
| 323 |
* @param 'create'|'update'|'delete' $operation |
| 324 |
*/ |
| 325 |
protected function afterWrite(string $operation, int $id, array $context = []): void |
| 326 |
{ |
| 327 |
Cache::invalidateAfterTripWrite($operation, $id); |
| 328 |
|
| 329 |
if ($operation === 'update') { |
| 330 |
do_action('yatra_trip_updated', $id); |
| 331 |
} elseif ($operation === 'delete') { |
| 332 |
do_action('yatra_trip_deleted', $id); |
| 333 |
} |
| 334 |
} |
| 335 |
|
| 336 |
/** |
| 337 |
* Find published/active trip by ID |
| 338 |
* |
| 339 |
* @param int $id Trip ID |
| 340 |
* @return \stdClass|null |
| 341 |
*/ |
| 342 |
public function findPublished(int $id): ?\stdClass |
| 343 |
{ |
| 344 |
$table = esc_sql($this->table); |
| 345 |
$query = "SELECT * FROM `{$table}` WHERE id = %d AND status IN ('publish', 'published')"; |
| 346 |
|
| 347 |
if ($this->hasSoftDelete()) { |
| 348 |
$query .= " AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')"; |
| 349 |
} |
| 350 |
|
| 351 |
$result = $this->wpdb->get_row($this->wpdb->prepare($query, $id)); |
| 352 |
|
| 353 |
return $result ?: null; |
| 354 |
} |
| 355 |
|
| 356 |
/** |
| 357 |
* Get downloads for a trip (normalized for UI) |
| 358 |
*/ |
| 359 |
public function getDownloads(int $tripId): array |
| 360 |
{ |
| 361 |
$repo = new TripDownloadRepository(); |
| 362 |
// TripDownloadRepository already normalizes the data correctly |
| 363 |
// No need to normalize again |
| 364 |
return $repo->getByTripId($tripId); |
| 365 |
} |
| 366 |
|
| 367 |
/** |
| 368 |
* Attach attribute filter map for findWithFilters() (trip ↔ attribute rows in trip_classifications). |
| 369 |
* |
| 370 |
* @param array<string,mixed> $filters |
| 371 |
* @param array<int,mixed> $attributeFilters |
| 372 |
* @return array<string,mixed> |
| 373 |
*/ |
| 374 |
public function filterByAttributes(array $filters, array $attributeFilters): array |
| 375 |
{ |
| 376 |
if ($attributeFilters === []) { |
| 377 |
return $filters; |
| 378 |
} |
| 379 |
$filters['attribute_filters'] = $attributeFilters; |
| 380 |
|
| 381 |
return $filters; |
| 382 |
} |
| 383 |
|
| 384 |
/** @var array<string,bool> */ |
| 385 |
private static array $tripColumnExistsCache = []; |
| 386 |
|
| 387 |
protected function tripTableHasColumn(string $column): bool |
| 388 |
{ |
| 389 |
if (isset(self::$tripColumnExistsCache[$column])) { |
| 390 |
return self::$tripColumnExistsCache[$column]; |
| 391 |
} |
| 392 |
$table = $this->getTableName(); |
| 393 |
$n = (int) $this->wpdb->get_var($this->wpdb->prepare( |
| 394 |
'SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = %s', |
| 395 |
$table, |
| 396 |
$column |
| 397 |
)); |
| 398 |
self::$tripColumnExistsCache[$column] = $n > 0; |
| 399 |
|
| 400 |
return self::$tripColumnExistsCache[$column]; |
| 401 |
} |
| 402 |
|
| 403 |
/** |
| 404 |
* SQL expression for trip "current" list price (matches TripPricingService::resolveRegularCurrentPrice). |
| 405 |
*/ |
| 406 |
protected function sqlTripEffectiveListPrice(): string |
| 407 |
{ |
| 408 |
return '(CASE ' |
| 409 |
. 'WHEN CAST(t.discounted_price AS DECIMAL(10,2)) > 0 THEN CAST(t.discounted_price AS DECIMAL(10,2)) ' |
| 410 |
. 'WHEN CAST(t.sale_price AS DECIMAL(10,2)) > 0 THEN CAST(t.sale_price AS DECIMAL(10,2)) ' |
| 411 |
. 'ELSE CAST(t.original_price AS DECIMAL(10,2)) END)'; |
| 412 |
} |
| 413 |
|
| 414 |
/** |
| 415 |
* Find trips with comprehensive filtering, pagination, and relationships |
| 416 |
* |
| 417 |
* @param array $filters Filter criteria |
| 418 |
* @param int $page Current page |
| 419 |
* @param int $perPage Items per page |
| 420 |
* @return array |
| 421 |
*/ |
| 422 |
public function findWithFilters(array $filters = [], int $page = 1, int $perPage = 10): array |
| 423 |
{ |
| 424 |
// Create cache key based on filters and pagination |
| 425 |
$cacheKey = Cache::KEY_TRIPS_WITH_FILTERS . '_' . md5(serialize($filters) . "_page_{$page}_per_page_{$perPage}"); |
| 426 |
|
| 427 |
return $this->cacheQueryResult($cacheKey, function() use ($filters, $page, $perPage) { |
| 428 |
global $wpdb; |
| 429 |
|
| 430 |
// Table references |
| 431 |
$trip_table = $this->getTableName(); |
| 432 |
|
| 433 |
// Use new table classes |
| 434 |
$reviews_table = ReviewsTable::getTableName(); |
| 435 |
$bookings_table = BookingsTable::getTableName(); |
| 436 |
// Build WHERE conditions and parameters |
| 437 |
$wheres = ["t.status IN ('publish', 'published')", "(t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')"]; |
| 438 |
$params = []; |
| 439 |
$joins = []; |
| 440 |
$having_clauses = []; |
| 441 |
$rating_params = []; |
| 442 |
|
| 443 |
$classificationsTable = ClassificationsTable::getTableName(); |
| 444 |
$tripClassificationsTable = TripClassificationsTable::getTableName(); |
| 445 |
|
| 446 |
// Keyword search (trip fields + attribute values marked searchable in metadata) |
| 447 |
if (!empty($filters['search']) && is_string($filters['search'])) { |
| 448 |
$like = '%' . $wpdb->esc_like($filters['search']) . '%'; |
| 449 |
$searchFlag = AttributeRepository::metadataEnabledSqlOnAlias('yatra_c_srch', 'searchable'); |
| 450 |
$wheres[] = '(t.title LIKE %s OR t.short_description LIKE %s OR t.description LIKE %s OR t.slug LIKE %s OR EXISTS ( |
| 451 |
SELECT 1 FROM `' . esc_sql($tripClassificationsTable) . '` yatra_tc_srch |
| 452 |
INNER JOIN `' . esc_sql($classificationsTable) . '` yatra_c_srch |
| 453 |
ON yatra_c_srch.id = yatra_tc_srch.classification_id |
| 454 |
WHERE yatra_tc_srch.trip_id = t.id |
| 455 |
AND yatra_tc_srch.classification_type = %s |
| 456 |
AND yatra_tc_srch.is_active = 1 |
| 457 |
AND yatra_c_srch.type = %s |
| 458 |
AND yatra_c_srch.status = %s |
| 459 |
AND (' . $searchFlag . ') |
| 460 |
AND ( |
| 461 |
LOWER(JSON_UNQUOTE(JSON_EXTRACT(yatra_tc_srch.metadata, \'$.value\'))) LIKE LOWER(%s) |
| 462 |
OR LOWER(yatra_tc_srch.metadata) LIKE LOWER(%s) |
| 463 |
) |
| 464 |
))'; |
| 465 |
$params[] = $like; |
| 466 |
$params[] = $like; |
| 467 |
$params[] = $like; |
| 468 |
$params[] = $like; |
| 469 |
$params[] = 'attribute'; |
| 470 |
$params[] = ClassificationTypes::ATTRIBUTE; |
| 471 |
$params[] = 'publish'; |
| 472 |
$params[] = $like; |
| 473 |
$params[] = $like; |
| 474 |
} |
| 475 |
|
| 476 |
// Trip duration type (DB column) |
| 477 |
if (!empty($filters['trip_type']) && is_string($filters['trip_type'])) { |
| 478 |
$allowedTypes = ['single_day', 'multi_day', 'flexible']; |
| 479 |
if (in_array($filters['trip_type'], $allowedTypes, true)) { |
| 480 |
$wheres[] = 't.trip_type = %s'; |
| 481 |
$params[] = $filters['trip_type']; |
| 482 |
} |
| 483 |
} |
| 484 |
|
| 485 |
// Featured Priority (column on yatra_trips, indexed by idx_featured_priority). |
| 486 |
// Admin form's Featured Priority dropdown is the single source of truth. |
| 487 |
// Legacy `is_featured` shortcode/block flag is normalised upstream into featured_priority='featured'. |
| 488 |
if ( |
| 489 |
!empty($filters['featured_priority']) |
| 490 |
&& is_string($filters['featured_priority']) |
| 491 |
&& in_array($filters['featured_priority'], ['featured', 'new', 'limited'], true) |
| 492 |
&& $this->tripTableHasColumn('featured_priority') |
| 493 |
) { |
| 494 |
$wheres[] = 't.featured_priority = %s'; |
| 495 |
$params[] = $filters['featured_priority']; |
| 496 |
} |
| 497 |
|
| 498 |
// Destination: checkbox classification IDs (OR) or single slug |
| 499 |
if (!empty($filters['destination_ids']) && is_array($filters['destination_ids'])) { |
| 500 |
$ids = array_values(array_filter(array_map('intval', $filters['destination_ids']), static fn (int $id): bool => $id > 0)); |
| 501 |
if ($ids !== []) { |
| 502 |
$placeholders = implode(',', array_fill(0, count($ids), '%d')); |
| 503 |
// Rely on joined classification row type only (some rows may have inconsistent classification_type). |
| 504 |
$wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcdx INNER JOIN {$classificationsTable} destx ON destx.id = tcdx.classification_id AND destx.type = %s WHERE tcdx.trip_id = t.id AND tcdx.is_active = 1 AND tcdx.classification_id IN ({$placeholders}))"; |
| 505 |
$params[] = ClassificationTypes::DESTINATION; |
| 506 |
$params = array_merge($params, $ids); |
| 507 |
} |
| 508 |
} elseif (!empty($filters['destination'])) { |
| 509 |
if (is_array($filters['destination'])) { |
| 510 |
$slugs = array_values(array_filter(array_map('sanitize_title', $filters['destination']))); |
| 511 |
if ($slugs !== []) { |
| 512 |
$placeholders = implode(',', array_fill(0, count($slugs), '%s')); |
| 513 |
$wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcdx INNER JOIN {$classificationsTable} destx ON destx.id = tcdx.classification_id AND destx.type = %s WHERE tcdx.trip_id = t.id AND tcdx.is_active = 1 AND destx.slug IN ({$placeholders}))"; |
| 514 |
$params[] = ClassificationTypes::DESTINATION; |
| 515 |
$params = array_merge($params, $slugs); |
| 516 |
} |
| 517 |
} elseif (is_string($filters['destination']) && $filters['destination'] !== '') { |
| 518 |
$joins[] = "LEFT JOIN {$tripClassificationsTable} tcd ON tcd.trip_id = t.id"; |
| 519 |
$joins[] = "LEFT JOIN {$classificationsTable} dest ON dest.id = tcd.classification_id"; |
| 520 |
$wheres[] = 'dest.type = %s AND dest.slug = %s'; |
| 521 |
$params[] = ClassificationTypes::DESTINATION; |
| 522 |
$params[] = $filters['destination']; |
| 523 |
} |
| 524 |
} |
| 525 |
|
| 526 |
// Activity: IDs or slug |
| 527 |
if (!empty($filters['activity_ids']) && is_array($filters['activity_ids'])) { |
| 528 |
$ids = array_values(array_filter(array_map('intval', $filters['activity_ids']), static fn (int $id): bool => $id > 0)); |
| 529 |
if ($ids !== []) { |
| 530 |
$placeholders = implode(',', array_fill(0, count($ids), '%d')); |
| 531 |
$wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcax INNER JOIN {$classificationsTable} actx ON actx.id = tcax.classification_id AND actx.type = %s WHERE tcax.trip_id = t.id AND tcax.is_active = 1 AND tcax.classification_id IN ({$placeholders}))"; |
| 532 |
$params[] = ClassificationTypes::ACTIVITY; |
| 533 |
$params = array_merge($params, $ids); |
| 534 |
} |
| 535 |
} elseif (!empty($filters['activity'])) { |
| 536 |
if (is_array($filters['activity'])) { |
| 537 |
$slugs = array_values(array_filter(array_map('sanitize_title', $filters['activity']))); |
| 538 |
if ($slugs !== []) { |
| 539 |
$placeholders = implode(',', array_fill(0, count($slugs), '%s')); |
| 540 |
$wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcax INNER JOIN {$classificationsTable} actx ON actx.id = tcax.classification_id AND actx.type = %s WHERE tcax.trip_id = t.id AND tcax.is_active = 1 AND actx.slug IN ({$placeholders}))"; |
| 541 |
$params[] = ClassificationTypes::ACTIVITY; |
| 542 |
$params = array_merge($params, $slugs); |
| 543 |
} |
| 544 |
} elseif (is_string($filters['activity']) && $filters['activity'] !== '') { |
| 545 |
$joins[] = "LEFT JOIN {$tripClassificationsTable} tca ON tca.trip_id = t.id"; |
| 546 |
$joins[] = "LEFT JOIN {$classificationsTable} act ON act.id = tca.classification_id"; |
| 547 |
$wheres[] = 'act.type = %s AND act.slug = %s'; |
| 548 |
$params[] = ClassificationTypes::ACTIVITY; |
| 549 |
$params[] = $filters['activity']; |
| 550 |
} |
| 551 |
} |
| 552 |
|
| 553 |
// Category: IDs or slug |
| 554 |
if (!empty($filters['category_ids']) && is_array($filters['category_ids'])) { |
| 555 |
$ids = array_values(array_filter(array_map('intval', $filters['category_ids']), static fn (int $id): bool => $id > 0)); |
| 556 |
if ($ids !== []) { |
| 557 |
$placeholders = implode(',', array_fill(0, count($ids), '%d')); |
| 558 |
$wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tccx INNER JOIN {$classificationsTable} catx ON catx.id = tccx.classification_id AND catx.type = %s WHERE tccx.trip_id = t.id AND tccx.is_active = 1 AND tccx.classification_id IN ({$placeholders}))"; |
| 559 |
$params[] = ClassificationTypes::CATEGORY; |
| 560 |
$params = array_merge($params, $ids); |
| 561 |
} |
| 562 |
} elseif (!empty($filters['trip_category'])) { |
| 563 |
if (is_array($filters['trip_category'])) { |
| 564 |
$slugs = array_values(array_filter(array_map('sanitize_title', $filters['trip_category']))); |
| 565 |
if ($slugs !== []) { |
| 566 |
$placeholders = implode(',', array_fill(0, count($slugs), '%s')); |
| 567 |
$wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tccx INNER JOIN {$classificationsTable} catx ON catx.id = tccx.classification_id AND catx.type = %s WHERE tccx.trip_id = t.id AND tccx.is_active = 1 AND catx.slug IN ({$placeholders}))"; |
| 568 |
$params[] = ClassificationTypes::CATEGORY; |
| 569 |
$params = array_merge($params, $slugs); |
| 570 |
} |
| 571 |
} elseif (is_string($filters['trip_category']) && $filters['trip_category'] !== '') { |
| 572 |
$joins[] = "LEFT JOIN {$tripClassificationsTable} tcc ON tcc.trip_id = t.id"; |
| 573 |
$joins[] = "LEFT JOIN {$classificationsTable} cat ON cat.id = tcc.classification_id"; |
| 574 |
$wheres[] = 'cat.type = %s AND cat.slug = %s'; |
| 575 |
$params[] = ClassificationTypes::CATEGORY; |
| 576 |
$params[] = $filters['trip_category']; |
| 577 |
} |
| 578 |
} |
| 579 |
|
| 580 |
// Special offers (OR within group) |
| 581 |
if (!empty($filters['special_offers']) && is_array($filters['special_offers'])) { |
| 582 |
$offerParts = []; |
| 583 |
foreach (array_unique($filters['special_offers']) as $offer) { |
| 584 |
if (!is_string($offer)) { |
| 585 |
continue; |
| 586 |
} |
| 587 |
switch ($offer) { |
| 588 |
case 'discount': |
| 589 |
$offerParts[] = '(t.discounted_price IS NOT NULL OR t.sale_price IS NOT NULL)'; |
| 590 |
break; |
| 591 |
case 'early-bird': |
| 592 |
if ($this->tripTableHasColumn('early_bird_discount_enabled')) { |
| 593 |
$offerParts[] = 't.early_bird_discount_enabled = 1'; |
| 594 |
} |
| 595 |
break; |
| 596 |
case 'last-minute': |
| 597 |
if ($this->tripTableHasColumn('last_minute_discount_enabled')) { |
| 598 |
$offerParts[] = 't.last_minute_discount_enabled = 1'; |
| 599 |
} |
| 600 |
break; |
| 601 |
case 'instant-booking': |
| 602 |
if ($this->tripTableHasColumn('instant_booking')) { |
| 603 |
$offerParts[] = 't.instant_booking = 1'; |
| 604 |
} |
| 605 |
break; |
| 606 |
case 'flexible-dates': |
| 607 |
if ($this->tripTableHasColumn('flexible_dates')) { |
| 608 |
$offerParts[] = 't.flexible_dates = 1'; |
| 609 |
} |
| 610 |
break; |
| 611 |
case 'deposit-available': |
| 612 |
if ($this->tripTableHasColumn('deposit_required')) { |
| 613 |
$offerParts[] = 't.deposit_required = 1'; |
| 614 |
} |
| 615 |
break; |
| 616 |
} |
| 617 |
} |
| 618 |
if ($offerParts !== []) { |
| 619 |
$wheres[] = '(' . implode(' OR ', $offerParts) . ')'; |
| 620 |
} |
| 621 |
} |
| 622 |
|
| 623 |
// Booking options (OR) |
| 624 |
if (!empty($filters['booking_options']) && is_array($filters['booking_options'])) { |
| 625 |
$bookParts = []; |
| 626 |
foreach (array_unique($filters['booking_options']) as $opt) { |
| 627 |
if (!is_string($opt)) { |
| 628 |
continue; |
| 629 |
} |
| 630 |
switch ($opt) { |
| 631 |
case 'instant': |
| 632 |
if ($this->tripTableHasColumn('instant_booking')) { |
| 633 |
$bookParts[] = 't.instant_booking = 1'; |
| 634 |
} |
| 635 |
break; |
| 636 |
case 'flexible': |
| 637 |
if ($this->tripTableHasColumn('flexible_dates')) { |
| 638 |
$bookParts[] = 't.flexible_dates = 1'; |
| 639 |
} |
| 640 |
break; |
| 641 |
case 'pay-later': |
| 642 |
if ($this->tripTableHasColumn('deposit_required')) { |
| 643 |
$bookParts[] = 't.deposit_required = 1'; |
| 644 |
} |
| 645 |
break; |
| 646 |
} |
| 647 |
} |
| 648 |
if ($bookParts !== []) { |
| 649 |
$wheres[] = '(' . implode(' OR ', $bookParts) . ')'; |
| 650 |
} |
| 651 |
} |
| 652 |
|
| 653 |
// Age suitability (OR) |
| 654 |
if (!empty($filters['age_suitability']) && is_array($filters['age_suitability'])) { |
| 655 |
$ageParts = []; |
| 656 |
foreach (array_unique($filters['age_suitability']) as $age) { |
| 657 |
if (!is_string($age)) { |
| 658 |
continue; |
| 659 |
} |
| 660 |
switch ($age) { |
| 661 |
case 'family-friendly': |
| 662 |
$ageParts[] = '(t.age_min IS NULL OR t.age_min <= 5)'; |
| 663 |
break; |
| 664 |
case 'kids-friendly': |
| 665 |
$ageParts[] = '(t.age_min IS NULL OR t.age_min <= 12)'; |
| 666 |
break; |
| 667 |
case 'senior-friendly': |
| 668 |
$ageParts[] = '(t.age_max IS NULL OR t.age_max >= 65)'; |
| 669 |
break; |
| 670 |
case 'adults-only': |
| 671 |
$ageParts[] = 't.age_min >= 18'; |
| 672 |
break; |
| 673 |
} |
| 674 |
} |
| 675 |
if ($ageParts !== []) { |
| 676 |
$wheres[] = '(' . implode(' OR ', $ageParts) . ')'; |
| 677 |
} |
| 678 |
} |
| 679 |
|
| 680 |
// Accommodation type |
| 681 |
if (!empty($filters['accommodation']) && is_array($filters['accommodation']) && $this->tripTableHasColumn('accommodation_type')) { |
| 682 |
$acc = array_values(array_filter(array_map('sanitize_text_field', $filters['accommodation']))); |
| 683 |
if ($acc !== []) { |
| 684 |
$placeholders = implode(',', array_fill(0, count($acc), '%s')); |
| 685 |
$wheres[] = "t.accommodation_type IN ({$placeholders})"; |
| 686 |
$params = array_merge($params, $acc); |
| 687 |
} |
| 688 |
} |
| 689 |
|
| 690 |
// Included services (loose match on JSON text) |
| 691 |
if (!empty($filters['included_services']) && is_array($filters['included_services'])) { |
| 692 |
foreach (array_unique($filters['included_services']) as $svc) { |
| 693 |
if (!is_string($svc) || $svc === '') { |
| 694 |
continue; |
| 695 |
} |
| 696 |
$like = '%' . $wpdb->esc_like($svc) . '%'; |
| 697 |
$wheres[] = 't.included_items LIKE %s'; |
| 698 |
$params[] = $like; |
| 699 |
} |
| 700 |
} |
| 701 |
|
| 702 |
// Dynamic attribute filters (only attributes allowed in public filters — admin setting "Show in Filters") |
| 703 |
if (!empty($filters['attribute_filters']) && is_array($filters['attribute_filters'])) { |
| 704 |
$allowedFilterAttrIds = (new AttributeRepository())->getFilterableAttributeIds(); |
| 705 |
foreach ($filters['attribute_filters'] as $attrId => $rawVal) { |
| 706 |
$attrId = (int) $attrId; |
| 707 |
if ($attrId <= 0) { |
| 708 |
continue; |
| 709 |
} |
| 710 |
if (!in_array($attrId, $allowedFilterAttrIds, true)) { |
| 711 |
continue; |
| 712 |
} |
| 713 |
if (is_array($rawVal) && (isset($rawVal['min']) || isset($rawVal['max']))) { |
| 714 |
if (isset($rawVal['min']) && is_numeric($rawVal['min'])) { |
| 715 |
$wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_n WHERE ta_n.trip_id = t.id AND ta_n.classification_type = 'attribute' AND ta_n.classification_id = {$attrId} AND ta_n.is_active = 1 AND CAST(JSON_UNQUOTE(JSON_EXTRACT(ta_n.metadata, '$.value')) AS DECIMAL(14,4)) >= %f)"; |
| 716 |
$params[] = (float) $rawVal['min']; |
| 717 |
} |
| 718 |
if (isset($rawVal['max']) && is_numeric($rawVal['max'])) { |
| 719 |
$wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_x WHERE ta_x.trip_id = t.id AND ta_x.classification_type = 'attribute' AND ta_x.classification_id = {$attrId} AND ta_x.is_active = 1 AND CAST(JSON_UNQUOTE(JSON_EXTRACT(ta_x.metadata, '$.value')) AS DECIMAL(14,4)) <= %f)"; |
| 720 |
$params[] = (float) $rawVal['max']; |
| 721 |
} |
| 722 |
} elseif (is_array($rawVal) && $rawVal !== []) { |
| 723 |
$vals = array_values(array_filter(array_map('sanitize_text_field', $rawVal))); |
| 724 |
if ($vals !== []) { |
| 725 |
$placeholders = implode(',', array_fill(0, count($vals), '%s')); |
| 726 |
$wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_m WHERE ta_m.trip_id = t.id AND ta_m.classification_type = 'attribute' AND ta_m.classification_id = {$attrId} AND ta_m.is_active = 1 AND JSON_UNQUOTE(JSON_EXTRACT(ta_m.metadata, '$.value')) IN ({$placeholders}))"; |
| 727 |
$params = array_merge($params, $vals); |
| 728 |
} |
| 729 |
} else { |
| 730 |
$v = sanitize_text_field((string) $rawVal); |
| 731 |
if ($v !== '') { |
| 732 |
$wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_s WHERE ta_s.trip_id = t.id AND ta_s.classification_type = 'attribute' AND ta_s.classification_id = {$attrId} AND ta_s.is_active = 1 AND JSON_UNQUOTE(JSON_EXTRACT(ta_s.metadata, '$.value')) = %s)"; |
| 733 |
$params[] = $v; |
| 734 |
} |
| 735 |
} |
| 736 |
} |
| 737 |
} |
| 738 |
|
| 739 |
// Price range filter. |
| 740 |
// |
| 741 |
// The previous version compared a single legacy "effective" |
| 742 |
// price (discounted → sale → original column) against the |
| 743 |
// range. That excluded traveler-based trips whose real |
| 744 |
// displayed price lives in the `price_types` JSON — so eg. a |
| 745 |
// trip costing €3755 (Adult category) never appeared in search |
| 746 |
// when the user set max=€3755, because its legacy |
| 747 |
// original_price was €0 / stale. |
| 748 |
// |
| 749 |
// New approach: match a trip if EITHER the legacy effective |
| 750 |
// price OR ANY per-category price in the JSON falls in the |
| 751 |
// requested range. Numeric values in the `price_types` JSON |
| 752 |
// are extracted with a regex on the raw text — works in all |
| 753 |
// MySQL 5.7+ builds without needing JSON_VALUE / JSON_TABLE |
| 754 |
// (which are inconsistent across MariaDB / older MySQL). |
| 755 |
$effPrice = $this->sqlTripEffectiveListPrice(); |
| 756 |
$priceMin = !empty($filters['price_min']) && $filters['price_min'] > 0 ? (float) $filters['price_min'] : null; |
| 757 |
$priceMax = !empty($filters['price_max']) && $filters['price_max'] > 0 ? (float) $filters['price_max'] : null; |
| 758 |
|
| 759 |
if ($priceMin !== null || $priceMax !== null) { |
| 760 |
$legacyCond = []; |
| 761 |
if ($priceMin !== null) { $legacyCond[] = "{$effPrice} >= %f"; $params[] = $priceMin; } |
| 762 |
if ($priceMax !== null) { $legacyCond[] = "{$effPrice} <= %f"; $params[] = $priceMax; } |
| 763 |
$legacyClause = implode(' AND ', $legacyCond); |
| 764 |
|
| 765 |
// For per-category pricing, find ANY price in `price_types` |
| 766 |
// JSON that falls in the requested range. This subquery |
| 767 |
// creates an ad-hoc number sequence (n=0..49 covers up to |
| 768 |
// 50 categories — far more than any real trip uses) and |
| 769 |
// uses JSON_EXTRACT to fetch each entry's effective price. |
| 770 |
$numbers = "(SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 UNION ALL SELECT 19)"; |
| 771 |
// Each category's effective price = first non-null of |
| 772 |
// discounted_price → sale_price → original_price → price. |
| 773 |
$categoryEff = "COALESCE(" |
| 774 |
. "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].discounted_price'))) AS DECIMAL(10,2)), 0)," |
| 775 |
. "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].sale_price'))) AS DECIMAL(10,2)), 0)," |
| 776 |
. "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].original_price'))) AS DECIMAL(10,2)), 0)," |
| 777 |
. "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].price'))) AS DECIMAL(10,2)), 0)" |
| 778 |
. ")"; |
| 779 |
$catCond = []; |
| 780 |
if ($priceMin !== null) { $catCond[] = "{$categoryEff} >= %f"; $params[] = $priceMin; } |
| 781 |
if ($priceMax !== null) { $catCond[] = "{$categoryEff} <= %f"; $params[] = $priceMax; } |
| 782 |
$catClause = implode(' AND ', $catCond); |
| 783 |
|
| 784 |
// Guard the per-category branch against malformed JSON. |
| 785 |
// JSON_EXTRACT throws on invalid JSON, and `price_types` |
| 786 |
// can be NULL, '', '[]' or a stale string on legacy rows. |
| 787 |
// JSON_VALID() + IFNULL() lets us bail cleanly for those. |
| 788 |
$perCategoryExists = "(" |
| 789 |
. "t.price_types IS NOT NULL" |
| 790 |
. " AND t.price_types <> ''" |
| 791 |
. " AND JSON_VALID(t.price_types) = 1" |
| 792 |
. " AND EXISTS (" |
| 793 |
. "SELECT 1 FROM {$numbers} _n" |
| 794 |
. " WHERE JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, ']')) IS NOT NULL" |
| 795 |
. " AND {$catClause}" |
| 796 |
. ")" |
| 797 |
. ")"; |
| 798 |
|
| 799 |
// Match if EITHER condition passes — a single legacy-priced |
| 800 |
// trip OR a per-category trip with any matching tier. |
| 801 |
$wheres[] = "(({$legacyClause}) OR {$perCategoryExists})"; |
| 802 |
} |
| 803 |
|
| 804 |
// Duration filter |
| 805 |
if (!empty($filters['duration_min']) && $filters['duration_min'] > 0) { |
| 806 |
$wheres[] = "CAST(t.duration_days AS UNSIGNED) >= %d"; |
| 807 |
$params[] = $filters['duration_min']; |
| 808 |
} |
| 809 |
|
| 810 |
if (!empty($filters['duration_max']) && $filters['duration_max'] > 0) { |
| 811 |
$wheres[] = "CAST(t.duration_days AS UNSIGNED) <= %d"; |
| 812 |
$params[] = $filters['duration_max']; |
| 813 |
} |
| 814 |
|
| 815 |
// Rating filter |
| 816 |
if (!empty($filters['rating_min']) && $filters['rating_min'] > 0) { |
| 817 |
$having_clauses[] = "AVG(r.rating) >= %f"; |
| 818 |
$rating_params[] = $filters['rating_min']; |
| 819 |
} |
| 820 |
|
| 821 |
// Difficulty filter |
| 822 |
if (!empty($filters['difficulty']) && is_array($filters['difficulty'])) { |
| 823 |
$joins[] = "LEFT JOIN {$classificationsTable} dl ON dl.id = t.difficulty_level"; |
| 824 |
$difficulty_placeholders = implode(',', array_fill(0, count($filters['difficulty']), '%d')); |
| 825 |
$wheres[] = "dl.type = %s AND dl.id IN ({$difficulty_placeholders})"; |
| 826 |
$params[] = ClassificationTypes::DIFFICULTY; |
| 827 |
$params = array_merge($params, $filters['difficulty']); |
| 828 |
} |
| 829 |
|
| 830 |
// Build SQL components |
| 831 |
$join_sql = !empty($joins) ? implode(' ', $joins) : ''; |
| 832 |
$where_sql = 'WHERE ' . implode(' AND ', $wheres); |
| 833 |
$having_sql = !empty($having_clauses) ? ('HAVING ' . implode(' AND ', $having_clauses)) : ''; |
| 834 |
|
| 835 |
// Calculate pagination |
| 836 |
$offset = ($page - 1) * $perPage; |
| 837 |
|
| 838 |
// Count total results |
| 839 |
$count_params = array_merge($params, $rating_params); |
| 840 |
if (!empty($having_clauses)) { |
| 841 |
$count_sql = "SELECT COUNT(*) FROM ( |
| 842 |
SELECT t.id |
| 843 |
FROM {$trip_table} t |
| 844 |
{$join_sql} |
| 845 |
LEFT JOIN {$reviews_table} r ON r.trip_id = t.id AND r.status = 'approved' |
| 846 |
{$where_sql} |
| 847 |
GROUP BY t.id |
| 848 |
{$having_sql} |
| 849 |
) as filtered_trips"; |
| 850 |
} else { |
| 851 |
$count_sql = "SELECT COUNT(DISTINCT t.id) |
| 852 |
FROM {$trip_table} t |
| 853 |
{$join_sql} |
| 854 |
{$where_sql}"; |
| 855 |
$count_params = $params; |
| 856 |
} |
| 857 |
|
| 858 |
$prepared_count_query = empty($count_params) ? $count_sql : |
| 859 |
$wpdb->prepare($count_sql, ...$count_params); |
| 860 |
|
| 861 |
$total = (int) $wpdb->get_var($prepared_count_query); |
| 862 |
$total_pages = $total > 0 ? (int) ceil($total / $perPage) : 1; |
| 863 |
|
| 864 |
// Build ORDER BY clause |
| 865 |
$order_clause = $this->buildTripOrderClause($filters['sort'] ?? ''); |
| 866 |
|
| 867 |
// Check for difficulty table and build difficulty JOIN |
| 868 |
$difficulty_join = $this->buildDifficultyJoin(); |
| 869 |
$difficulty_select = $difficulty_join ? ', diff.name AS difficulty_name, diff.icon AS difficulty_icon' : ''; |
| 870 |
|
| 871 |
|
| 872 |
// Main query |
| 873 |
$query_sql = "SELECT t.*, |
| 874 |
AVG(r.rating) AS average_rating, |
| 875 |
COUNT(DISTINCT r.id) AS review_count, |
| 876 |
COUNT(DISTINCT b.id) AS booking_count{$difficulty_select} |
| 877 |
FROM {$trip_table} t |
| 878 |
{$join_sql} |
| 879 |
LEFT JOIN {$reviews_table} r ON r.trip_id = t.id AND r.status = 'approved' |
| 880 |
LEFT JOIN {$bookings_table} b ON b.trip_id = t.id AND b.status IN ('confirmed', 'completed', 'paid') |
| 881 |
{$difficulty_join} |
| 882 |
{$where_sql} |
| 883 |
GROUP BY t.id |
| 884 |
{$having_sql} |
| 885 |
{$order_clause} |
| 886 |
LIMIT %d OFFSET %d"; |
| 887 |
|
| 888 |
$main_query_params = array_merge($params, $rating_params, [$perPage, $offset]); |
| 889 |
$prepared_query = $wpdb->prepare($query_sql, ...$main_query_params); |
| 890 |
|
| 891 |
$trips = $wpdb->get_results($prepared_query) ?: []; |
| 892 |
|
| 893 |
// Process trips in batch for better performance (eliminates N+1 queries) |
| 894 |
if (!empty($trips)) { |
| 895 |
$this->batchEnrichTrips($trips); |
| 896 |
} |
| 897 |
|
| 898 |
return [ |
| 899 |
'trips' => $trips, |
| 900 |
'total' => $total, |
| 901 |
'pages' => $total_pages, |
| 902 |
'page' => $page, |
| 903 |
'per_page' => $perPage |
| 904 |
]; |
| 905 |
}, Cache::DURATION_QUERY_RESULT); // Cache for 10 minutes |
| 906 |
} |
| 907 |
|
| 908 |
/** |
| 909 |
* Build ORDER BY clause for trip-specific sorting |
| 910 |
*/ |
| 911 |
protected function buildTripOrderClause(string $sort): string |
| 912 |
{ |
| 913 |
$effPrice = $this->sqlTripEffectiveListPrice(); |
| 914 |
switch ($sort) { |
| 915 |
case 'most_popular': |
| 916 |
return "ORDER BY booking_count DESC, t.created_at DESC"; |
| 917 |
case 'price_low': |
| 918 |
return "ORDER BY {$effPrice} ASC"; |
| 919 |
case 'price_high': |
| 920 |
return "ORDER BY {$effPrice} DESC"; |
| 921 |
case 'rating_high': |
| 922 |
return "ORDER BY average_rating DESC"; |
| 923 |
case 'date_asc': |
| 924 |
return "ORDER BY t.created_at ASC"; |
| 925 |
case 'duration_short': |
| 926 |
return "ORDER BY CAST(t.duration_days AS UNSIGNED) ASC, CAST(t.duration_nights AS UNSIGNED) ASC"; |
| 927 |
case 'duration_long': |
| 928 |
return "ORDER BY CAST(t.duration_days AS UNSIGNED) DESC, CAST(t.duration_nights AS UNSIGNED) DESC"; |
| 929 |
default: |
| 930 |
return "ORDER BY t.created_at DESC"; |
| 931 |
} |
| 932 |
} |
| 933 |
|
| 934 |
/** |
| 935 |
* Build difficulty JOIN clause if table exists |
| 936 |
*/ |
| 937 |
protected function buildDifficultyJoin(): string |
| 938 |
{ |
| 939 |
global $wpdb; |
| 940 |
|
| 941 |
// Use ClassificationsTable for difficulty levels (type = 'difficulty') |
| 942 |
$difficulty_table = ClassificationsTable::getTableName(); |
| 943 |
|
| 944 |
// Use advanced caching system instead of simple array cache |
| 945 |
$tableExists = Cache::tableExists($difficulty_table, function() use ($wpdb, $difficulty_table) { |
| 946 |
return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$difficulty_table}'"); |
| 947 |
}); |
| 948 |
|
| 949 |
if ($tableExists) { |
| 950 |
// Map yatra_trips.difficulty_level (bigint ID) to yatra_classifications table |
| 951 |
// The difficulty_level field contains the classification ID |
| 952 |
return sprintf( |
| 953 |
"LEFT JOIN {$difficulty_table} diff ON diff.id = t.difficulty_level AND diff.type = '%s'", |
| 954 |
ClassificationTypes::DIFFICULTY |
| 955 |
); |
| 956 |
} |
| 957 |
return ''; |
| 958 |
} |
| 959 |
|
| 960 |
/** |
| 961 |
* Batch enrich multiple trips to eliminate N+1 queries |
| 962 |
* |
| 963 |
* @param array $trips Array of trip objects |
| 964 |
*/ |
| 965 |
protected function batchEnrichTrips(array $trips): void |
| 966 |
{ |
| 967 |
if (empty($trips)) { |
| 968 |
return; |
| 969 |
} |
| 970 |
|
| 971 |
global $wpdb; |
| 972 |
$trip_ids = array_column($trips, 'id'); |
| 973 |
$trip_ids_placeholder = implode(',', array_fill(0, count($trip_ids), '%d')); |
| 974 |
|
| 975 |
// Batch load destinations |
| 976 |
$tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); |
| 977 |
$classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); |
| 978 |
$destinations_data = []; |
| 979 |
$destTableExists = Cache::tableExists($tripClassificationsTable, function() use ($wpdb, $tripClassificationsTable) { |
| 980 |
return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$tripClassificationsTable}'"); |
| 981 |
}); |
| 982 |
|
| 983 |
if ($destTableExists) { |
| 984 |
$destinations_raw = $wpdb->get_results($wpdb->prepare( |
| 985 |
"SELECT tc.trip_id, c.* FROM {$classificationsTable} c |
| 986 |
INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id |
| 987 |
WHERE tc.trip_id IN ({$trip_ids_placeholder}) AND c.type = %s AND c.status = 'publish' |
| 988 |
ORDER BY tc.trip_id, tc.sort_order ASC, c.name ASC", |
| 989 |
ClassificationTypes::DESTINATION, ...$trip_ids |
| 990 |
)); |
| 991 |
|
| 992 |
foreach ($destinations_raw as $dest) { |
| 993 |
$trip_id = $dest->trip_id; |
| 994 |
unset($dest->trip_id); |
| 995 |
$destinations_data[$trip_id][] = $dest; |
| 996 |
} |
| 997 |
} |
| 998 |
|
| 999 |
// Batch load activities |
| 1000 |
$activities_data = []; |
| 1001 |
$actTableExists = Cache::tableExists($tripClassificationsTable, function() use ($wpdb, $tripClassificationsTable) { |
| 1002 |
return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$tripClassificationsTable}'"); |
| 1003 |
}); |
| 1004 |
|
| 1005 |
if ($actTableExists) { |
| 1006 |
$activities_raw = $wpdb->get_results($wpdb->prepare( |
| 1007 |
"SELECT tc.trip_id, c.* FROM {$classificationsTable} c |
| 1008 |
INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id |
| 1009 |
WHERE tc.trip_id IN ({$trip_ids_placeholder}) AND c.type = %s AND c.status = 'publish' |
| 1010 |
ORDER BY tc.trip_id, tc.sort_order ASC, c.name ASC", |
| 1011 |
ClassificationTypes::ACTIVITY, ...$trip_ids |
| 1012 |
)); |
| 1013 |
|
| 1014 |
foreach ($activities_raw as $act) { |
| 1015 |
$trip_id = $act->trip_id; |
| 1016 |
unset($act->trip_id); |
| 1017 |
$activities_data[$trip_id][] = $act; |
| 1018 |
} |
| 1019 |
} |
| 1020 |
|
| 1021 |
// Batch load categories |
| 1022 |
// Using TripClassificationsTable for category relationships |
| 1023 |
$cat_rel_table = $tripClassificationsTable; |
| 1024 |
$categories_data = []; |
| 1025 |
$catTableExists = Cache::tableExists($cat_rel_table, function() use ($wpdb, $cat_rel_table) { |
| 1026 |
return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$cat_rel_table}'"); |
| 1027 |
}); |
| 1028 |
|
| 1029 |
if ($catTableExists) { |
| 1030 |
$categories_raw = $wpdb->get_results($wpdb->prepare( |
| 1031 |
"SELECT tc.trip_id, c.* FROM {$classificationsTable} c |
| 1032 |
INNER JOIN {$cat_rel_table} tc ON c.id = tc.classification_id |
| 1033 |
WHERE tc.trip_id IN ({$trip_ids_placeholder}) |
| 1034 |
AND c.type = %s AND c.status = 'publish' |
| 1035 |
ORDER BY tc.trip_id, c.name ASC", |
| 1036 |
ClassificationTypes::CATEGORY, ...$trip_ids |
| 1037 |
)); |
| 1038 |
|
| 1039 |
foreach ($categories_raw as $cat) { |
| 1040 |
$trip_id = $cat->trip_id; |
| 1041 |
unset($cat->trip_id); |
| 1042 |
$categories_data[$trip_id][] = $cat; |
| 1043 |
} |
| 1044 |
} |
| 1045 |
|
| 1046 |
$price_types_by_trip = $this->batchLoadPriceTypesByTripIds($trip_ids); |
| 1047 |
|
| 1048 |
// Apply enriched data to each trip — use centralized TripPricingService |
| 1049 |
foreach ($trips as $trip) { |
| 1050 |
$trip->effective_price_min = \Yatra\Services\TripPricingService::getEffectivePrice($trip); |
| 1051 |
|
| 1052 |
// Set relationships |
| 1053 |
$trip->destinations = $destinations_data[$trip->id] ?? []; |
| 1054 |
$trip->activities = $activities_data[$trip->id] ?? []; |
| 1055 |
$trip->categories = $categories_data[$trip->id] ?? []; |
| 1056 |
$trip->price_types = $price_types_by_trip[$trip->id] ?? []; |
| 1057 |
} |
| 1058 |
} |
| 1059 |
|
| 1060 |
/** |
| 1061 |
* Enrich trip data with additional computed fields (legacy single-trip method) |
| 1062 |
*/ |
| 1063 |
protected function enrichTripData(\stdClass $trip): void |
| 1064 |
{ |
| 1065 |
global $wpdb; |
| 1066 |
|
| 1067 |
// Compute effective pricing via centralized TripPricingService |
| 1068 |
$trip->effective_price_min = \Yatra\Services\TripPricingService::getEffectivePrice($trip); |
| 1069 |
} |
| 1070 |
|
| 1071 |
/** |
| 1072 |
* Load trip relationships (destinations, activities, categories) |
| 1073 |
*/ |
| 1074 |
protected function loadTripRelationships(\stdClass $trip): void |
| 1075 |
{ |
| 1076 |
global $wpdb; |
| 1077 |
|
| 1078 |
// Use new Classification tables |
| 1079 |
$tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); |
| 1080 |
$classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); |
| 1081 |
|
| 1082 |
// Load destinations |
| 1083 |
$destinations = $wpdb->get_results($wpdb->prepare( |
| 1084 |
"SELECT c.* FROM {$classificationsTable} c |
| 1085 |
INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id |
| 1086 |
WHERE tc.trip_id = %d AND c.type = %s AND c.status = 'publish' |
| 1087 |
ORDER BY tc.sort_order ASC, c.name ASC", |
| 1088 |
$trip->id, ClassificationTypes::DESTINATION |
| 1089 |
)); |
| 1090 |
$trip->destinations = $destinations ?: []; |
| 1091 |
|
| 1092 |
// Load activities |
| 1093 |
$activities = $wpdb->get_results($wpdb->prepare( |
| 1094 |
"SELECT c.* FROM {$classificationsTable} c |
| 1095 |
INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id |
| 1096 |
WHERE tc.trip_id = %d AND c.type = %s AND c.status = 'publish' |
| 1097 |
ORDER BY tc.sort_order ASC, c.name ASC", |
| 1098 |
$trip->id, ClassificationTypes::ACTIVITY |
| 1099 |
)); |
| 1100 |
$trip->activities = $activities ?: []; |
| 1101 |
|
| 1102 |
// Load categories |
| 1103 |
$categories = $wpdb->get_results($wpdb->prepare( |
| 1104 |
"SELECT c.* FROM {$classificationsTable} c |
| 1105 |
INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id |
| 1106 |
WHERE tc.trip_id = %d AND c.type = %s AND c.status = 'publish' |
| 1107 |
ORDER BY tc.sort_order ASC, c.name ASC", |
| 1108 |
$trip->id, ClassificationTypes::CATEGORY |
| 1109 |
)); |
| 1110 |
$trip->categories = $categories ?: []; |
| 1111 |
|
| 1112 |
// Load price types for traveler-based pricing (from trips table JSON) |
| 1113 |
$trip->price_types = $this->getPriceTypes((int) $trip->id); |
| 1114 |
} |
| 1115 |
|
| 1116 |
/** |
| 1117 |
* Find by slug |
| 1118 |
*/ |
| 1119 |
public function findBySlug(string $slug): ?\stdClass |
| 1120 |
{ |
| 1121 |
$table = esc_sql($this->table); |
| 1122 |
$query = "SELECT * FROM `{$table}` WHERE slug = %s"; |
| 1123 |
|
| 1124 |
if ($this->hasSoftDelete()) { |
| 1125 |
$query .= " AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')"; |
| 1126 |
} |
| 1127 |
|
| 1128 |
$result = $this->wpdb->get_row( |
| 1129 |
$this->wpdb->prepare($query, $slug) |
| 1130 |
); |
| 1131 |
|
| 1132 |
return $result ?: null; |
| 1133 |
} |
| 1134 |
|
| 1135 |
/** |
| 1136 |
* Find by ID with relationships |
| 1137 |
*/ |
| 1138 |
public function findWithRelations(int $id, bool $includeDeleted = false): ?\stdClass |
| 1139 |
{ |
| 1140 |
$trip = $this->find($id, $includeDeleted); |
| 1141 |
|
| 1142 |
if (!$trip) { |
| 1143 |
return null; |
| 1144 |
} |
| 1145 |
|
| 1146 |
// Load destinations |
| 1147 |
$trip->destinations = $this->getDestinations($id); |
| 1148 |
|
| 1149 |
// Load activities |
| 1150 |
$trip->activities = $this->getActivities($id); |
| 1151 |
|
| 1152 |
// Load trip categories |
| 1153 |
$trip->trip_category = $this->getTripCategories($id); |
| 1154 |
|
| 1155 |
// Load price types |
| 1156 |
$trip->price_types = $this->getPriceTypes($id); |
| 1157 |
|
| 1158 |
// Load gallery images |
| 1159 |
$trip->gallery_images = $this->getGalleryImages($id); |
| 1160 |
|
| 1161 |
// Load downloads |
| 1162 |
$trip->downloadable_items = $this->getDownloads($id); |
| 1163 |
|
| 1164 |
// Load highlights |
| 1165 |
$trip->highlights = $this->getHighlights($id); |
| 1166 |
|
| 1167 |
// Load landmarks |
| 1168 |
$trip->landmarks = $this->getLandmarks($id); |
| 1169 |
|
| 1170 |
// Load FAQs |
| 1171 |
$trip->faqs = $this->getFaqs($id); |
| 1172 |
|
| 1173 |
// Load availability dates |
| 1174 |
$trip->availability_dates = $this->getAvailabilityDates($id); |
| 1175 |
|
| 1176 |
// Load itinerary days with entries |
| 1177 |
$trip->itinerary_days = $this->getItineraryDays($id); |
| 1178 |
|
| 1179 |
do_action('yatra_trip_loaded_with_relations', $trip); |
| 1180 |
|
| 1181 |
return $trip; |
| 1182 |
} |
| 1183 |
|
| 1184 |
/** |
| 1185 |
* Get destinations for a trip |
| 1186 |
*/ |
| 1187 |
public function getDestinations(int $tripId): array |
| 1188 |
{ |
| 1189 |
global $wpdb; |
| 1190 |
|
| 1191 |
// Use new Classification tables |
| 1192 |
$tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); |
| 1193 |
$classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); |
| 1194 |
|
| 1195 |
return $wpdb->get_results( |
| 1196 |
$wpdb->prepare( |
| 1197 |
"SELECT tc.classification_id as id, tc.sort_order, tc.relationship_type, tc.is_featured, c.name, c.slug |
| 1198 |
FROM {$tripClassificationsTable} tc |
| 1199 |
LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id |
| 1200 |
WHERE tc.trip_id = %d AND c.type = %s |
| 1201 |
ORDER BY tc.sort_order ASC, tc.id ASC", |
| 1202 |
$tripId, ClassificationTypes::DESTINATION |
| 1203 |
) |
| 1204 |
) ?: []; |
| 1205 |
} |
| 1206 |
|
| 1207 |
/** |
| 1208 |
* Get activities for a trip |
| 1209 |
*/ |
| 1210 |
public function getActivities(int $tripId): array |
| 1211 |
{ |
| 1212 |
global $wpdb; |
| 1213 |
|
| 1214 |
// Use new Classification tables |
| 1215 |
$tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); |
| 1216 |
$classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); |
| 1217 |
|
| 1218 |
$results = $wpdb->get_results( |
| 1219 |
$wpdb->prepare( |
| 1220 |
"SELECT tc.*, c.name as activity_name, c.slug as activity_slug |
| 1221 |
FROM {$tripClassificationsTable} tc |
| 1222 |
LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id |
| 1223 |
WHERE tc.trip_id = %d AND tc.classification_type = %s |
| 1224 |
ORDER BY tc.sort_order ASC, tc.id ASC", |
| 1225 |
$tripId, ClassificationTypes::ACTIVITY |
| 1226 |
) |
| 1227 |
) ?: []; |
| 1228 |
|
| 1229 |
// Filter out relationships where the activity doesn't exist in Classifications table |
| 1230 |
$validResults = array_filter($results, function($result) { |
| 1231 |
return !empty($result->activity_name) && !empty($result->activity_slug); |
| 1232 |
}); |
| 1233 |
|
| 1234 |
return array_values($validResults); |
| 1235 |
} |
| 1236 |
|
| 1237 |
/** |
| 1238 |
* Get trip categories for a trip |
| 1239 |
*/ |
| 1240 |
public function getTripCategories(int $tripId): array |
| 1241 |
{ |
| 1242 |
global $wpdb; |
| 1243 |
|
| 1244 |
// Use TripClassificationsTable for trip-category relationships |
| 1245 |
$tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); |
| 1246 |
$classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); |
| 1247 |
|
| 1248 |
$sql = $wpdb->prepare( |
| 1249 |
"SELECT tc.*, c.name as category_name, c.slug as category_slug |
| 1250 |
FROM {$tripClassificationsTable} tc |
| 1251 |
LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id |
| 1252 |
WHERE tc.trip_id = %d AND tc.classification_type = %s |
| 1253 |
ORDER BY tc.sort_order ASC, tc.id ASC", |
| 1254 |
$tripId, ClassificationTypes::CATEGORY |
| 1255 |
); |
| 1256 |
|
| 1257 |
$results = $wpdb->get_results($sql) ?: []; |
| 1258 |
|
| 1259 |
// Filter out relationships where the category doesn't exist in Classifications table |
| 1260 |
$validResults = array_filter($results, function($result) { |
| 1261 |
return !empty($result->category_name) && !empty($result->category_slug); |
| 1262 |
}); |
| 1263 |
|
| 1264 |
return array_values($validResults); |
| 1265 |
} |
| 1266 |
|
| 1267 |
/** |
| 1268 |
* Normalize decoded price_types JSON (trips.price_types column). |
| 1269 |
* |
| 1270 |
* @param mixed $json Raw column value or already-decoded array |
| 1271 |
* @return array<int, array<string, mixed>> |
| 1272 |
*/ |
| 1273 |
protected function parsePriceTypesJson($json): array |
| 1274 |
{ |
| 1275 |
if ($json === null || $json === '') { |
| 1276 |
return []; |
| 1277 |
} |
| 1278 |
|
| 1279 |
$decoded = is_string($json) ? json_decode($json, true) : $json; |
| 1280 |
if (!is_array($decoded)) { |
| 1281 |
return []; |
| 1282 |
} |
| 1283 |
|
| 1284 |
return array_values(array_filter(array_map(function ($pt) { |
| 1285 |
if (!is_array($pt)) { |
| 1286 |
return null; |
| 1287 |
} |
| 1288 |
$normalized = [ |
| 1289 |
'category_id' => isset($pt['category_id']) ? (int) $pt['category_id'] : null, |
| 1290 |
'original_price' => isset($pt['original_price']) ? (float) $pt['original_price'] : null, |
| 1291 |
'discounted_price' => isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : null, |
| 1292 |
'sale_price' => isset($pt['sale_price']) ? (float) $pt['sale_price'] : null, |
| 1293 |
'label' => $pt['label'] ?? ($pt['title'] ?? null), |
| 1294 |
'pricing_mode' => $pt['pricing_mode'] ?? 'per_person', |
| 1295 |
'is_default' => !empty($pt['is_default']), |
| 1296 |
]; |
| 1297 |
if (isset($pt['category_label'])) { |
| 1298 |
$normalized['category_label'] = $pt['category_label']; |
| 1299 |
} |
| 1300 |
if (isset($pt['description'])) { |
| 1301 |
$normalized['description'] = $pt['description']; |
| 1302 |
} |
| 1303 |
|
| 1304 |
return $normalized; |
| 1305 |
}, $decoded))); |
| 1306 |
} |
| 1307 |
|
| 1308 |
/** |
| 1309 |
* Batch-load price_types for many trips (single query). |
| 1310 |
* |
| 1311 |
* @param int[] $trip_ids |
| 1312 |
* @return array<int, array<int, array<string, mixed>>> |
| 1313 |
*/ |
| 1314 |
protected function batchLoadPriceTypesByTripIds(array $trip_ids): array |
| 1315 |
{ |
| 1316 |
$trip_ids = array_values(array_unique(array_map('intval', array_filter($trip_ids)))); |
| 1317 |
if ($trip_ids === []) { |
| 1318 |
return []; |
| 1319 |
} |
| 1320 |
|
| 1321 |
$table = esc_sql($this->table); |
| 1322 |
$placeholders = implode(',', array_fill(0, count($trip_ids), '%d')); |
| 1323 |
$sql = "SELECT id, price_types FROM `{$table}` WHERE id IN ({$placeholders})"; |
| 1324 |
$rows = $this->wpdb->get_results($this->wpdb->prepare($sql, ...$trip_ids)) ?: []; |
| 1325 |
|
| 1326 |
$out = []; |
| 1327 |
foreach ($rows as $row) { |
| 1328 |
$out[(int) $row->id] = $this->parsePriceTypesJson($row->price_types ?? null); |
| 1329 |
} |
| 1330 |
|
| 1331 |
return $out; |
| 1332 |
} |
| 1333 |
|
| 1334 |
/** |
| 1335 |
* Get price types for a trip |
| 1336 |
*/ |
| 1337 |
public function getPriceTypes(int $tripId): array |
| 1338 |
{ |
| 1339 |
$table = esc_sql($this->table); |
| 1340 |
$json = $this->wpdb->get_var( |
| 1341 |
$this->wpdb->prepare("SELECT price_types FROM `{$table}` WHERE id = %d", $tripId) |
| 1342 |
); |
| 1343 |
|
| 1344 |
return $this->parsePriceTypesJson($json); |
| 1345 |
} |
| 1346 |
|
| 1347 |
/** |
| 1348 |
* Get gallery images for a trip |
| 1349 |
*/ |
| 1350 |
public function getGalleryImages(int $tripId): array |
| 1351 |
{ |
| 1352 |
global $wpdb; |
| 1353 |
|
| 1354 |
// Use TripContentTable for gallery images |
| 1355 |
$tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); |
| 1356 |
|
| 1357 |
$rows = $wpdb->get_results( |
| 1358 |
$wpdb->prepare( |
| 1359 |
"SELECT * FROM {$tripContentTable} |
| 1360 |
WHERE trip_id = %d AND content_type = 'image' |
| 1361 |
ORDER BY sort_order ASC, id ASC", |
| 1362 |
$tripId |
| 1363 |
) |
| 1364 |
) ?: []; |
| 1365 |
|
| 1366 |
// Normalize to the shape the edit form expects |
| 1367 |
return array_map(function ($row) { |
| 1368 |
$metadata = []; |
| 1369 |
if (!empty($row->metadata)) { |
| 1370 |
$decoded = json_decode($row->metadata, true); |
| 1371 |
if (is_array($decoded)) { |
| 1372 |
$metadata = $decoded; |
| 1373 |
} |
| 1374 |
} |
| 1375 |
|
| 1376 |
$imageId = $metadata['image_id'] ?? ($row->image_id ?? null); |
| 1377 |
$altText = $metadata['alt_text'] ?? null; |
| 1378 |
$caption = $metadata['caption'] ?? null; |
| 1379 |
$dimensions = $metadata['dimensions'] ?? null; |
| 1380 |
|
| 1381 |
return (object) [ |
| 1382 |
'id' => $imageId ? (int) $imageId : 0, |
| 1383 |
'image_id' => $imageId ? (int) $imageId : 0, |
| 1384 |
'url' => $row->content_url ?? '', |
| 1385 |
'image_url' => $row->content_url ?? '', |
| 1386 |
'thumbnail_url' => $row->thumbnail_url ?? '', |
| 1387 |
'alt_text' => $altText ?? '', |
| 1388 |
'caption' => $caption ?? '', |
| 1389 |
'width' => is_array($dimensions) && isset($dimensions['width']) ? (int) $dimensions['width'] : null, |
| 1390 |
'height' => is_array($dimensions) && isset($dimensions['height']) ? (int) $dimensions['height'] : null, |
| 1391 |
'is_featured' => isset($row->is_featured) ? (bool) $row->is_featured : false, |
| 1392 |
'order' => isset($row->sort_order) ? (int) $row->sort_order : 0, |
| 1393 |
]; |
| 1394 |
}, $rows); |
| 1395 |
} |
| 1396 |
|
| 1397 |
/** |
| 1398 |
* Get highlights for a trip |
| 1399 |
*/ |
| 1400 |
public function getHighlights(int $tripId): array |
| 1401 |
{ |
| 1402 |
global $wpdb; |
| 1403 |
|
| 1404 |
// Use TripContentTable for highlights |
| 1405 |
$tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); |
| 1406 |
|
| 1407 |
$rows = $wpdb->get_results( |
| 1408 |
$wpdb->prepare( |
| 1409 |
"SELECT * FROM {$tripContentTable} |
| 1410 |
WHERE trip_id = %d AND content_type = 'highlight' |
| 1411 |
ORDER BY sort_order ASC, id ASC", |
| 1412 |
$tripId |
| 1413 |
) |
| 1414 |
) ?: []; |
| 1415 |
|
| 1416 |
// Normalize to UI shape |
| 1417 |
return array_map(function ($row) { |
| 1418 |
$metadata = []; |
| 1419 |
if (!empty($row->metadata)) { |
| 1420 |
$decoded = json_decode($row->metadata, true); |
| 1421 |
if (is_array($decoded)) { |
| 1422 |
$metadata = $decoded; |
| 1423 |
} |
| 1424 |
} |
| 1425 |
|
| 1426 |
$imageId = $metadata['image_id'] ?? ($row->image_id ?? null); |
| 1427 |
$icon = $metadata['icon'] ?? ($row->icon ?? null); |
| 1428 |
|
| 1429 |
return (object) [ |
| 1430 |
'text' => $row->title ?? '', |
| 1431 |
'description' => $row->description ?? '', |
| 1432 |
'image_id' => $imageId ? (int) $imageId : 0, |
| 1433 |
'icon' => $icon ?? '', |
| 1434 |
'is_featured' => isset($row->is_featured) ? (bool) $row->is_featured : false, |
| 1435 |
'order' => isset($row->sort_order) ? (int) $row->sort_order : 0, |
| 1436 |
]; |
| 1437 |
}, $rows); |
| 1438 |
} |
| 1439 |
|
| 1440 |
/** |
| 1441 |
* Get landmarks for a trip |
| 1442 |
*/ |
| 1443 |
public function getLandmarks(int $tripId): array |
| 1444 |
{ |
| 1445 |
global $wpdb; |
| 1446 |
|
| 1447 |
// Use TripContentTable for landmarks |
| 1448 |
$tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); |
| 1449 |
|
| 1450 |
$rows = $wpdb->get_results( |
| 1451 |
$wpdb->prepare( |
| 1452 |
"SELECT * FROM {$tripContentTable} |
| 1453 |
WHERE trip_id = %d AND content_type = 'landmark' |
| 1454 |
ORDER BY sort_order ASC, id ASC", |
| 1455 |
$tripId |
| 1456 |
) |
| 1457 |
) ?: []; |
| 1458 |
|
| 1459 |
// Convert to simple array of landmark texts (like SingleTripController) |
| 1460 |
$landmark_texts = []; |
| 1461 |
foreach ($rows as $landmark) { |
| 1462 |
if (!empty($landmark->title)) { |
| 1463 |
$landmark_texts[] = $landmark->title; |
| 1464 |
} elseif (!empty($landmark->description)) { |
| 1465 |
$landmark_texts[] = $landmark->description; |
| 1466 |
} |
| 1467 |
} |
| 1468 |
|
| 1469 |
return $landmark_texts; |
| 1470 |
} |
| 1471 |
|
| 1472 |
/** |
| 1473 |
* Get FAQs for a trip |
| 1474 |
*/ |
| 1475 |
public function getFaqs(int $tripId): array |
| 1476 |
{ |
| 1477 |
global $wpdb; |
| 1478 |
|
| 1479 |
// Use TripContentTable for FAQs |
| 1480 |
$tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); |
| 1481 |
|
| 1482 |
$rows = $wpdb->get_results( |
| 1483 |
$wpdb->prepare( |
| 1484 |
"SELECT * FROM {$tripContentTable} |
| 1485 |
WHERE trip_id = %d AND content_type = 'faq' |
| 1486 |
ORDER BY sort_order ASC, id ASC", |
| 1487 |
$tripId |
| 1488 |
) |
| 1489 |
) ?: []; |
| 1490 |
|
| 1491 |
// Normalize to UI shape |
| 1492 |
return array_map(function ($row) { |
| 1493 |
$metadata = []; |
| 1494 |
if (!empty($row->metadata)) { |
| 1495 |
$decoded = json_decode($row->metadata, true); |
| 1496 |
if (is_array($decoded)) { |
| 1497 |
$metadata = $decoded; |
| 1498 |
} |
| 1499 |
} |
| 1500 |
return (object) [ |
| 1501 |
'question' => $row->title ?? '', |
| 1502 |
'answer' => $row->description ?? '', |
| 1503 |
'category' => $metadata['category'] ?? '', |
| 1504 |
'is_featured' => isset($row->is_featured) ? (bool) $row->is_featured : false, |
| 1505 |
'order' => isset($row->sort_order) ? (int) $row->sort_order : 0, |
| 1506 |
]; |
| 1507 |
}, $rows); |
| 1508 |
} |
| 1509 |
|
| 1510 |
/** |
| 1511 |
* Get availability dates for a trip |
| 1512 |
*/ |
| 1513 |
public function getAvailabilityDates(int $tripId): array |
| 1514 |
{ |
| 1515 |
global $wpdb; |
| 1516 |
$table = TripAvailabilityDatesTable::getTableName(); |
| 1517 |
|
| 1518 |
return $wpdb->get_results( |
| 1519 |
$wpdb->prepare( |
| 1520 |
"SELECT * FROM `{$table}` |
| 1521 |
WHERE trip_id = %d |
| 1522 |
ORDER BY departure_date ASC", |
| 1523 |
$tripId |
| 1524 |
) |
| 1525 |
) ?: []; |
| 1526 |
} |
| 1527 |
|
| 1528 |
/** |
| 1529 |
* Get itinerary days with entries for a trip |
| 1530 |
*/ |
| 1531 |
public function getItineraryDays(int $tripId): array |
| 1532 |
{ |
| 1533 |
global $wpdb; |
| 1534 |
|
| 1535 |
// Use new table names for itinerary |
| 1536 |
$tableDays = \Yatra\Database\Tables\TripItineraryDaysTable::getTableName(); |
| 1537 |
$tableEntries = \Yatra\Database\Tables\TripItineraryDayEntryTable::getTableName(); |
| 1538 |
|
| 1539 |
// Check if tables exist, return empty array if they don't |
| 1540 |
$table_exists = $wpdb->get_var($wpdb->prepare( |
| 1541 |
"SELECT COUNT(*) FROM information_schema.tables |
| 1542 |
WHERE table_schema = %s AND table_name = %s", |
| 1543 |
DB_NAME, |
| 1544 |
$tableDays |
| 1545 |
)); |
| 1546 |
|
| 1547 |
if (!$table_exists) { |
| 1548 |
// Tables don't exist yet, return empty array |
| 1549 |
return []; |
| 1550 |
} |
| 1551 |
|
| 1552 |
// Get all days for this trip |
| 1553 |
$days = $wpdb->get_results( |
| 1554 |
$wpdb->prepare( |
| 1555 |
"SELECT * FROM `{$tableDays}` |
| 1556 |
WHERE trip_id = %d |
| 1557 |
ORDER BY `order` ASC, day_number ASC", |
| 1558 |
$tripId |
| 1559 |
) |
| 1560 |
) ?: []; |
| 1561 |
|
| 1562 |
// For each day, load its entries |
| 1563 |
foreach ($days as $day) { |
| 1564 |
$dayId = (int) $day->id; |
| 1565 |
|
| 1566 |
// Get entries for this day |
| 1567 |
$entries = $wpdb->get_results( |
| 1568 |
$wpdb->prepare( |
| 1569 |
"SELECT * FROM `{$tableEntries}` |
| 1570 |
WHERE day_id = %d |
| 1571 |
ORDER BY `order` ASC", |
| 1572 |
$dayId |
| 1573 |
) |
| 1574 |
) ?: []; |
| 1575 |
|
| 1576 |
// Process each entry |
| 1577 |
foreach ($entries as $entry) { |
| 1578 |
// Decode included/excluded items JSON stored directly on the entry |
| 1579 |
$entry->included_items = $this->decodeAmenityItems($entry->included_items ?? null); |
| 1580 |
$entry->excluded_items = $this->decodeAmenityItems($entry->excluded_items ?? null); |
| 1581 |
|
| 1582 |
// Images are stored in metadata in the new structure |
| 1583 |
$entry->images = []; |
| 1584 |
} |
| 1585 |
|
| 1586 |
// Attach entries to day |
| 1587 |
$day->entries = $entries; |
| 1588 |
} |
| 1589 |
|
| 1590 |
return $days; |
| 1591 |
} |
| 1592 |
|
| 1593 |
/** |
| 1594 |
* Save destinations for a trip |
| 1595 |
*/ |
| 1596 |
public function saveDestinations(int $tripId, array $destinations): void |
| 1597 |
{ |
| 1598 |
global $wpdb; |
| 1599 |
|
| 1600 |
$table = TripClassificationsTable::getTableName(); |
| 1601 |
$classificationsTable = ClassificationsTable::getTableName(); |
| 1602 |
|
| 1603 |
// Delete existing destination relations |
| 1604 |
$wpdb->delete( |
| 1605 |
$table, |
| 1606 |
[ |
| 1607 |
'trip_id' => $tripId, |
| 1608 |
'classification_type' => ClassificationTypes::DESTINATION, |
| 1609 |
], |
| 1610 |
['%d', '%s'] |
| 1611 |
); |
| 1612 |
|
| 1613 |
// Extract destination IDs from destination objects |
| 1614 |
$destinationIds = []; |
| 1615 |
foreach ($destinations as $destination) { |
| 1616 |
if (is_array($destination) && isset($destination['id'])) { |
| 1617 |
$destinationIds[] = (int) $destination['id']; |
| 1618 |
} elseif (is_object($destination) && isset($destination->id)) { |
| 1619 |
$destinationIds[] = (int) $destination->id; |
| 1620 |
} elseif (is_numeric($destination)) { |
| 1621 |
$destinationIds[] = (int) $destination; |
| 1622 |
} |
| 1623 |
} |
| 1624 |
|
| 1625 |
// Validate that destinations exist before saving (same as activities) |
| 1626 |
$validDestinationIds = []; |
| 1627 |
if (!empty($destinationIds)) { |
| 1628 |
$placeholders = implode(',', array_fill(0, count($destinationIds), '%d')); |
| 1629 |
$existingDestinations = $wpdb->get_col( |
| 1630 |
$wpdb->prepare( |
| 1631 |
"SELECT id FROM {$classificationsTable} |
| 1632 |
WHERE id IN ({$placeholders}) AND type = %s", |
| 1633 |
array_merge($destinationIds, [ClassificationTypes::DESTINATION]) |
| 1634 |
) |
| 1635 |
); |
| 1636 |
$validDestinationIds = array_map('intval', $existingDestinations); |
| 1637 |
} |
| 1638 |
|
| 1639 |
// Also clean up any existing invalid destination relationships for this trip |
| 1640 |
$deletedRows = $wpdb->query( |
| 1641 |
$wpdb->prepare( |
| 1642 |
"DELETE FROM {$table} |
| 1643 |
WHERE trip_id = %d AND classification_type = %s |
| 1644 |
AND classification_id NOT IN ( |
| 1645 |
SELECT id FROM {$classificationsTable} WHERE type = %s |
| 1646 |
)", |
| 1647 |
$tripId, ClassificationTypes::DESTINATION, ClassificationTypes::DESTINATION |
| 1648 |
) |
| 1649 |
); |
| 1650 |
|
| 1651 |
|
| 1652 |
// Insert new destination relations |
| 1653 |
if (!empty($validDestinationIds)) { |
| 1654 |
foreach ($validDestinationIds as $index => $destinationId) { |
| 1655 |
$wpdb->insert( |
| 1656 |
$table, |
| 1657 |
[ |
| 1658 |
'trip_id' => $tripId, |
| 1659 |
'classification_id' => $destinationId, |
| 1660 |
'classification_type' => ClassificationTypes::DESTINATION, |
| 1661 |
'relationship_type' => $index === 0 ? 'primary' : 'secondary', |
| 1662 |
'sort_order' => $index, |
| 1663 |
'is_featured' => $index === 0 ? 1 : 0, |
| 1664 |
], |
| 1665 |
['%d', '%d', '%s', '%s', '%d', '%d'] |
| 1666 |
); |
| 1667 |
} |
| 1668 |
} |
| 1669 |
} |
| 1670 |
|
| 1671 |
/** |
| 1672 |
* Save activities for a trip |
| 1673 |
*/ |
| 1674 |
public function saveActivities(int $tripId, array $activityIds): void |
| 1675 |
{ |
| 1676 |
global $wpdb; |
| 1677 |
|
| 1678 |
$table = TripClassificationsTable::getTableName(); |
| 1679 |
$classificationsTable = ClassificationsTable::getTableName(); |
| 1680 |
|
| 1681 |
// Validate that activities exist before saving |
| 1682 |
$validActivityIds = []; |
| 1683 |
if (!empty($activityIds)) { |
| 1684 |
$placeholders = implode(',', array_fill(0, count($activityIds), '%d')); |
| 1685 |
$existingActivities = $wpdb->get_col( |
| 1686 |
$wpdb->prepare( |
| 1687 |
"SELECT id FROM {$classificationsTable} |
| 1688 |
WHERE id IN ({$placeholders}) AND type = %s", |
| 1689 |
array_merge($activityIds, [ClassificationTypes::ACTIVITY]) |
| 1690 |
) |
| 1691 |
); |
| 1692 |
$validActivityIds = array_map('intval', $existingActivities); |
| 1693 |
} |
| 1694 |
|
| 1695 |
// Delete existing activity relations |
| 1696 |
$wpdb->delete( |
| 1697 |
$table, |
| 1698 |
[ |
| 1699 |
'trip_id' => $tripId, |
| 1700 |
'classification_type' => ClassificationTypes::ACTIVITY, |
| 1701 |
], |
| 1702 |
['%d', '%s'] |
| 1703 |
); |
| 1704 |
|
| 1705 |
// Insert new activity relations (only for valid activities) |
| 1706 |
if (!empty($validActivityIds)) { |
| 1707 |
foreach ($validActivityIds as $index => $activityId) { |
| 1708 |
$wpdb->insert( |
| 1709 |
$table, |
| 1710 |
[ |
| 1711 |
'trip_id' => $tripId, |
| 1712 |
'classification_id' => (int) $activityId, |
| 1713 |
'classification_type' => ClassificationTypes::ACTIVITY, |
| 1714 |
'relationship_type' => $index === 0 ? 'primary' : 'secondary', |
| 1715 |
'sort_order' => $index, |
| 1716 |
'is_featured' => $index === 0 ? 1 : 0, |
| 1717 |
], |
| 1718 |
['%d', '%d', '%s', '%s', '%d', '%d'] |
| 1719 |
); |
| 1720 |
} |
| 1721 |
} |
| 1722 |
} |
| 1723 |
|
| 1724 |
/** |
| 1725 |
* Save trip categories for a trip |
| 1726 |
*/ |
| 1727 |
public function saveTripCategories(int $tripId, array $categoryIds): void |
| 1728 |
{ |
| 1729 |
global $wpdb; |
| 1730 |
|
| 1731 |
$table = TripClassificationsTable::getTableName(); |
| 1732 |
|
| 1733 |
// Delete existing category relations |
| 1734 |
$wpdb->delete( |
| 1735 |
$table, |
| 1736 |
[ |
| 1737 |
'trip_id' => $tripId, |
| 1738 |
'classification_type' => ClassificationTypes::CATEGORY, |
| 1739 |
], |
| 1740 |
['%d', '%s'] |
| 1741 |
); |
| 1742 |
|
| 1743 |
// Insert new categories |
| 1744 |
if (!empty($categoryIds)) { |
| 1745 |
foreach ($categoryIds as $index => $categoryId) { |
| 1746 |
$wpdb->insert( |
| 1747 |
$table, |
| 1748 |
[ |
| 1749 |
'trip_id' => $tripId, |
| 1750 |
'classification_id' => (int) $categoryId, |
| 1751 |
'classification_type' => ClassificationTypes::CATEGORY, |
| 1752 |
'relationship_type' => $index === 0 ? 'primary' : 'secondary', |
| 1753 |
'sort_order' => $index, |
| 1754 |
'is_featured' => $index === 0 ? 1 : 0, |
| 1755 |
], |
| 1756 |
['%d', '%d', '%s', '%s', '%d', '%d'] |
| 1757 |
); |
| 1758 |
} |
| 1759 |
} |
| 1760 |
} |
| 1761 |
|
| 1762 |
/** |
| 1763 |
* Save price types for a trip |
| 1764 |
*/ |
| 1765 |
public function savePriceTypes(int $tripId, array $priceTypes): void |
| 1766 |
{ |
| 1767 |
if (empty($priceTypes)) { |
| 1768 |
// Explicitly clear stored per-category pricing so that deleting all |
| 1769 |
// rows (even the last one) persists. Previously this early-returned, |
| 1770 |
// leaving the old price_types JSON in place — the deletion silently |
| 1771 |
// reverted on reload. Only the JSON is always cleared; the derived |
| 1772 |
// scalar columns (original/discounted/sale) are reset solely for |
| 1773 |
// traveler-based trips, because for regular pricing those columns |
| 1774 |
// hold the actual price and must not be wiped. |
| 1775 |
$table = TripsTable::getTableName(); |
| 1776 |
$pricingType = $this->wpdb->get_var( |
| 1777 |
$this->wpdb->prepare("SELECT pricing_type FROM `{$table}` WHERE id = %d", $tripId) |
| 1778 |
); |
| 1779 |
|
| 1780 |
$data = ['price_types' => null]; |
| 1781 |
if ($pricingType === 'traveler_based') { |
| 1782 |
$data['original_price'] = null; |
| 1783 |
$data['discounted_price'] = null; |
| 1784 |
$data['sale_price'] = null; |
| 1785 |
} |
| 1786 |
|
| 1787 |
$this->wpdb->update($table, $data, ['id' => $tripId]); |
| 1788 |
return; |
| 1789 |
} |
| 1790 |
|
| 1791 |
// Ensure at most one default category is set (keep the first truthy one). |
| 1792 |
$defaultFound = false; |
| 1793 |
foreach ($priceTypes as &$pt) { |
| 1794 |
if (!is_array($pt)) { |
| 1795 |
continue; |
| 1796 |
} |
| 1797 |
$isDefault = !empty($pt['is_default']); |
| 1798 |
if ($isDefault && !$defaultFound) { |
| 1799 |
$defaultFound = true; |
| 1800 |
$pt['is_default'] = true; |
| 1801 |
} else { |
| 1802 |
$pt['is_default'] = false; |
| 1803 |
} |
| 1804 |
} |
| 1805 |
unset($pt); |
| 1806 |
|
| 1807 |
// Compute minimal pricing values from provided price types |
| 1808 |
$minOriginal = PHP_FLOAT_MAX; |
| 1809 |
$minDiscounted = PHP_FLOAT_MAX; |
| 1810 |
$minSale = PHP_FLOAT_MAX; |
| 1811 |
|
| 1812 |
foreach ($priceTypes as $priceType) { |
| 1813 |
$original = isset($priceType['original_price']) ? (float) $priceType['original_price'] : null; |
| 1814 |
$discounted = isset($priceType['discounted_price']) ? (float) $priceType['discounted_price'] : null; |
| 1815 |
$sale = isset($priceType['sale_price']) ? (float) $priceType['sale_price'] : null; |
| 1816 |
|
| 1817 |
if ($original !== null && $original > 0 && $original < $minOriginal) { |
| 1818 |
$minOriginal = $original; |
| 1819 |
} |
| 1820 |
if ($discounted !== null && $discounted > 0 && $discounted < $minDiscounted) { |
| 1821 |
$minDiscounted = $discounted; |
| 1822 |
} |
| 1823 |
if ($sale !== null && $sale > 0 && $sale < $minSale) { |
| 1824 |
$minSale = $sale; |
| 1825 |
} |
| 1826 |
} |
| 1827 |
|
| 1828 |
// Normalize infinity values to null |
| 1829 |
$minOriginal = ($minOriginal === PHP_FLOAT_MAX) ? null : $minOriginal; |
| 1830 |
$minDiscounted = ($minDiscounted === PHP_FLOAT_MAX) ? null : $minDiscounted; |
| 1831 |
$minSale = ($minSale === PHP_FLOAT_MAX) ? null : $minSale; |
| 1832 |
|
| 1833 |
// Determine final prices to store on trips table |
| 1834 |
$finalOriginal = $minOriginal; |
| 1835 |
$finalDiscounted = $minDiscounted ?? null; |
| 1836 |
$finalSale = $minSale ?? null; |
| 1837 |
|
| 1838 |
// If no discounted/sale but original exists, keep it; else leave unchanged |
| 1839 |
$data = []; |
| 1840 |
$format = []; |
| 1841 |
|
| 1842 |
// Persist full price_types JSON for reference (stored on trips table) |
| 1843 |
$data['price_types'] = wp_json_encode($priceTypes); |
| 1844 |
$format[] = '%s'; |
| 1845 |
|
| 1846 |
if ($finalOriginal !== null) { |
| 1847 |
$data['original_price'] = $finalOriginal; |
| 1848 |
$format[] = '%f'; |
| 1849 |
} |
| 1850 |
if ($finalDiscounted !== null) { |
| 1851 |
$data['discounted_price'] = $finalDiscounted; |
| 1852 |
$format[] = '%f'; |
| 1853 |
} |
| 1854 |
if ($finalSale !== null) { |
| 1855 |
$data['sale_price'] = $finalSale; |
| 1856 |
$format[] = '%f'; |
| 1857 |
} |
| 1858 |
|
| 1859 |
if (!empty($data)) { |
| 1860 |
$this->wpdb->update( |
| 1861 |
TripsTable::getTableName(), |
| 1862 |
$data, |
| 1863 |
['id' => $tripId], |
| 1864 |
$format, |
| 1865 |
['%d'] |
| 1866 |
); |
| 1867 |
} |
| 1868 |
} |
| 1869 |
|
| 1870 |
/** |
| 1871 |
* Save highlights for a trip |
| 1872 |
*/ |
| 1873 |
public function saveHighlights(int $tripId, array $highlights): void |
| 1874 |
{ |
| 1875 |
global $wpdb; |
| 1876 |
|
| 1877 |
$table = TripContentTable::getTableName(); |
| 1878 |
|
| 1879 |
// Delete existing highlights |
| 1880 |
$wpdb->delete( |
| 1881 |
$table, |
| 1882 |
[ |
| 1883 |
'trip_id' => $tripId, |
| 1884 |
'content_type' => 'highlight', |
| 1885 |
], |
| 1886 |
['%d', '%s'] |
| 1887 |
); |
| 1888 |
|
| 1889 |
if (!empty($highlights)) { |
| 1890 |
foreach ($highlights as $index => $highlight) { |
| 1891 |
$highlightText = is_string($highlight) ? $highlight : ($highlight['text'] ?? $highlight['highlight_text'] ?? ''); |
| 1892 |
if (empty($highlightText)) { |
| 1893 |
continue; |
| 1894 |
} |
| 1895 |
|
| 1896 |
$metadata = []; |
| 1897 |
if (is_array($highlight)) { |
| 1898 |
if (!empty($highlight['icon'])) { |
| 1899 |
$metadata['icon'] = $highlight['icon']; |
| 1900 |
} |
| 1901 |
if (!empty($highlight['image_id'])) { |
| 1902 |
$metadata['image_id'] = (int) $highlight['image_id']; |
| 1903 |
} |
| 1904 |
} |
| 1905 |
|
| 1906 |
$wpdb->insert( |
| 1907 |
$table, |
| 1908 |
[ |
| 1909 |
'trip_id' => $tripId, |
| 1910 |
'content_type' => 'highlight', |
| 1911 |
'title' => sanitize_text_field($highlightText), |
| 1912 |
'description' => is_array($highlight) && !empty($highlight['description']) ? wp_kses_post($highlight['description']) : null, |
| 1913 |
'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null, |
| 1914 |
'sort_order' => $index, |
| 1915 |
'is_featured' => is_array($highlight) && isset($highlight['is_featured']) ? (int) $highlight['is_featured'] : 0, |
| 1916 |
], |
| 1917 |
['%d', '%s', '%s', '%s', '%s', '%d', '%d'] |
| 1918 |
); |
| 1919 |
} |
| 1920 |
} |
| 1921 |
} |
| 1922 |
|
| 1923 |
/** |
| 1924 |
* Save landmarks for a trip |
| 1925 |
*/ |
| 1926 |
public function saveLandmarks(int $tripId, array $landmarks): void |
| 1927 |
{ |
| 1928 |
global $wpdb; |
| 1929 |
|
| 1930 |
$table = TripContentTable::getTableName(); |
| 1931 |
|
| 1932 |
// Delete existing landmarks |
| 1933 |
$wpdb->delete( |
| 1934 |
$table, |
| 1935 |
[ |
| 1936 |
'trip_id' => $tripId, |
| 1937 |
'content_type' => 'landmark', |
| 1938 |
], |
| 1939 |
['%d', '%s'] |
| 1940 |
); |
| 1941 |
|
| 1942 |
if (!empty($landmarks)) { |
| 1943 |
foreach ($landmarks as $index => $landmark) { |
| 1944 |
$landmarkText = is_string($landmark) ? $landmark : ($landmark['text'] ?? $landmark['landmark_text'] ?? ''); |
| 1945 |
if (empty($landmarkText)) { |
| 1946 |
continue; |
| 1947 |
} |
| 1948 |
|
| 1949 |
$metadata = []; |
| 1950 |
if (is_array($landmark)) { |
| 1951 |
if (!empty($landmark['icon'])) { |
| 1952 |
$metadata['icon'] = $landmark['icon']; |
| 1953 |
} |
| 1954 |
if (!empty($landmark['image_id'])) { |
| 1955 |
$metadata['image_id'] = (int) $landmark['image_id']; |
| 1956 |
} |
| 1957 |
} |
| 1958 |
|
| 1959 |
$wpdb->insert( |
| 1960 |
$table, |
| 1961 |
[ |
| 1962 |
'trip_id' => $tripId, |
| 1963 |
'content_type' => 'landmark', |
| 1964 |
'title' => sanitize_text_field($landmarkText), |
| 1965 |
'description' => is_array($landmark) && !empty($landmark['description']) ? wp_kses_post($landmark['description']) : null, |
| 1966 |
'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null, |
| 1967 |
'sort_order' => $index, |
| 1968 |
'is_featured' => is_array($landmark) && isset($landmark['is_featured']) ? (int) $landmark['is_featured'] : 0, |
| 1969 |
], |
| 1970 |
['%d', '%s', '%s', '%s', '%s', '%d', '%d'] |
| 1971 |
); |
| 1972 |
} |
| 1973 |
} |
| 1974 |
} |
| 1975 |
|
| 1976 |
/** |
| 1977 |
* Save gallery images for a trip |
| 1978 |
*/ |
| 1979 |
public function saveGalleryImages(int $tripId, array $galleryImages): void |
| 1980 |
{ |
| 1981 |
global $wpdb; |
| 1982 |
|
| 1983 |
$table = TripContentTable::getTableName(); |
| 1984 |
|
| 1985 |
// Delete existing gallery images |
| 1986 |
$wpdb->delete( |
| 1987 |
$table, |
| 1988 |
[ |
| 1989 |
'trip_id' => $tripId, |
| 1990 |
'content_type' => 'image', |
| 1991 |
], |
| 1992 |
['%d', '%s'] |
| 1993 |
); |
| 1994 |
|
| 1995 |
// Insert new |
| 1996 |
if (!empty($galleryImages)) { |
| 1997 |
foreach ($galleryImages as $index => $image) { |
| 1998 |
$imageUrl = is_string($image) ? $image : ($image['url'] ?? $image['image_url'] ?? ''); |
| 1999 |
if (empty($imageUrl)) { |
| 2000 |
continue; |
| 2001 |
} |
| 2002 |
|
| 2003 |
$metadata = []; |
| 2004 |
if (is_array($image)) { |
| 2005 |
if (!empty($image['alt_text'])) { |
| 2006 |
$metadata['alt_text'] = $image['alt_text']; |
| 2007 |
} |
| 2008 |
if (!empty($image['caption'])) { |
| 2009 |
$metadata['caption'] = $image['caption']; |
| 2010 |
} |
| 2011 |
} |
| 2012 |
|
| 2013 |
$wpdb->insert( |
| 2014 |
$table, |
| 2015 |
[ |
| 2016 |
'trip_id' => $tripId, |
| 2017 |
'content_type' => 'image', |
| 2018 |
'content_url' => esc_url_raw($imageUrl), |
| 2019 |
'file_path' => is_array($image) ? ($image['file_path'] ?? null) : null, |
| 2020 |
'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null, |
| 2021 |
'thumbnail_url' => is_array($image) ? ($image['thumbnail_url'] ?? null) : null, |
| 2022 |
'sort_order' => $index, |
| 2023 |
'is_featured' => is_array($image) && isset($image['is_featured']) ? (int) $image['is_featured'] : 0, |
| 2024 |
], |
| 2025 |
['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d'] |
| 2026 |
); |
| 2027 |
} |
| 2028 |
} |
| 2029 |
} |
| 2030 |
|
| 2031 |
/** |
| 2032 |
* Save FAQs for a trip |
| 2033 |
*/ |
| 2034 |
public function saveFaqs(int $tripId, array $faqs): void |
| 2035 |
{ |
| 2036 |
global $wpdb; |
| 2037 |
|
| 2038 |
$table = TripContentTable::getTableName(); |
| 2039 |
|
| 2040 |
// Delete existing FAQs |
| 2041 |
$wpdb->delete( |
| 2042 |
$table, |
| 2043 |
[ |
| 2044 |
'trip_id' => $tripId, |
| 2045 |
'content_type' => 'faq', |
| 2046 |
], |
| 2047 |
['%d', '%s'] |
| 2048 |
); |
| 2049 |
|
| 2050 |
// Insert new FAQs |
| 2051 |
if (!empty($faqs)) { |
| 2052 |
foreach ($faqs as $index => $faq) { |
| 2053 |
if (!is_array($faq) || empty($faq['question']) || empty($faq['answer'])) { |
| 2054 |
continue; |
| 2055 |
} |
| 2056 |
|
| 2057 |
$metadata = []; |
| 2058 |
if (!empty($faq['category'])) { |
| 2059 |
$metadata['category'] = sanitize_text_field($faq['category']); |
| 2060 |
} |
| 2061 |
|
| 2062 |
$wpdb->insert( |
| 2063 |
$table, |
| 2064 |
[ |
| 2065 |
'trip_id' => $tripId, |
| 2066 |
'content_type' => 'faq', |
| 2067 |
'title' => sanitize_text_field($faq['question']), |
| 2068 |
'description' => wp_kses_post($faq['answer']), |
| 2069 |
'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null, |
| 2070 |
'sort_order' => $index, |
| 2071 |
'is_featured' => isset($faq['is_featured']) ? (int) $faq['is_featured'] : 0, |
| 2072 |
], |
| 2073 |
['%d', '%s', '%s', '%s', '%s', '%d', '%d'] |
| 2074 |
); |
| 2075 |
} |
| 2076 |
} |
| 2077 |
} |
| 2078 |
|
| 2079 |
/** |
| 2080 |
* Save entries for a specific day using upsert strategy |
| 2081 |
*/ |
| 2082 |
private function saveDayEntries(int $dayId, array $entries, array $existingEntries): void |
| 2083 |
{ |
| 2084 |
global $wpdb; |
| 2085 |
$tableEntries = TripItineraryDayEntryTable::getTableName(); |
| 2086 |
|
| 2087 |
// Create lookup map for existing entries |
| 2088 |
$existingEntryMap = []; |
| 2089 |
foreach ($existingEntries as $entry) { |
| 2090 |
$key = $entry->title . '|' . ($entry->order ?? 0); |
| 2091 |
$existingEntryMap[$key] = $entry; |
| 2092 |
} |
| 2093 |
|
| 2094 |
$processedEntryIds = []; |
| 2095 |
foreach ($entries as $entryIndex => $entry) { |
| 2096 |
if (!is_array($entry) || empty($entry['title'])) continue; |
| 2097 |
|
| 2098 |
$entryKey = $entry['title'] . '|' . $entryIndex; |
| 2099 |
$entryData = [ |
| 2100 |
'title' => sanitize_text_field($entry['title']), |
| 2101 |
'description' => isset($entry['description']) ? wp_kses_post($entry['description']) : null, |
| 2102 |
'item_type_id' => isset($entry['item_type_id']) ? (int) $entry['item_type_id'] : null, |
| 2103 |
'item_id' => isset($entry['item_id']) ? (int) $entry['item_id'] : null, |
| 2104 |
'item_type' => isset($entry['item_type']) ? sanitize_text_field($entry['item_type']) : null, |
| 2105 |
'item_name' => isset($entry['item_name']) ? sanitize_text_field($entry['item_name']) : null, |
| 2106 |
'item_icon' => isset($entry['item_icon']) ? sanitize_text_field($entry['item_icon']) : null, |
| 2107 |
'time' => isset($entry['time']) ? sanitize_text_field($entry['time']) : null, |
| 2108 |
'start_time' => isset($entry['start_time']) ? sanitize_text_field($entry['start_time']) : null, |
| 2109 |
'end_time' => isset($entry['end_time']) ? sanitize_text_field($entry['end_time']) : null, |
| 2110 |
'time_type' => isset($entry['time_type']) ? sanitize_text_field($entry['time_type']) : 'exact', |
| 2111 |
'location' => isset($entry['location']) ? sanitize_text_field($entry['location']) : null, |
| 2112 |
'duration' => isset($entry['duration']) ? sanitize_text_field($entry['duration']) : null, |
| 2113 |
'cost' => isset($entry['cost']) ? floatval($entry['cost']) : null, |
| 2114 |
'cost_per_person' => isset($entry['cost_per_person']) ? (int) $entry['cost_per_person'] : 0, |
| 2115 |
'notes' => isset($entry['notes']) ? wp_kses_post($entry['notes']) : null, |
| 2116 |
'included_items' => isset($entry['included_items']) ? wp_json_encode($entry['included_items']) : null, |
| 2117 |
'excluded_items' => isset($entry['excluded_items']) ? wp_json_encode($entry['excluded_items']) : null, |
| 2118 |
'gallery' => isset($entry['gallery']) ? wp_json_encode($entry['gallery']) : null, |
| 2119 |
'video_url' => isset($entry['video_url']) ? esc_url_raw($entry['video_url']) : null, |
| 2120 |
'status' => isset($entry['status']) ? sanitize_text_field($entry['status']) : 'publish', |
| 2121 |
'order' => $entryIndex, |
| 2122 |
'updated_at' => current_time('mysql'), |
| 2123 |
]; |
| 2124 |
|
| 2125 |
// Update existing entry or insert new |
| 2126 |
if (isset($existingEntryMap[$entryKey])) { |
| 2127 |
$existingEntry = $existingEntryMap[$entryKey]; |
| 2128 |
$wpdb->update($tableEntries, $entryData, ['id' => $existingEntry->id]); |
| 2129 |
$processedEntryIds[] = $existingEntry->id; |
| 2130 |
} else { |
| 2131 |
$entryData['day_id'] = $dayId; |
| 2132 |
$entryData['trip_id'] = $this->getTripIdByDayId($dayId); |
| 2133 |
$entryData['created_at'] = current_time('mysql'); |
| 2134 |
$wpdb->insert($tableEntries, $entryData); |
| 2135 |
$processedEntryIds[] = $wpdb->insert_id; |
| 2136 |
} |
| 2137 |
} |
| 2138 |
|
| 2139 |
// Delete entries that are no longer present |
| 2140 |
if (!empty($processedEntryIds)) { |
| 2141 |
$placeholders = implode(',', array_fill(0, count($processedEntryIds), '%d')); |
| 2142 |
$wpdb->query($wpdb->prepare( |
| 2143 |
"DELETE FROM {$tableEntries} WHERE day_id = %d AND id NOT IN ({$placeholders})", |
| 2144 |
$dayId, |
| 2145 |
...$processedEntryIds |
| 2146 |
)); |
| 2147 |
} else { |
| 2148 |
// If no entries provided, delete all entries for this day |
| 2149 |
$wpdb->delete($tableEntries, ['day_id' => $dayId], ['%d']); |
| 2150 |
} |
| 2151 |
} |
| 2152 |
|
| 2153 |
/** |
| 2154 |
* Get trip ID by day ID |
| 2155 |
*/ |
| 2156 |
private function getTripIdByDayId(int $dayId): int |
| 2157 |
{ |
| 2158 |
global $wpdb; |
| 2159 |
$tableDays = TripItineraryDaysTable::getTableName(); |
| 2160 |
return (int) $wpdb->get_var($wpdb->prepare( |
| 2161 |
"SELECT trip_id FROM {$tableDays} WHERE id = %d", |
| 2162 |
$dayId |
| 2163 |
)); |
| 2164 |
} |
| 2165 |
|
| 2166 |
/** |
| 2167 |
* Save availability dates for a trip. |
| 2168 |
* |
| 2169 |
* Previously a flat DELETE-THEN-INSERT: every row for the trip was nuked |
| 2170 |
* and the incoming list rewritten. That made the trip-edit save lossy — |
| 2171 |
* when the Trip form only knew about a subset of fields on each date |
| 2172 |
* (e.g. it was loaded once, then the operator changed the seats on a |
| 2173 |
* single date through the Availability tab, then re-saved the Trip with |
| 2174 |
* the form's stale date payload), every field absent from the incoming |
| 2175 |
* row was reset to its hard-coded default. That's why operator edits |
| 2176 |
* to `seats_total` silently reverted to 20 after another trip-save: |
| 2177 |
* |
| 2178 |
* 1. Operator opens Trip → form loads `availability_dates` with |
| 2179 |
* `seats_total = 20`. |
| 2180 |
* 2. Operator switches to Availability tab → edits Sept 5 to |
| 2181 |
* `seats_total = 14` via the dedicated specific-date endpoint |
| 2182 |
* (writes directly to the row, so the row is now 14). |
| 2183 |
* 3. Operator returns to the Trip form, edits something unrelated |
| 2184 |
* (title / description), clicks Save. The form re-posts the date |
| 2185 |
* list it loaded in step 1 — `seats_total = 20`. |
| 2186 |
* 4. saveAvailabilityDates DELETEs everything, INSERTs the |
| 2187 |
* form-supplied list → Sept 5 is back to 20 (or the literal `20` |
| 2188 |
* default if the form didn't include seats_total at all). |
| 2189 |
* |
| 2190 |
* Fix: snapshot the existing rows before the DELETE and, when the |
| 2191 |
* incoming row omits an editable field, fall back to the snapshot's |
| 2192 |
* value instead of the schema default. The literal `20` default is |
| 2193 |
* removed in favour of the trip's `max_travelers` (or 1 as a last |
| 2194 |
* resort) so new dates inserted by truly-new entries don't pretend the |
| 2195 |
* trip seats 20 people unless the trip actually says so. |
| 2196 |
* |
| 2197 |
* Match key for the snapshot: `departure_date` + `departure_time` (both |
| 2198 |
* normalised), which is the same identity the Availability UI uses. |
| 2199 |
*/ |
| 2200 |
public function saveAvailabilityDates(int $tripId, array $availabilityDates): void |
| 2201 |
{ |
| 2202 |
global $wpdb; |
| 2203 |
$table = TripAvailabilityDatesTable::getTableName(); |
| 2204 |
|
| 2205 |
// Snapshot existing rows by date + time so partial incoming payloads |
| 2206 |
// can be merged with persisted values instead of clobbering them. |
| 2207 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from schema helper. |
| 2208 |
$existingRows = $wpdb->get_results($wpdb->prepare( |
| 2209 |
"SELECT * FROM {$table} WHERE trip_id = %d", |
| 2210 |
$tripId |
| 2211 |
)); |
| 2212 |
$snapshot = []; |
| 2213 |
if (is_array($existingRows)) { |
| 2214 |
foreach ($existingRows as $row) { |
| 2215 |
$key = self::availabilityRowKey( |
| 2216 |
(string) ($row->departure_date ?? ''), |
| 2217 |
(string) ($row->departure_time ?? '') |
| 2218 |
); |
| 2219 |
if ($key !== '') { |
| 2220 |
$snapshot[$key] = $row; |
| 2221 |
} |
| 2222 |
} |
| 2223 |
} |
| 2224 |
|
| 2225 |
// Fallback capacity when both the incoming row and the snapshot |
| 2226 |
// miss seats_total: read the trip's max_travelers once. The |
| 2227 |
// legacy literal `20` was a phantom default that punished sites |
| 2228 |
// whose actual trip capacity differs. |
| 2229 |
$tripFallbackSeats = 0; |
| 2230 |
$tripRow = $this->find($tripId); |
| 2231 |
if (is_object($tripRow) && isset($tripRow->max_travelers)) { |
| 2232 |
$tripFallbackSeats = (int) $tripRow->max_travelers; |
| 2233 |
} |
| 2234 |
if ($tripFallbackSeats <= 0) { |
| 2235 |
$tripFallbackSeats = 1; |
| 2236 |
} |
| 2237 |
|
| 2238 |
// Delete existing |
| 2239 |
$wpdb->delete($table, ['trip_id' => $tripId], ['%d']); |
| 2240 |
|
| 2241 |
// Insert new |
| 2242 |
if (!empty($availabilityDates)) { |
| 2243 |
foreach ($availabilityDates as $date) { |
| 2244 |
if (is_array($date) && !empty($date['departure_date'])) { |
| 2245 |
$key = self::availabilityRowKey( |
| 2246 |
(string) $date['departure_date'], |
| 2247 |
(string) ($date['departure_time'] ?? '') |
| 2248 |
); |
| 2249 |
$existing = $key !== '' && isset($snapshot[$key]) ? $snapshot[$key] : null; |
| 2250 |
|
| 2251 |
$seatsTotal = isset($date['seats_total']) |
| 2252 |
? (int) $date['seats_total'] |
| 2253 |
: ($existing ? (int) ($existing->seats_total ?? $tripFallbackSeats) : $tripFallbackSeats); |
| 2254 |
$seatsAvailable = isset($date['seats_available']) |
| 2255 |
? (int) $date['seats_available'] |
| 2256 |
: ($existing ? (int) ($existing->seats_available ?? $seatsTotal) : $seatsTotal); |
| 2257 |
|
| 2258 |
$arrivalDate = isset($date['arrival_date']) |
| 2259 |
? sanitize_text_field($date['arrival_date']) |
| 2260 |
: (isset($date['return_date']) |
| 2261 |
? sanitize_text_field($date['return_date']) |
| 2262 |
: ($existing ? $existing->arrival_date : null)); |
| 2263 |
$returnDate = isset($date['return_date']) |
| 2264 |
? sanitize_text_field($date['return_date']) |
| 2265 |
: ($existing ? $existing->return_date : null); |
| 2266 |
$arrivalTime = isset($date['arrival_time']) |
| 2267 |
? sanitize_text_field($date['arrival_time']) |
| 2268 |
: ($existing ? $existing->arrival_time : null); |
| 2269 |
|
| 2270 |
$originalPrice = isset($date['original_price']) |
| 2271 |
? (float) $date['original_price'] |
| 2272 |
: (isset($date['price_override']) |
| 2273 |
? (float) $date['price_override'] |
| 2274 |
: ($existing && $existing->original_price !== null ? (float) $existing->original_price : null)); |
| 2275 |
$discountedPrice = isset($date['discounted_price']) |
| 2276 |
? (float) $date['discounted_price'] |
| 2277 |
: ($existing && $existing->discounted_price !== null ? (float) $existing->discounted_price : null); |
| 2278 |
|
| 2279 |
$fromLocation = isset($date['from_location']) ? sanitize_text_field($date['from_location']) : ($existing ? $existing->from_location : null); |
| 2280 |
$toLocation = isset($date['to_location']) ? sanitize_text_field($date['to_location']) : ($existing ? $existing->to_location : null); |
| 2281 |
$fromLat = isset($date['from_latitude']) && is_numeric($date['from_latitude']) |
| 2282 |
? (string) $date['from_latitude'] |
| 2283 |
: ($existing ? $existing->from_latitude : null); |
| 2284 |
$fromLng = isset($date['from_longitude']) && is_numeric($date['from_longitude']) |
| 2285 |
? (string) $date['from_longitude'] |
| 2286 |
: ($existing ? $existing->from_longitude : null); |
| 2287 |
$toLat = isset($date['to_latitude']) && is_numeric($date['to_latitude']) |
| 2288 |
? (string) $date['to_latitude'] |
| 2289 |
: ($existing ? $existing->to_latitude : null); |
| 2290 |
$toLng = isset($date['to_longitude']) && is_numeric($date['to_longitude']) |
| 2291 |
? (string) $date['to_longitude'] |
| 2292 |
: ($existing ? $existing->to_longitude : null); |
| 2293 |
|
| 2294 |
if (isset($date['is_blackout'])) { |
| 2295 |
$status = $date['is_blackout'] ? 'blocked' : 'available'; |
| 2296 |
} elseif (isset($date['status'])) { |
| 2297 |
$status = sanitize_text_field($date['status']); |
| 2298 |
} elseif ($existing) { |
| 2299 |
$status = (string) ($existing->status ?? 'available'); |
| 2300 |
} else { |
| 2301 |
$status = 'available'; |
| 2302 |
} |
| 2303 |
|
| 2304 |
$insertData = [ |
| 2305 |
'trip_id' => $tripId, |
| 2306 |
'departure_date' => sanitize_text_field($date['departure_date']), |
| 2307 |
'arrival_date' => $arrivalDate, |
| 2308 |
'return_date' => $returnDate, |
| 2309 |
'departure_time' => isset($date['departure_time']) ? sanitize_text_field($date['departure_time']) : ($existing ? $existing->departure_time : null), |
| 2310 |
'arrival_time' => $arrivalTime, |
| 2311 |
'seats_total' => $seatsTotal, |
| 2312 |
'seats_available' => $seatsAvailable, |
| 2313 |
'original_price' => $originalPrice, |
| 2314 |
'discounted_price' => $discountedPrice, |
| 2315 |
'from_location' => $fromLocation, |
| 2316 |
'to_location' => $toLocation, |
| 2317 |
'from_latitude' => $fromLat, |
| 2318 |
'from_longitude' => $fromLng, |
| 2319 |
'to_latitude' => $toLat, |
| 2320 |
'to_longitude' => $toLng, |
| 2321 |
'status' => $status, |
| 2322 |
]; |
| 2323 |
|
| 2324 |
$wpdb->insert( |
| 2325 |
$table, |
| 2326 |
$insertData, |
| 2327 |
['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s'] |
| 2328 |
); |
| 2329 |
} |
| 2330 |
} |
| 2331 |
} |
| 2332 |
} |
| 2333 |
|
| 2334 |
/** |
| 2335 |
* Normalise a (date, time) pair to a single string key used to match |
| 2336 |
* incoming availability rows against the pre-delete snapshot. Time is |
| 2337 |
* left as-is for an exact comparison; empty time matches the "no |
| 2338 |
* specific time" row. |
| 2339 |
*/ |
| 2340 |
private static function availabilityRowKey(string $date, string $time): string |
| 2341 |
{ |
| 2342 |
$date = trim($date); |
| 2343 |
if ($date === '') { |
| 2344 |
return ''; |
| 2345 |
} |
| 2346 |
return $date . '|' . trim($time); |
| 2347 |
} |
| 2348 |
|
| 2349 |
/** |
| 2350 |
* Save attributes for a trip |
| 2351 |
*/ |
| 2352 |
public function saveAttributes(int $tripId, array $attributes): void |
| 2353 |
{ |
| 2354 |
$tripAttributeRepository = new \Yatra\Repositories\TripAttributeRepository(); |
| 2355 |
$tripAttributeRepository->saveTripAttributes($tripId, $attributes); |
| 2356 |
} |
| 2357 |
|
| 2358 |
/** |
| 2359 |
* Create trip with relationships |
| 2360 |
*/ |
| 2361 |
public function createWithRelations(array $data, array $relationships = []): int |
| 2362 |
{ |
| 2363 |
// Extract relationship data |
| 2364 |
$destinations = $relationships['destinations'] ?? []; |
| 2365 |
$activities = $relationships['activities'] ?? []; |
| 2366 |
$tripCategories = $relationships['trip_category'] ?? []; |
| 2367 |
$priceTypes = $relationships['price_types'] ?? []; |
| 2368 |
$highlights = $relationships['highlights'] ?? []; |
| 2369 |
$landmarks = $relationships['landmarks'] ?? []; |
| 2370 |
$galleryImages = $relationships['gallery_images'] ?? []; |
| 2371 |
$faqs = $relationships['faqs'] ?? []; |
| 2372 |
$downloadableItems = $relationships['downloadable_items'] ?? []; |
| 2373 |
$itineraryDays = $relationships['itinerary_days'] ?? []; |
| 2374 |
$availabilityDates = $relationships['availability_dates'] ?? []; |
| 2375 |
$attributes = $relationships['attributes'] ?? []; |
| 2376 |
|
| 2377 |
// Remove relationship data from main data (these should not be in the main table) |
| 2378 |
unset( |
| 2379 |
$data['destinations'], |
| 2380 |
$data['activities'], |
| 2381 |
$data['trip_category'], |
| 2382 |
$data['highlights'], |
| 2383 |
$data['landmarks'], |
| 2384 |
$data['gallery_images'], |
| 2385 |
$data['faqs'], |
| 2386 |
$data['downloadable_items'], |
| 2387 |
$data['itinerary_days'], |
| 2388 |
$data['availability_dates'], |
| 2389 |
$data['attributes'] |
| 2390 |
); |
| 2391 |
|
| 2392 |
// Create main trip record |
| 2393 |
$tripId = $this->create($data); |
| 2394 |
|
| 2395 |
// Update relationships if provided |
| 2396 |
if (!empty($destinations)) { |
| 2397 |
$this->saveDestinations($tripId, $destinations); |
| 2398 |
} |
| 2399 |
|
| 2400 |
if (!empty($activities)) { |
| 2401 |
$this->saveActivities($tripId, $activities); |
| 2402 |
} |
| 2403 |
|
| 2404 |
if (!empty($tripCategories)) { |
| 2405 |
$this->saveTripCategories($tripId, $tripCategories); |
| 2406 |
} |
| 2407 |
|
| 2408 |
if (!empty($priceTypes)) { |
| 2409 |
$this->savePriceTypes($tripId, $priceTypes); |
| 2410 |
} |
| 2411 |
|
| 2412 |
if (!empty($highlights)) { |
| 2413 |
$this->saveHighlights($tripId, $highlights); |
| 2414 |
} |
| 2415 |
|
| 2416 |
if (!empty($landmarks)) { |
| 2417 |
$this->saveLandmarks($tripId, $landmarks); |
| 2418 |
} |
| 2419 |
|
| 2420 |
if (!empty($galleryImages)) { |
| 2421 |
$this->saveGalleryImages($tripId, $galleryImages); |
| 2422 |
} |
| 2423 |
|
| 2424 |
if (!empty($faqs)) { |
| 2425 |
$this->saveFaqs($tripId, $faqs); |
| 2426 |
} |
| 2427 |
|
| 2428 |
// Always replace downloads (clear if empty array) |
| 2429 |
$downloadRepo = new TripDownloadRepository(); |
| 2430 |
$downloadRepo->replaceForTrip($tripId, is_array($downloadableItems) ? $downloadableItems : []); |
| 2431 |
|
| 2432 |
// ITINERARY SHOULD NEVER BE PROCESSED DURING TRIP CREATION |
| 2433 |
// Itinerary should be created separately through dedicated itinerary endpoints |
| 2434 |
// This ensures complete separation of concerns and prevents data loss |
| 2435 |
|
| 2436 |
if (!empty($availabilityDates)) { |
| 2437 |
$this->saveAvailabilityDates($tripId, $availabilityDates); |
| 2438 |
} |
| 2439 |
|
| 2440 |
if (!empty($attributes)) { |
| 2441 |
$this->saveAttributes($tripId, $attributes); |
| 2442 |
} |
| 2443 |
|
| 2444 |
// Full bust after junction/related tables are written (create() already invalidated listings/stats). |
| 2445 |
Cache::invalidateAfterTripWrite('update', $tripId); |
| 2446 |
|
| 2447 |
do_action('yatra_trip_created_with_relations', $tripId, $relationships, $data); |
| 2448 |
|
| 2449 |
return $tripId; |
| 2450 |
} |
| 2451 |
|
| 2452 |
/** |
| 2453 |
* Update trip with relationships |
| 2454 |
*/ |
| 2455 |
public function updateWithRelations(int $id, array $data, array $relationships = []): bool |
| 2456 |
{ |
| 2457 |
// Extract relationship data (excluding itinerary - handled separately) |
| 2458 |
$destinations = $relationships['destinations'] ?? null; |
| 2459 |
$activities = $relationships['activities'] ?? null; |
| 2460 |
$tripCategories = $relationships['trip_category'] ?? null; |
| 2461 |
$priceTypes = $relationships['price_types'] ?? null; |
| 2462 |
$highlights = $relationships['highlights'] ?? null; |
| 2463 |
$landmarks = $relationships['landmarks'] ?? null; |
| 2464 |
$galleryImages = $relationships['gallery_images'] ?? null; |
| 2465 |
$faqs = $relationships['faqs'] ?? null; |
| 2466 |
$downloadableItems = $relationships['downloadable_items'] ?? null; |
| 2467 |
// ITINERARY IS HANDLED SEPARATELY - NEVER PROCESSED HERE |
| 2468 |
$availabilityDates = $relationships['availability_dates'] ?? null; |
| 2469 |
$attributes = $relationships['attributes'] ?? null; |
| 2470 |
|
| 2471 |
// Remove relationship data from main data (excluding itinerary - handled separately) |
| 2472 |
unset( |
| 2473 |
$data['destinations'], |
| 2474 |
$data['activities'], |
| 2475 |
$data['trip_category'], |
| 2476 |
$data['price_types'], |
| 2477 |
$data['highlights'], |
| 2478 |
$data['landmarks'], |
| 2479 |
$data['gallery_images'], |
| 2480 |
$data['faqs'], |
| 2481 |
// ITINERARY IS HANDLED SEPARATELY - DO NOT UNSET |
| 2482 |
$data['availability_dates'], |
| 2483 |
$data['attributes'] |
| 2484 |
); |
| 2485 |
|
| 2486 |
// Update main trip record |
| 2487 |
$result = $this->update($id, $data); |
| 2488 |
|
| 2489 |
// Update relationships if provided |
| 2490 |
if ($destinations !== null) { |
| 2491 |
$this->saveDestinations($id, $destinations); |
| 2492 |
} |
| 2493 |
|
| 2494 |
if ($activities !== null) { |
| 2495 |
$this->saveActivities($id, $activities); |
| 2496 |
} |
| 2497 |
|
| 2498 |
if ($tripCategories !== null) { |
| 2499 |
$this->saveTripCategories($id, $tripCategories); |
| 2500 |
} |
| 2501 |
|
| 2502 |
if ($priceTypes !== null) { |
| 2503 |
$this->savePriceTypes($id, $priceTypes); |
| 2504 |
} |
| 2505 |
|
| 2506 |
if ($highlights !== null) { |
| 2507 |
$this->saveHighlights($id, $highlights); |
| 2508 |
} |
| 2509 |
|
| 2510 |
if ($landmarks !== null) { |
| 2511 |
$this->saveLandmarks($id, $landmarks); |
| 2512 |
} |
| 2513 |
|
| 2514 |
if ($galleryImages !== null) { |
| 2515 |
$this->saveGalleryImages($id, $galleryImages); |
| 2516 |
} |
| 2517 |
|
| 2518 |
if ($faqs !== null) { |
| 2519 |
$this->saveFaqs($id, $faqs); |
| 2520 |
} |
| 2521 |
|
| 2522 |
if (is_array($downloadableItems)) { |
| 2523 |
$downloadRepo = new TripDownloadRepository(); |
| 2524 |
$downloadRepo->replaceForTrip($id, $downloadableItems); |
| 2525 |
} |
| 2526 |
|
| 2527 |
// ITINERARY SHOULD NEVER BE PROCESSED DURING TRIP UPDATES |
| 2528 |
// Itinerary updates should be handled separately through dedicated endpoints |
| 2529 |
// This ensures complete separation of concerns and prevents data loss |
| 2530 |
|
| 2531 |
if ($availabilityDates !== null) { |
| 2532 |
$this->saveAvailabilityDates($id, $availabilityDates); |
| 2533 |
} |
| 2534 |
|
| 2535 |
if ($attributes !== null) { |
| 2536 |
$this->saveAttributes($id, $attributes); |
| 2537 |
} |
| 2538 |
|
| 2539 |
if ($result) { |
| 2540 |
// Ensure caches reflect relationship writes (update() runs before saves; this runs after). |
| 2541 |
Cache::invalidateAfterTripWrite('update', $id); |
| 2542 |
} |
| 2543 |
|
| 2544 |
do_action('yatra_trip_updated_with_relations', $id, $relationships, $data); |
| 2545 |
|
| 2546 |
return $result; |
| 2547 |
} |
| 2548 |
|
| 2549 |
/** |
| 2550 |
* Soft delete a trip |
| 2551 |
*/ |
| 2552 |
public function softDelete(int $id, int $userId): bool |
| 2553 |
{ |
| 2554 |
return $this->update($id, [ |
| 2555 |
'deleted_at' => current_time('mysql'), |
| 2556 |
'deleted_by' => $userId, |
| 2557 |
]); |
| 2558 |
} |
| 2559 |
|
| 2560 |
/** |
| 2561 |
* Restore a soft-deleted trip |
| 2562 |
*/ |
| 2563 |
public function restore(int $id): bool |
| 2564 |
{ |
| 2565 |
return $this->update($id, [ |
| 2566 |
'deleted_at' => null, |
| 2567 |
'deleted_by' => null, |
| 2568 |
]); |
| 2569 |
} |
| 2570 |
|
| 2571 |
/** |
| 2572 |
* Get active trips (not deleted, with specified statuses) |
| 2573 |
* |
| 2574 |
* @param array $args Query arguments |
| 2575 |
* @param array $statuses Array of statuses to filter by. Defaults to ['published'] |
| 2576 |
* @return array |
| 2577 |
*/ |
| 2578 |
public function getActive(array $args = [], array $statuses = ['publish']): array |
| 2579 |
{ |
| 2580 |
$args['where']['deleted_at'] = null; |
| 2581 |
|
| 2582 |
// If status is already set in where clause, respect it |
| 2583 |
if (!isset($args['where']['status'])) { |
| 2584 |
$args['where']['status'] = $statuses; |
| 2585 |
} |
| 2586 |
|
| 2587 |
return $this->all($args); |
| 2588 |
} |
| 2589 |
|
| 2590 |
/** |
| 2591 |
* Find trips within a price range, considering all pricing types |
| 2592 |
* |
| 2593 |
* @param float $min_price Minimum price |
| 2594 |
* @param float $max_price Maximum price |
| 2595 |
* @param array $args Additional query arguments |
| 2596 |
* @return array Array of trips |
| 2597 |
*/ |
| 2598 |
public function findByPriceRange(float $min_price = 0, float $max_price = 0, array $args = []): array |
| 2599 |
{ |
| 2600 |
global $wpdb; |
| 2601 |
|
| 2602 |
// DEBUG: Log method entry |
| 2603 |
if (defined('WP_DEBUG') && WP_DEBUG) { |
| 2604 |
} |
| 2605 |
|
| 2606 |
// Base query to get all active trips |
| 2607 |
$args['where']['deleted_at'] = null; |
| 2608 |
if (!isset($args['where']['status'])) { |
| 2609 |
$args['where']['status'] = ['publish']; |
| 2610 |
} |
| 2611 |
|
| 2612 |
// DEBUG: Log query args |
| 2613 |
if (defined('WP_DEBUG') && WP_DEBUG) { |
| 2614 |
} |
| 2615 |
|
| 2616 |
// Get all active trips first |
| 2617 |
$all_trips = $this->all($args); |
| 2618 |
|
| 2619 |
if (empty($all_trips)) { |
| 2620 |
return []; |
| 2621 |
} |
| 2622 |
|
| 2623 |
// Get trip IDs |
| 2624 |
$trip_ids = array_map(function($trip) { |
| 2625 |
return $trip->id; |
| 2626 |
}, $all_trips); |
| 2627 |
|
| 2628 |
// Get trip prices and filter by range (price types table removed) |
| 2629 |
$filtered_trips = []; |
| 2630 |
|
| 2631 |
// DEBUG: Log price filtering process |
| 2632 |
if (defined('WP_DEBUG') && WP_DEBUG) { |
| 2633 |
} |
| 2634 |
|
| 2635 |
foreach ($all_trips as $trip) { |
| 2636 |
$trip_id = $trip->id; |
| 2637 |
$trip_min_price = PHP_FLOAT_MAX; |
| 2638 |
|
| 2639 |
// Check trip's own price first |
| 2640 |
if (!empty($trip->sale_price) && $trip->sale_price > 0) { |
| 2641 |
$trip_min_price = min($trip_min_price, (float)$trip->sale_price); |
| 2642 |
} |
| 2643 |
if (!empty($trip->discounted_price) && $trip->discounted_price > 0) { |
| 2644 |
$trip_min_price = min($trip_min_price, (float)$trip->discounted_price); |
| 2645 |
} |
| 2646 |
if (!empty($trip->original_price) && $trip->original_price > 0) { |
| 2647 |
$trip_min_price = min($trip_min_price, (float)$trip->original_price); |
| 2648 |
} |
| 2649 |
|
| 2650 |
// If no valid price found, skip |
| 2651 |
if ($trip_min_price === PHP_FLOAT_MAX) { |
| 2652 |
continue; |
| 2653 |
} |
| 2654 |
|
| 2655 |
// Apply price range filter |
| 2656 |
$passes_filter = ($min_price === 0 || $trip_min_price >= $min_price) && |
| 2657 |
($max_price === 0 || $trip_min_price <= $max_price); |
| 2658 |
|
| 2659 |
// DEBUG: Log individual trip filtering |
| 2660 |
if (defined('WP_DEBUG') && WP_DEBUG) { |
| 2661 |
} |
| 2662 |
|
| 2663 |
if ($passes_filter) { |
| 2664 |
$trip->min_price = $trip_min_price; |
| 2665 |
$filtered_trips[] = $trip; |
| 2666 |
} |
| 2667 |
} |
| 2668 |
|
| 2669 |
return $filtered_trips; |
| 2670 |
} |
| 2671 |
|
| 2672 |
/** |
| 2673 |
* Build where clause (override to handle soft deletes) |
| 2674 |
*/ |
| 2675 |
protected function buildWhereClause(array $args): string |
| 2676 |
{ |
| 2677 |
$where = parent::buildWhereClause($args); |
| 2678 |
|
| 2679 |
// Add soft delete filter if not explicitly requested |
| 2680 |
if (!isset($args['include_deleted']) || !$args['include_deleted']) { |
| 2681 |
if ($where) { |
| 2682 |
$where .= ' AND (deleted_at IS NULL OR deleted_at = \'0000-00-00 00:00:00\')'; |
| 2683 |
} else { |
| 2684 |
$where = 'WHERE (deleted_at IS NULL OR deleted_at = \'0000-00-00 00:00:00\')'; |
| 2685 |
} |
| 2686 |
} |
| 2687 |
|
| 2688 |
return $where; |
| 2689 |
} |
| 2690 |
|
| 2691 |
/** |
| 2692 |
* Count trips by status |
| 2693 |
*/ |
| 2694 |
public function countByStatus(string $status): int |
| 2695 |
{ |
| 2696 |
$table = esc_sql($this->table); |
| 2697 |
$count = $this->wpdb->get_var( |
| 2698 |
$this->wpdb->prepare( |
| 2699 |
"SELECT COUNT(*) FROM `{$table}` |
| 2700 |
WHERE status = %s |
| 2701 |
AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')", |
| 2702 |
$status |
| 2703 |
) |
| 2704 |
); |
| 2705 |
|
| 2706 |
return (int) $count; |
| 2707 |
} |
| 2708 |
|
| 2709 |
/** |
| 2710 |
* Search trips by keyword |
| 2711 |
*/ |
| 2712 |
public function search(string $keyword, array $args = []): array |
| 2713 |
{ |
| 2714 |
$table = esc_sql($this->table); |
| 2715 |
$where = $this->buildWhereClause($args); |
| 2716 |
$order = $this->buildOrderClause($args); |
| 2717 |
$limit = $this->buildLimitClause($args); |
| 2718 |
|
| 2719 |
$searchTerm = '%' . $this->wpdb->esc_like($keyword) . '%'; |
| 2720 |
|
| 2721 |
// Build search condition |
| 2722 |
$searchCondition = "(title LIKE %s OR description LIKE %s OR short_description LIKE %s)"; |
| 2723 |
|
| 2724 |
// If we have a WHERE clause, add AND; otherwise start with WHERE |
| 2725 |
if (!empty($where)) { |
| 2726 |
$whereClause = "{$where} AND {$searchCondition}"; |
| 2727 |
} else { |
| 2728 |
$whereClause = "WHERE {$searchCondition}"; |
| 2729 |
} |
| 2730 |
|
| 2731 |
$query = $this->wpdb->prepare( |
| 2732 |
"SELECT * FROM `{$table}` {$whereClause} {$order} {$limit}", |
| 2733 |
$searchTerm, |
| 2734 |
$searchTerm, |
| 2735 |
$searchTerm |
| 2736 |
); |
| 2737 |
|
| 2738 |
return $this->wpdb->get_results($query) ?: []; |
| 2739 |
} |
| 2740 |
|
| 2741 |
/** |
| 2742 |
* Decode included/excluded items JSON column stored on itinerary entries |
| 2743 |
*/ |
| 2744 |
private function decodeAmenityItems($value): array |
| 2745 |
{ |
| 2746 |
if (empty($value)) { |
| 2747 |
return []; |
| 2748 |
} |
| 2749 |
|
| 2750 |
if (is_array($value)) { |
| 2751 |
return $value; |
| 2752 |
} |
| 2753 |
|
| 2754 |
if (is_string($value)) { |
| 2755 |
$decoded = json_decode($value, true); |
| 2756 |
return is_array($decoded) ? $decoded : []; |
| 2757 |
} |
| 2758 |
|
| 2759 |
return []; |
| 2760 |
} |
| 2761 |
|
| 2762 |
/** |
| 2763 |
* Human-readable label for one included_items JSON element (title, name, label, or plain string). |
| 2764 |
*/ |
| 2765 |
private function extractIncludedItemLabel($item): string |
| 2766 |
{ |
| 2767 |
if (is_string($item)) { |
| 2768 |
$t = sanitize_text_field($item); |
| 2769 |
|
| 2770 |
return $t; |
| 2771 |
} |
| 2772 |
if (is_object($item)) { |
| 2773 |
$item = (array) $item; |
| 2774 |
} |
| 2775 |
if (!is_array($item)) { |
| 2776 |
return ''; |
| 2777 |
} |
| 2778 |
foreach (['title', 'name', 'label', 'text'] as $k) { |
| 2779 |
if (!empty($item[$k]) && is_scalar($item[$k])) { |
| 2780 |
$t = sanitize_text_field((string) $item[$k]); |
| 2781 |
|
| 2782 |
return $t; |
| 2783 |
} |
| 2784 |
} |
| 2785 |
|
| 2786 |
return ''; |
| 2787 |
} |
| 2788 |
|
| 2789 |
/** |
| 2790 |
* Get trip title by ID |
| 2791 |
*/ |
| 2792 |
public function getTripTitle(int $tripId): string |
| 2793 |
{ |
| 2794 |
global $wpdb; |
| 2795 |
$trips_table = $this->getTableName(); |
| 2796 |
|
| 2797 |
return (string) $wpdb->get_var($wpdb->prepare( |
| 2798 |
"SELECT title FROM {$trips_table} WHERE id = %d", |
| 2799 |
$tripId |
| 2800 |
)) ?: ''; |
| 2801 |
} |
| 2802 |
|
| 2803 |
/** |
| 2804 |
* Count all trips |
| 2805 |
*/ |
| 2806 |
public function countAllTrips(): int |
| 2807 |
{ |
| 2808 |
global $wpdb; |
| 2809 |
$trips_table = $this->getTableName(); |
| 2810 |
|
| 2811 |
return (int) $wpdb->get_var("SELECT COUNT(*) FROM `{$trips_table}`"); |
| 2812 |
} |
| 2813 |
|
| 2814 |
/** |
| 2815 |
* Get trip status counts |
| 2816 |
*/ |
| 2817 |
public function getTripStatusCounts(): array |
| 2818 |
{ |
| 2819 |
global $wpdb; |
| 2820 |
$trips_table = $this->getTableName(); |
| 2821 |
|
| 2822 |
return $wpdb->get_results("SELECT status, COUNT(*) as count FROM `{$trips_table}` GROUP BY status"); |
| 2823 |
} |
| 2824 |
|
| 2825 |
/** |
| 2826 |
* Get trip with destinations |
| 2827 |
*/ |
| 2828 |
public function getTripWithDestinations(int $tripId): ?\stdClass |
| 2829 |
{ |
| 2830 |
global $wpdb; |
| 2831 |
$trips_table = $this->getTableName(); |
| 2832 |
|
| 2833 |
// Use ClassificationsTable for destinations (type = 'destination') |
| 2834 |
$trip_destinations_table = TripClassificationsTable::getTableName(); |
| 2835 |
$destinations_table = ClassificationsTable::getTableName(); |
| 2836 |
|
| 2837 |
return $wpdb->get_row($wpdb->prepare( |
| 2838 |
"SELECT t.id, t.title, t.slug, t.status, t.pricing_type, t.original_price, t.sale_price, |
| 2839 |
td.destination_id, d.name as destination_name, d.slug as destination_slug |
| 2840 |
FROM {$trips_table} t |
| 2841 |
LEFT JOIN {$trip_destinations_table} td ON td.trip_id = t.id |
| 2842 |
LEFT JOIN {$destinations_table} d ON d.id = td.destination_id |
| 2843 |
WHERE t.id = %d", |
| 2844 |
$tripId |
| 2845 |
)); |
| 2846 |
} |
| 2847 |
|
| 2848 |
/** |
| 2849 |
* Get trip destinations |
| 2850 |
*/ |
| 2851 |
public function getTripDestinations(int $tripId): array |
| 2852 |
{ |
| 2853 |
global $wpdb; |
| 2854 |
|
| 2855 |
// Use new TripClassificationsTable for trip-destination relationships |
| 2856 |
$tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); |
| 2857 |
$classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); |
| 2858 |
|
| 2859 |
$results = $wpdb->get_results($wpdb->prepare( |
| 2860 |
"SELECT tc.trip_id, tc.classification_id, c.name, c.slug |
| 2861 |
FROM {$tripClassificationsTable} tc |
| 2862 |
LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id |
| 2863 |
WHERE tc.trip_id = %d AND tc.classification_type = %s", |
| 2864 |
$tripId, ClassificationTypes::DESTINATION |
| 2865 |
)); |
| 2866 |
|
| 2867 |
// Filter out destinations with missing classification data |
| 2868 |
return array_filter($results, function($destination) { |
| 2869 |
return !empty($destination->name) && !empty($destination->slug); |
| 2870 |
}); |
| 2871 |
} |
| 2872 |
|
| 2873 |
/** |
| 2874 |
* Get trip activities |
| 2875 |
*/ |
| 2876 |
public function getTripActivities(int $tripId): array |
| 2877 |
{ |
| 2878 |
global $wpdb; |
| 2879 |
|
| 2880 |
// Use new TripClassificationsTable for trip-activity relationships |
| 2881 |
$tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); |
| 2882 |
$classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); |
| 2883 |
|
| 2884 |
return $wpdb->get_results($wpdb->prepare( |
| 2885 |
"SELECT tc.trip_id, c.id, c.name, c.slug |
| 2886 |
FROM {$tripClassificationsTable} tc |
| 2887 |
INNER JOIN {$classificationsTable} c ON c.id = tc.classification_id |
| 2888 |
WHERE tc.trip_id = %d AND c.type = 'activity'", |
| 2889 |
$tripId |
| 2890 |
)); |
| 2891 |
} |
| 2892 |
|
| 2893 |
/** |
| 2894 |
* Get trip with availability |
| 2895 |
*/ |
| 2896 |
public function getTripWithAvailability(int $tripId): ?\stdClass |
| 2897 |
{ |
| 2898 |
global $wpdb; |
| 2899 |
$trips_table = $this->getTableName(); |
| 2900 |
|
| 2901 |
// Use TripAvailabilityDatesTable for availability data |
| 2902 |
$availability_table = \Yatra\Database\Tables\TripAvailabilityDatesTable::getTableName(); |
| 2903 |
|
| 2904 |
return $wpdb->get_row($wpdb->prepare( |
| 2905 |
"SELECT t.*, a.departure_date, a.seats_total, a.seats_reserved AS seats_booked, a.status as availability_status |
| 2906 |
FROM {$trips_table} t |
| 2907 |
LEFT JOIN {$availability_table} a ON a.trip_id = t.id |
| 2908 |
WHERE t.id = %d", |
| 2909 |
$tripId |
| 2910 |
)); |
| 2911 |
} |
| 2912 |
|
| 2913 |
/** |
| 2914 |
* Get price range statistics for published trips. |
| 2915 |
* |
| 2916 |
* IMPORTANT: the previous SQL-only implementation only considered |
| 2917 |
* the legacy `original_price` / `discounted_price` / `sale_price` |
| 2918 |
* columns. For trips using traveler-based pricing (per-category |
| 2919 |
* prices stored in the `price_types` JSON column), those legacy |
| 2920 |
* columns are often empty or stale — so the computed max was lower |
| 2921 |
* than the actually-displayed price, and the price-range slider |
| 2922 |
* cut off above the real maximum (eg. trip cost €3755 but slider |
| 2923 |
* stopped at €3731). Worse, the trip then couldn't be filtered |
| 2924 |
* into the results because the effective-price WHERE excluded it. |
| 2925 |
* |
| 2926 |
* Fix: walk the published trips once in PHP and let |
| 2927 |
* `TripPricingService` decide each trip's effective price using |
| 2928 |
* the same logic the listing/single-trip pages use to DISPLAY the |
| 2929 |
* price. We also track the per-trip MIN/MAX across categories so |
| 2930 |
* the slider bounds cover every traveler tier (not only the |
| 2931 |
* default / cheapest). |
| 2932 |
* |
| 2933 |
* @return object Object with min_price and max_price properties |
| 2934 |
*/ |
| 2935 |
public function getPriceRangeStats(): object |
| 2936 |
{ |
| 2937 |
$table = $this->getTableName(); |
| 2938 |
$rows = $this->wpdb->get_results( |
| 2939 |
"SELECT id, original_price, discounted_price, sale_price, price_types |
| 2940 |
FROM {$table} |
| 2941 |
WHERE status IN ('publish', 'published') |
| 2942 |
AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')" |
| 2943 |
) ?: []; |
| 2944 |
|
| 2945 |
$minPrice = null; |
| 2946 |
$maxPrice = null; |
| 2947 |
|
| 2948 |
foreach ($rows as $row) { |
| 2949 |
$tripPrices = $this->collectTripDisplayPrices($row); |
| 2950 |
foreach ($tripPrices as $price) { |
| 2951 |
if ($price <= 0) { |
| 2952 |
continue; |
| 2953 |
} |
| 2954 |
if ($minPrice === null || $price < $minPrice) { |
| 2955 |
$minPrice = $price; |
| 2956 |
} |
| 2957 |
if ($maxPrice === null || $price > $maxPrice) { |
| 2958 |
$maxPrice = $price; |
| 2959 |
} |
| 2960 |
} |
| 2961 |
} |
| 2962 |
|
| 2963 |
return (object) [ |
| 2964 |
'min_price' => $minPrice, |
| 2965 |
'max_price' => $maxPrice, |
| 2966 |
]; |
| 2967 |
} |
| 2968 |
|
| 2969 |
/** |
| 2970 |
* Collect every price a single trip might display to a user. |
| 2971 |
* |
| 2972 |
* Returns BOTH the legacy "regular" effective price AND every |
| 2973 |
* per-category effective price from `price_types`. Used by |
| 2974 |
* `getPriceRangeStats()` so the price-range slider's bounds cover |
| 2975 |
* the full range a customer could see — eg. for a traveler-based |
| 2976 |
* trip with Adult €3755 / Child €1500 / Infant €100, the array |
| 2977 |
* includes all three so the slider stretches from €100 to €3755. |
| 2978 |
* |
| 2979 |
* @param object $row Raw trip row (must include `price_types`, |
| 2980 |
* `original_price`, `discounted_price`, |
| 2981 |
* `sale_price`). |
| 2982 |
* @return float[] |
| 2983 |
*/ |
| 2984 |
protected function collectTripDisplayPrices(object $row): array |
| 2985 |
{ |
| 2986 |
$prices = []; |
| 2987 |
|
| 2988 |
// Legacy regular pricing. |
| 2989 |
$legacyPrice = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($row); |
| 2990 |
if ($legacyPrice > 0) { |
| 2991 |
$prices[] = $legacyPrice; |
| 2992 |
} |
| 2993 |
|
| 2994 |
// Per-category prices (traveler-based pricing). Decode whether |
| 2995 |
// `price_types` is stored as a JSON string (raw DB row) or an |
| 2996 |
// already-decoded array (in-memory trip object). |
| 2997 |
$priceTypes = $row->price_types ?? null; |
| 2998 |
if (is_string($priceTypes) && $priceTypes !== '') { |
| 2999 |
$decoded = json_decode($priceTypes, true); |
| 3000 |
if (is_array($decoded)) { |
| 3001 |
$priceTypes = $decoded; |
| 3002 |
} |
| 3003 |
} |
| 3004 |
if (is_array($priceTypes)) { |
| 3005 |
foreach ($priceTypes as $pt) { |
| 3006 |
$price = \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt); |
| 3007 |
if ($price > 0) { |
| 3008 |
$prices[] = $price; |
| 3009 |
} |
| 3010 |
} |
| 3011 |
} |
| 3012 |
|
| 3013 |
return $prices; |
| 3014 |
} |
| 3015 |
|
| 3016 |
/** |
| 3017 |
* Count trips by difficulty level |
| 3018 |
* |
| 3019 |
* @param int $difficultyLevelId Difficulty level ID |
| 3020 |
* @return int Number of trips with this difficulty level |
| 3021 |
*/ |
| 3022 |
public function countByDifficultyLevel(int $difficultyLevelId): int |
| 3023 |
{ |
| 3024 |
$table = $this->getTableName(); |
| 3025 |
|
| 3026 |
// Use ClassificationsTable for difficulty levels (type = 'difficulty') |
| 3027 |
$difficultyTable = ClassificationsTable::getTableName(); |
| 3028 |
|
| 3029 |
return (int) $this->wpdb->get_var($this->wpdb->prepare( |
| 3030 |
"SELECT COUNT(*) FROM {$table} t |
| 3031 |
LEFT JOIN {$difficultyTable} dl ON (t.difficulty_level = dl.id OR t.difficulty_level = dl.slug OR t.difficulty_level = dl.name) |
| 3032 |
WHERE dl.id = %d AND t.status IN ('publish','published')", |
| 3033 |
$difficultyLevelId |
| 3034 |
)); |
| 3035 |
} |
| 3036 |
|
| 3037 |
/** |
| 3038 |
* Check if reviews table exists |
| 3039 |
* |
| 3040 |
* @return bool True if reviews table exists |
| 3041 |
*/ |
| 3042 |
public function reviewsTableExists(): bool |
| 3043 |
{ |
| 3044 |
// Use ReviewsTable for reviews |
| 3045 |
$reviewsTable = ReviewsTable::getTableName(); |
| 3046 |
return (bool) $this->wpdb->get_var( |
| 3047 |
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES |
| 3048 |
WHERE TABLE_SCHEMA = DATABASE() |
| 3049 |
AND TABLE_NAME = '{$reviewsTable}'" |
| 3050 |
); |
| 3051 |
} |
| 3052 |
|
| 3053 |
/** |
| 3054 |
* Count trips by minimum rating |
| 3055 |
* |
| 3056 |
* @param int $minRating Minimum rating |
| 3057 |
* @return int Number of trips with this rating or above |
| 3058 |
*/ |
| 3059 |
public function countByMinRating(int $minRating): int |
| 3060 |
{ |
| 3061 |
$table = $this->getTableName(); |
| 3062 |
|
| 3063 |
// Use ReviewsTable for reviews |
| 3064 |
$reviewsTable = ReviewsTable::getTableName(); |
| 3065 |
|
| 3066 |
return (int) $this->wpdb->get_var($this->wpdb->prepare( |
| 3067 |
"SELECT COUNT(DISTINCT t.id) |
| 3068 |
FROM {$reviewsTable} r |
| 3069 |
INNER JOIN {$table} t ON r.trip_id = t.id |
| 3070 |
WHERE r.rating >= %d AND t.status IN ('publish','published')", |
| 3071 |
$minRating |
| 3072 |
)); |
| 3073 |
} |
| 3074 |
|
| 3075 |
/** |
| 3076 |
* Count trips by category |
| 3077 |
* |
| 3078 |
* @param int $categoryId Category ID |
| 3079 |
* @return int Number of trips in this category |
| 3080 |
*/ |
| 3081 |
public function countByCategory(int $categoryId): int |
| 3082 |
{ |
| 3083 |
$table = $this->getTableName(); |
| 3084 |
|
| 3085 |
// Use TripClassificationsTable for trip-category relationships |
| 3086 |
$categoryTable = TripClassificationsTable::getTableName(); |
| 3087 |
|
| 3088 |
$c = ClassificationsTable::getTableName(); |
| 3089 |
|
| 3090 |
return (int) $this->wpdb->get_var($this->wpdb->prepare( |
| 3091 |
"SELECT COUNT(DISTINCT t.id) FROM {$table} t |
| 3092 |
INNER JOIN {$categoryTable} ttc ON t.id = ttc.trip_id AND ttc.is_active = 1 |
| 3093 |
INNER JOIN {$c} cls ON cls.id = ttc.classification_id AND cls.type = %s |
| 3094 |
WHERE ttc.classification_id = %d |
| 3095 |
AND t.status IN ('publish', 'published') |
| 3096 |
AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')", |
| 3097 |
ClassificationTypes::CATEGORY, |
| 3098 |
$categoryId |
| 3099 |
)); |
| 3100 |
} |
| 3101 |
|
| 3102 |
/** |
| 3103 |
* Count trips by destination |
| 3104 |
* |
| 3105 |
* @param int $destinationId Destination ID |
| 3106 |
* @return int Number of trips to this destination |
| 3107 |
*/ |
| 3108 |
public function countByDestination(int $destinationId): int |
| 3109 |
{ |
| 3110 |
$table = $this->getTableName(); |
| 3111 |
$tc = TripClassificationsTable::getTableName(); |
| 3112 |
$c = ClassificationsTable::getTableName(); |
| 3113 |
|
| 3114 |
return (int) $this->wpdb->get_var($this->wpdb->prepare( |
| 3115 |
"SELECT COUNT(DISTINCT t.id) FROM {$table} t |
| 3116 |
INNER JOIN {$tc} ttc ON t.id = ttc.trip_id AND ttc.is_active = 1 |
| 3117 |
INNER JOIN {$c} cls ON cls.id = ttc.classification_id AND cls.type = %s |
| 3118 |
WHERE ttc.classification_id = %d |
| 3119 |
AND t.status IN ('publish', 'published') |
| 3120 |
AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')", |
| 3121 |
ClassificationTypes::DESTINATION, |
| 3122 |
$destinationId |
| 3123 |
)); |
| 3124 |
} |
| 3125 |
|
| 3126 |
/** |
| 3127 |
* Count trips by activity |
| 3128 |
* |
| 3129 |
* @param int $activityId Activity ID |
| 3130 |
* @return int Number of trips with this activity |
| 3131 |
*/ |
| 3132 |
public function countByActivity(int $activityId): int |
| 3133 |
{ |
| 3134 |
$table = $this->getTableName(); |
| 3135 |
$tc = TripClassificationsTable::getTableName(); |
| 3136 |
$c = ClassificationsTable::getTableName(); |
| 3137 |
|
| 3138 |
return (int) $this->wpdb->get_var($this->wpdb->prepare( |
| 3139 |
"SELECT COUNT(DISTINCT t.id) FROM {$table} t |
| 3140 |
INNER JOIN {$tc} ttc ON t.id = ttc.trip_id AND ttc.is_active = 1 |
| 3141 |
INNER JOIN {$c} cls ON cls.id = ttc.classification_id AND cls.type = %s |
| 3142 |
WHERE ttc.classification_id = %d |
| 3143 |
AND t.status IN ('publish', 'published') |
| 3144 |
AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')", |
| 3145 |
ClassificationTypes::ACTIVITY, |
| 3146 |
$activityId |
| 3147 |
)); |
| 3148 |
} |
| 3149 |
|
| 3150 |
/** |
| 3151 |
* Get popular trips for cache warming |
| 3152 |
* |
| 3153 |
* @param int $limit Number of trips to return |
| 3154 |
* @return array Array of popular trip IDs |
| 3155 |
*/ |
| 3156 |
public function getPopularTrips(int $limit = 20): array |
| 3157 |
{ |
| 3158 |
global $wpdb; |
| 3159 |
$tripsTable = $this->getTableName(); |
| 3160 |
|
| 3161 |
// Using hardcoded table name since there's no dedicated repository for this table |
| 3162 |
$bookingsTable = BookingsTable::getTableName(); |
| 3163 |
|
| 3164 |
return $wpdb->get_results(" |
| 3165 |
SELECT t.id |
| 3166 |
FROM {$tripsTable} t |
| 3167 |
LEFT JOIN {$bookingsTable} b ON b.trip_id = t.id |
| 3168 |
WHERE t.status = 'publish' |
| 3169 |
GROUP BY t.id |
| 3170 |
ORDER BY COUNT(b.id) DESC |
| 3171 |
LIMIT {$limit} |
| 3172 |
") ?: []; |
| 3173 |
} |
| 3174 |
|
| 3175 |
/** |
| 3176 |
* Get price statistics for filter sidebar |
| 3177 |
*/ |
| 3178 |
public function getPriceStats(): ?object |
| 3179 |
{ |
| 3180 |
global $wpdb; |
| 3181 |
|
| 3182 |
$table = $this->getTableName(); |
| 3183 |
|
| 3184 |
$result = $wpdb->get_row(" |
| 3185 |
SELECT |
| 3186 |
MIN(sub.eff_price) as min_price, |
| 3187 |
MAX(sub.eff_price) as max_price, |
| 3188 |
AVG(sub.eff_price) as avg_price |
| 3189 |
FROM ( |
| 3190 |
SELECT (CASE |
| 3191 |
WHEN CAST(discounted_price AS DECIMAL(10,2)) > 0 THEN CAST(discounted_price AS DECIMAL(10,2)) |
| 3192 |
WHEN CAST(sale_price AS DECIMAL(10,2)) > 0 THEN CAST(sale_price AS DECIMAL(10,2)) |
| 3193 |
ELSE CAST(original_price AS DECIMAL(10,2)) |
| 3194 |
END) AS eff_price |
| 3195 |
FROM {$table} |
| 3196 |
WHERE status IN ('publish', 'published') |
| 3197 |
AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00') |
| 3198 |
) sub |
| 3199 |
WHERE sub.eff_price > 0 |
| 3200 |
"); |
| 3201 |
|
| 3202 |
return $result ? (object) [ |
| 3203 |
'min_price' => (float) $result->min_price, |
| 3204 |
'max_price' => (float) $result->max_price, |
| 3205 |
'avg_price' => (float) $result->avg_price |
| 3206 |
] : null; |
| 3207 |
} |
| 3208 |
|
| 3209 |
/** |
| 3210 |
* Distinct accommodation_type values on published trips with counts. |
| 3211 |
* |
| 3212 |
* @return list<object{name: string, trip_count: int}> |
| 3213 |
*/ |
| 3214 |
public function getAccommodationTypes(): array |
| 3215 |
{ |
| 3216 |
if (!$this->tripTableHasColumn('accommodation_type')) { |
| 3217 |
return []; |
| 3218 |
} |
| 3219 |
$table = $this->getTableName(); |
| 3220 |
$rows = $this->wpdb->get_results( |
| 3221 |
"SELECT TRIM(accommodation_type) AS name, COUNT(*) AS trip_count |
| 3222 |
FROM {$table} |
| 3223 |
WHERE status IN ('publish', 'published') |
| 3224 |
AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00') |
| 3225 |
AND accommodation_type IS NOT NULL AND TRIM(accommodation_type) <> '' |
| 3226 |
GROUP BY TRIM(accommodation_type) |
| 3227 |
ORDER BY trip_count DESC, name ASC" |
| 3228 |
) ?: []; |
| 3229 |
|
| 3230 |
$out = []; |
| 3231 |
foreach ($rows as $r) { |
| 3232 |
$out[] = (object) [ |
| 3233 |
'name' => (string) $r->name, |
| 3234 |
'trip_count' => (int) $r->trip_count, |
| 3235 |
]; |
| 3236 |
} |
| 3237 |
|
| 3238 |
return $out; |
| 3239 |
} |
| 3240 |
|
| 3241 |
/** |
| 3242 |
* Included item titles from trip.included_items JSON, aggregated by trip count. |
| 3243 |
* |
| 3244 |
* @return list<object{service_name: string, trip_count: int}> |
| 3245 |
*/ |
| 3246 |
public function getIncludedServices(): array |
| 3247 |
{ |
| 3248 |
if (!$this->tripTableHasColumn('included_items')) { |
| 3249 |
return []; |
| 3250 |
} |
| 3251 |
$table = $this->getTableName(); |
| 3252 |
$jsons = $this->wpdb->get_col( |
| 3253 |
"SELECT included_items FROM {$table} |
| 3254 |
WHERE status IN ('publish', 'published') |
| 3255 |
AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00') |
| 3256 |
AND included_items IS NOT NULL |
| 3257 |
AND included_items <> '' |
| 3258 |
AND included_items <> '[]'" |
| 3259 |
) ?: []; |
| 3260 |
|
| 3261 |
$counts = []; |
| 3262 |
foreach ($jsons as $json) { |
| 3263 |
$decoded = json_decode((string) $json, true); |
| 3264 |
if (!is_array($decoded)) { |
| 3265 |
continue; |
| 3266 |
} |
| 3267 |
$list = $decoded; |
| 3268 |
if (isset($decoded['items']) && is_array($decoded['items'])) { |
| 3269 |
$list = $decoded['items']; |
| 3270 |
} |
| 3271 |
foreach ($list as $item) { |
| 3272 |
$title = $this->extractIncludedItemLabel($item); |
| 3273 |
if ($title === '') { |
| 3274 |
continue; |
| 3275 |
} |
| 3276 |
$counts[$title] = ($counts[$title] ?? 0) + 1; |
| 3277 |
} |
| 3278 |
} |
| 3279 |
arsort($counts, SORT_NUMERIC); |
| 3280 |
$out = []; |
| 3281 |
foreach ($counts as $name => $c) { |
| 3282 |
$out[] = (object) ['service_name' => $name, 'trip_count' => (int) $c]; |
| 3283 |
} |
| 3284 |
|
| 3285 |
return $out; |
| 3286 |
} |
| 3287 |
|
| 3288 |
/** |
| 3289 |
* Min/max duration_days among published trips (for search UI). Falls back to 1–30 when empty. |
| 3290 |
* |
| 3291 |
* @return array{min: int, max: int} |
| 3292 |
*/ |
| 3293 |
public function getDurationDaysBounds(): array |
| 3294 |
{ |
| 3295 |
if (!$this->tripTableHasColumn('duration_days')) { |
| 3296 |
return ['min' => 1, 'max' => 30]; |
| 3297 |
} |
| 3298 |
$table = $this->getTableName(); |
| 3299 |
$row = $this->wpdb->get_row( |
| 3300 |
"SELECT |
| 3301 |
MIN(NULLIF(CAST(duration_days AS UNSIGNED), 0)) AS min_days, |
| 3302 |
MAX(CAST(duration_days AS UNSIGNED)) AS max_days |
| 3303 |
FROM {$table} |
| 3304 |
WHERE status IN ('publish', 'published') |
| 3305 |
AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')" |
| 3306 |
); |
| 3307 |
$min = (int) ($row->min_days ?? 1); |
| 3308 |
$max = (int) ($row->max_days ?? 1); |
| 3309 |
if ($min < 1) { |
| 3310 |
$min = 1; |
| 3311 |
} |
| 3312 |
if ($max < $min) { |
| 3313 |
$max = $min; |
| 3314 |
} |
| 3315 |
// Sensible upper bound for dual slider UX |
| 3316 |
if ($max > 365) { |
| 3317 |
$max = 365; |
| 3318 |
} |
| 3319 |
|
| 3320 |
return ['min' => $min, 'max' => $max]; |
| 3321 |
} |
| 3322 |
|
| 3323 |
/** |
| 3324 |
* Get duration options (placeholder) |
| 3325 |
* |
| 3326 |
* @return array |
| 3327 |
*/ |
| 3328 |
public function getDurationOptions(): array |
| 3329 |
{ |
| 3330 |
return []; |
| 3331 |
} |
| 3332 |
|
| 3333 |
/** |
| 3334 |
* Get group size options (placeholder) |
| 3335 |
* |
| 3336 |
* @return array |
| 3337 |
*/ |
| 3338 |
public function getGroupSizeOptions(): array |
| 3339 |
{ |
| 3340 |
return []; |
| 3341 |
} |
| 3342 |
|
| 3343 |
/** |
| 3344 |
* Get physical grades (placeholder) |
| 3345 |
* |
| 3346 |
* @return array |
| 3347 |
*/ |
| 3348 |
public function getPhysicalGrades(): array |
| 3349 |
{ |
| 3350 |
return []; |
| 3351 |
} |
| 3352 |
|
| 3353 |
/** |
| 3354 |
* Get trip types |
| 3355 |
*/ |
| 3356 |
public function getTripTypes(): array |
| 3357 |
{ |
| 3358 |
return [ |
| 3359 |
(object) ['value' => 'single_day', 'label' => __('Single day', 'yatra')], |
| 3360 |
(object) ['value' => 'multi_day', 'label' => __('Multi-day', 'yatra')], |
| 3361 |
(object) ['value' => 'flexible', 'label' => __('Flexible', 'yatra')], |
| 3362 |
]; |
| 3363 |
} |
| 3364 |
|
| 3365 |
/** |
| 3366 |
* Count trips by trip type |
| 3367 |
*/ |
| 3368 |
public function countByTripType(string $tripType): int |
| 3369 |
{ |
| 3370 |
global $wpdb; |
| 3371 |
$table = $this->getTableName(); |
| 3372 |
|
| 3373 |
return (int) $wpdb->get_var($wpdb->prepare( |
| 3374 |
"SELECT COUNT(*) FROM {$table} |
| 3375 |
WHERE trip_type = %s AND status = 'publish'", |
| 3376 |
$tripType |
| 3377 |
)); |
| 3378 |
} |
| 3379 |
|
| 3380 |
/** |
| 3381 |
* Count trips with discounts |
| 3382 |
*/ |
| 3383 |
public function countByDiscount(): int |
| 3384 |
{ |
| 3385 |
$table = $this->getTableName(); |
| 3386 |
return (int) $this->wpdb->get_var( |
| 3387 |
"SELECT COUNT(*) FROM {$table} |
| 3388 |
WHERE status = 'publish' AND (discounted_price IS NOT NULL OR sale_price IS NOT NULL)" |
| 3389 |
); |
| 3390 |
} |
| 3391 |
|
| 3392 |
/** |
| 3393 |
* Count trips with early bird offers |
| 3394 |
*/ |
| 3395 |
public function countByEarlyBird(): int |
| 3396 |
{ |
| 3397 |
$table = $this->getTableName(); |
| 3398 |
|
| 3399 |
// Check if column exists |
| 3400 |
$column_exists = (int) $this->wpdb->get_var( |
| 3401 |
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS |
| 3402 |
WHERE TABLE_SCHEMA = DATABASE() |
| 3403 |
AND TABLE_NAME = '{$table}' |
| 3404 |
AND COLUMN_NAME = 'early_bird_discount_enabled'" |
| 3405 |
); |
| 3406 |
|
| 3407 |
if ($column_exists) { |
| 3408 |
return (int) $this->wpdb->get_var( |
| 3409 |
"SELECT COUNT(*) FROM {$table} |
| 3410 |
WHERE status = 'publish' AND early_bird_discount_enabled = 1" |
| 3411 |
); |
| 3412 |
} |
| 3413 |
|
| 3414 |
return 0; |
| 3415 |
} |
| 3416 |
|
| 3417 |
/** |
| 3418 |
* Count trips with last minute deals |
| 3419 |
*/ |
| 3420 |
public function countByLastMinute(): int |
| 3421 |
{ |
| 3422 |
$table = $this->getTableName(); |
| 3423 |
|
| 3424 |
// Check if column exists |
| 3425 |
$column_exists = (int) $this->wpdb->get_var( |
| 3426 |
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS |
| 3427 |
WHERE TABLE_SCHEMA = DATABASE() |
| 3428 |
AND TABLE_NAME = '{$table}' |
| 3429 |
AND COLUMN_NAME = 'last_minute_discount_enabled'" |
| 3430 |
); |
| 3431 |
|
| 3432 |
if ($column_exists) { |
| 3433 |
return (int) $this->wpdb->get_var( |
| 3434 |
"SELECT COUNT(*) FROM {$table} |
| 3435 |
WHERE status = 'publish' AND last_minute_discount_enabled = 1" |
| 3436 |
); |
| 3437 |
} |
| 3438 |
|
| 3439 |
return 0; |
| 3440 |
} |
| 3441 |
|
| 3442 |
/** |
| 3443 |
* Count trips with instant booking |
| 3444 |
*/ |
| 3445 |
public function countByInstantBooking(): int |
| 3446 |
{ |
| 3447 |
$table = $this->getTableName(); |
| 3448 |
|
| 3449 |
// Check if column exists |
| 3450 |
$column_exists = (int) $this->wpdb->get_var( |
| 3451 |
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS |
| 3452 |
WHERE TABLE_SCHEMA = DATABASE() |
| 3453 |
AND TABLE_NAME = '{$table}' |
| 3454 |
AND COLUMN_NAME = 'instant_booking'" |
| 3455 |
); |
| 3456 |
|
| 3457 |
if ($column_exists) { |
| 3458 |
return (int) $this->wpdb->get_var( |
| 3459 |
"SELECT COUNT(*) FROM {$table} |
| 3460 |
WHERE status = 'publish' AND instant_booking = 1" |
| 3461 |
); |
| 3462 |
} |
| 3463 |
|
| 3464 |
return 0; |
| 3465 |
} |
| 3466 |
|
| 3467 |
/** |
| 3468 |
* Count trips with flexible dates |
| 3469 |
*/ |
| 3470 |
public function countByFlexibleDates(): int |
| 3471 |
{ |
| 3472 |
$table = $this->getTableName(); |
| 3473 |
|
| 3474 |
// Check if column exists |
| 3475 |
$column_exists = (int) $this->wpdb->get_var( |
| 3476 |
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS |
| 3477 |
WHERE TABLE_SCHEMA = DATABASE() |
| 3478 |
AND TABLE_NAME = '{$table}' |
| 3479 |
AND COLUMN_NAME = 'flexible_dates'" |
| 3480 |
); |
| 3481 |
|
| 3482 |
if ($column_exists) { |
| 3483 |
return (int) $this->wpdb->get_var( |
| 3484 |
"SELECT COUNT(*) FROM {$table} |
| 3485 |
WHERE status = 'publish' AND flexible_dates = 1" |
| 3486 |
); |
| 3487 |
} |
| 3488 |
|
| 3489 |
return 0; |
| 3490 |
} |
| 3491 |
|
| 3492 |
/** |
| 3493 |
* Count trips requiring deposit |
| 3494 |
*/ |
| 3495 |
public function countByDepositRequired(): int |
| 3496 |
{ |
| 3497 |
$table = $this->getTableName(); |
| 3498 |
|
| 3499 |
// Check if column exists |
| 3500 |
$column_exists = (int) $this->wpdb->get_var( |
| 3501 |
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS |
| 3502 |
WHERE TABLE_SCHEMA = DATABASE() |
| 3503 |
AND TABLE_NAME = '{$table}' |
| 3504 |
AND COLUMN_NAME = 'deposit_required'" |
| 3505 |
); |
| 3506 |
|
| 3507 |
if ($column_exists) { |
| 3508 |
return (int) $this->wpdb->get_var( |
| 3509 |
"SELECT COUNT(*) FROM {$table} |
| 3510 |
WHERE status = 'publish' AND deposit_required = 1" |
| 3511 |
); |
| 3512 |
} |
| 3513 |
|
| 3514 |
return 0; |
| 3515 |
} |
| 3516 |
|
| 3517 |
/** |
| 3518 |
* Count family friendly trips |
| 3519 |
*/ |
| 3520 |
public function countByFamilyFriendly(): int |
| 3521 |
{ |
| 3522 |
$table = $this->getTableName(); |
| 3523 |
return (int) $this->wpdb->get_var( |
| 3524 |
"SELECT COUNT(*) FROM {$table} |
| 3525 |
WHERE status = 'publish' AND (age_min IS NULL OR age_min <= 5)" |
| 3526 |
); |
| 3527 |
} |
| 3528 |
|
| 3529 |
/** |
| 3530 |
* Count kids friendly trips |
| 3531 |
*/ |
| 3532 |
public function countByKidsFriendly(): int |
| 3533 |
{ |
| 3534 |
$table = $this->getTableName(); |
| 3535 |
return (int) $this->wpdb->get_var( |
| 3536 |
"SELECT COUNT(*) FROM {$table} |
| 3537 |
WHERE status = 'publish' AND (age_min IS NULL OR age_min <= 12)" |
| 3538 |
); |
| 3539 |
} |
| 3540 |
|
| 3541 |
/** |
| 3542 |
* Count senior friendly trips |
| 3543 |
*/ |
| 3544 |
public function countBySeniorFriendly(): int |
| 3545 |
{ |
| 3546 |
$table = $this->getTableName(); |
| 3547 |
return (int) $this->wpdb->get_var( |
| 3548 |
"SELECT COUNT(*) FROM {$table} |
| 3549 |
WHERE status = 'publish' AND (age_max IS NULL OR age_max >= 65)" |
| 3550 |
); |
| 3551 |
} |
| 3552 |
|
| 3553 |
/** |
| 3554 |
* Count adults only trips |
| 3555 |
*/ |
| 3556 |
public function countByAdultsOnly(): int |
| 3557 |
{ |
| 3558 |
$table = $this->getTableName(); |
| 3559 |
return (int) $this->wpdb->get_var( |
| 3560 |
"SELECT COUNT(*) FROM {$table} |
| 3561 |
WHERE status = 'publish' AND age_min >= 18" |
| 3562 |
); |
| 3563 |
} |
| 3564 |
|
| 3565 |
/** |
| 3566 |
* Get all destinations for search dropdown |
| 3567 |
* Returns destinations that have associated trips |
| 3568 |
* |
| 3569 |
* @return array Array of destination objects |
| 3570 |
*/ |
| 3571 |
public function getAllDestinationsForSearch(): array |
| 3572 |
{ |
| 3573 |
global $wpdb; |
| 3574 |
|
| 3575 |
$tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); |
| 3576 |
$classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); |
| 3577 |
|
| 3578 |
// Get destinations - try multiple status values |
| 3579 |
$destinations = $wpdb->get_results(" |
| 3580 |
SELECT DISTINCT c.* FROM {$classificationsTable} c |
| 3581 |
INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id |
| 3582 |
WHERE c.type = 'destination' AND c.status IN ('publish', 'active', 'draft') |
| 3583 |
ORDER BY c.name ASC |
| 3584 |
"); |
| 3585 |
|
| 3586 |
// If no destinations found, try without status filter |
| 3587 |
if (empty($destinations)) { |
| 3588 |
$destinations = $wpdb->get_results(" |
| 3589 |
SELECT DISTINCT c.* FROM {$classificationsTable} c |
| 3590 |
INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id |
| 3591 |
WHERE c.type = 'destination' |
| 3592 |
ORDER BY c.name ASC |
| 3593 |
"); |
| 3594 |
} |
| 3595 |
|
| 3596 |
return $destinations ?: []; |
| 3597 |
} |
| 3598 |
|
| 3599 |
/** |
| 3600 |
* Get all activities for search dropdown |
| 3601 |
* Returns activities that have associated trips |
| 3602 |
* |
| 3603 |
* @return array Array of activity objects |
| 3604 |
*/ |
| 3605 |
public function getAllActivitiesForSearch(): array |
| 3606 |
{ |
| 3607 |
global $wpdb; |
| 3608 |
|
| 3609 |
$tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); |
| 3610 |
$classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); |
| 3611 |
|
| 3612 |
// Get activities - try multiple status values |
| 3613 |
$activities = $wpdb->get_results(" |
| 3614 |
SELECT DISTINCT c.* FROM {$classificationsTable} c |
| 3615 |
INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id |
| 3616 |
WHERE c.type = 'activity' AND c.status IN ('publish', 'active', 'draft') |
| 3617 |
ORDER BY c.name ASC |
| 3618 |
"); |
| 3619 |
|
| 3620 |
// If no activities found, try without status filter |
| 3621 |
if (empty($activities)) { |
| 3622 |
$activities = $wpdb->get_results(" |
| 3623 |
SELECT DISTINCT c.* FROM {$classificationsTable} c |
| 3624 |
INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id |
| 3625 |
WHERE c.type = 'activity' |
| 3626 |
ORDER BY c.name ASC |
| 3627 |
"); |
| 3628 |
} |
| 3629 |
|
| 3630 |
return $activities ?: []; |
| 3631 |
} |
| 3632 |
} |
| 3633 |
|