wpdb = $wpdb; $this->table_trips = TripsTable::getTableName(); $this->table_destinations = ClassificationsTable::getTableName(); $this->table_activities = ClassificationsTable::getTableName(); $this->table_reviews = ReviewsTable::getTableName(); $this->table_trip_cat_rel = TripClassificationsTable::getTableName(); } /** * Get trip by slug with all related data * * @param string $slug Trip slug * @return object|null Prepared trip data or null */ public function getBySlug(string $slug): ?object { $trip = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table_trips} WHERE slug = %s AND status IN ('publish', 'published', 'draft') LIMIT 1", $slug ) ); // Check if trip exists but has other status - return null to show "not found" if (!$trip) { // Try to find trip regardless of status to check if it exists $existingTrip = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT status FROM {$this->table_trips} WHERE slug = %s LIMIT 1", $slug ) ); // If trip exists but viewer can't preview drafts, hide it. // Admin fallback so site owners always see drafts even when // the Team module isn't active and yatra_edit_trips isn't // on the admin role. if ( $existingTrip && !current_user_can('manage_options') && !current_user_can('yatra_edit_trips') ) { return null; } } // Return null if no trip found or if trip has other status if (!$trip) { return null; } return $this->prepareTrip($trip); } /** * Get trip by ID with all related data * * @param int $id Trip ID * @return object|null Prepared trip data or null */ public function getById(int $id): ?object { $trip = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table_trips} WHERE id = %d LIMIT 1", $id ) ); if (!$trip) { return null; } return $this->prepareTrip($trip); } /** * Prepare trip data with all relationships and transformations * * @param object $trip Raw trip data from database * @return \Yatra\Models\Trip Prepared trip data as Trip model */ private function prepareTrip(object $trip): object { // Decode JSON fields $trip->highlights = $this->decodeJson($trip->highlights ?? ''); $trip->testimonials = $this->decodeJson($trip->testimonials ?? ''); $trip->countries = $this->decodeJson($trip->countries ?? ''); $trip->regions = $this->decodeJson($trip->regions ?? ''); $trip->landmarks = $this->decodeJson($trip->landmarks ?? ''); $trip->tags = $this->decodeJson($trip->tags ?? ''); $trip->included_items = $this->decodeJson($trip->included_items ?? ''); $trip->excluded_items = $this->decodeJson($trip->excluded_items ?? ''); $trip->testimonial_review_ids = $this->decodeJson($trip->testimonial_review_ids ?? ''); // Gallery images from separate table (with attachment IDs) $trip->gallery_images = $this->getGalleryImages((int) $trip->id); // Get highlights from TripContentTable $trip->highlights = $this->getHighlights((int) $trip->id); // Get landmarks from TripContentTable $trip->landmarks = $this->getLandmarks((int) $trip->id); // Get FAQs from TripContentTable $trip->faqs = $this->getFaqs((int) $trip->id); // Get downloadable items from TripContentTable $trip->downloadable_items = $this->getDownloadableItems((int) $trip->id); // Get videos, YouTube videos, virtual tours, and documents from TripContentTable $trip->videos = $this->getVideos((int) $trip->id); $trip->youtube_videos = $this->getYoutubeVideos((int) $trip->id); $trip->virtual_tours = $this->getVirtualTours((int) $trip->id); $trip->documents = $this->getDocuments((int) $trip->id); // Also check main trip table fields for YouTube videos and virtual tours $youtube_from_table = []; if (!empty($trip->video_url)) { $video_id = $this->extractYoutubeVideoId($trip->video_url); $youtube_from_table[] = [ 'id' => 'main_' . $trip->id, 'title' => $trip->title ?? '', 'description' => '', 'url' => $trip->video_url, 'thumbnail' => $video_id ? "https://img.youtube.com/vi/{$video_id}/maxresdefault.jpg" : '', 'video_id' => $video_id, 'duration' => '', 'embed_url' => $video_id ? "https://www.youtube.com/embed/{$video_id}" : '' ]; } $tours_from_table = []; if (!empty($trip->virtual_tour_url)) { $tours_from_table[] = [ 'id' => 'main_' . $trip->id, 'title' => $trip->title ?? '360° Virtual Tour', 'description' => '', 'url' => $trip->virtual_tour_url, 'thumbnail' => '', 'tour_type' => '360', 'is_embeddable' => false ]; } // Merge TripContentTable results with main table results $trip->youtube_videos = array_merge($trip->youtube_videos, $youtube_from_table); $trip->virtual_tours = array_merge($trip->virtual_tours, $tours_from_table); // Get price types from database table (for traveler-based pricing) $trip->price_types = $this->getPriceTypes((int) $trip->id); // Normalize pricing_type with the same rules as {@see TripPricingService::resolvePricingType} if ($trip->pricing_type === null || $trip->pricing_type === '') { $trip->pricing_type = \Yatra\Services\TripPricingService::resolvePricingType($trip); } // Load itinerary from new database tables (preferred) or fallback to JSON field $itinerary_from_db = $this->getItineraryDays((int) $trip->id); if (!empty($itinerary_from_db)) { $trip->itinerary_days = $itinerary_from_db; } else { // Fallback to old JSON field if new tables are empty $trip->itinerary_days = $this->decodeJson($trip->itinerary_days ?? ''); } // FAQs are now loaded from TripContentTable in getFaqs() method $db_frontend_tabs = $this->decodeJson($trip->frontend_tabs ?? ''); // Merge database data with complete default array to ensure all sections are available $trip->frontend_tabs = $this->mergeFrontendTabsWithDefaults($db_frontend_tabs); // Fetch availability dates from database table $trip->availability_dates = $this->getAvailabilityDates((int) $trip->id); // Get booking mode information (date-specific vs flexible) $resolutionService = new \Yatra\Services\AvailabilityResolutionService(); $bookingModeInfo = $resolutionService->getBookingMode((int) $trip->id); $trip->booking_mode = $bookingModeInfo['mode']; $trip->has_specific_availability = $bookingModeInfo['has_availability']; $trip->blackout_dates = $this->decodeJson($trip->blackout_dates ?? ''); // Get trip attributes with their values $trip->attributes = $this->getTripAttributes((int) $trip->id); // Set default values for numeric fields $trip->duration_days = (int) ($trip->duration_days ?? 1); $trip->duration_nights = (int) ($trip->duration_nights ?? 0); $trip->min_travelers = (int) ($trip->min_travelers ?? 1); $trip->max_travelers = (int) ($trip->max_travelers ?? 10); $trip->age_min = (int) ($trip->age_min ?? 0); $trip->age_max = (int) ($trip->age_max ?? 99); // Set default values for price fields $trip->original_price = (float) ($trip->original_price ?? 0); $trip->sale_price = !empty($trip->sale_price) ? (float) $trip->sale_price : 0; $trip->discounted_price = !empty($trip->discounted_price) ? (float) $trip->discounted_price : 0; $trip->deposit_amount = (float) ($trip->deposit_amount ?? 0); // Get currency $trip->currency = SettingsService::getCurrency(); // Compute effective pricing via centralized TripPricingService (single source of truth) $displayPricing = \Yatra\Services\TripPricingService::resolveDisplayPricing($trip); $trip->effective_price_min = $displayPricing['effective_price_min']; $trip->min_category_original_price = $displayPricing['min_category_original_price']; $trip->max_discount_percentage = $displayPricing['max_discount_percentage']; $trip->discount_percentage = $displayPricing['discount_percentage']; // Ensure ID is integer $trip_id = (int) $trip->id; // Fetch related data $trip->destinations = $this->getDestinations($trip_id); $trip->activities = $this->getActivities($trip_id); $trip->trip_categories = $this->getTripCategories($trip_id); $trip->reviews = $this->getReviews($trip_id); $trip->testimonials = $this->getTestimonials($trip_id); $trip->similar_trips = $this->getSimilarTrips($trip); // Count / average / breakdown from all approved reviews (getReviews() is capped for the list UI). $reviewRepo = new ReviewRepository(); $sqlCount = $reviewRepo->getReviewCount($trip_id); $sqlAvg = $reviewRepo->getAverageRating($trip_id); $loadedCount = count($trip->reviews); $trip->review_count = max($sqlCount, $loadedCount); $trip->average_rating = $sqlAvg; if ((float) $trip->average_rating <= 0 && $loadedCount > 0) { $trip->average_rating = $this->averageRatingFromReviewRows($trip->reviews); } $trip->avg_rating = (float) $trip->average_rating; $trip->rating_distribution = $reviewRepo->getRatingDistribution($trip_id); if ($trip->review_count > 0) { $trip->reviews_count = $trip->review_count; } // Calculate base price for template display $trip->base_price = $this->calculateBasePrice($trip); // Availability flags for backward compatibility and template logic // has_availability: true if specific dates/rules exist $trip->has_availability = !empty($trip->availability_dates) && is_array($trip->availability_dates) && count($trip->availability_dates) > 0; // has_booking_capability: true if trip can be booked (either specific dates OR flexible mode) // This ensures booking interface always shows (industry best practice) $trip->has_booking_capability = $trip->has_availability || ($trip->booking_mode === 'flexible'); $trip->has_traveler_pricing = ($trip->pricing_type === 'traveler_based' && !empty($trip->price_types)); // Format featured image $trip->featured_image_url = $this->getFeaturedImageUrl($trip->featured_image ?? ''); // Convert stdClass to Trip model instance return \Yatra\Models\Trip::fromStdClass($trip); } /** * Calculate base price for template display * * Delegates to centralized TripPricingService (single source of truth). * * @param object $trip Trip object * @return float Base price */ private function calculateBasePrice(object $trip): float { $availDates = !empty($trip->availability_dates) && is_array($trip->availability_dates) ? $trip->availability_dates : null; $pricing = \Yatra\Services\TripPricingService::resolveDisplayPricing($trip, $availDates); return (float) $pricing['effective_price_min']; } /** * Decode a trip JSON / serialized field safely. * * Accepts both JSON (`[...]`) and PHP-serialized (`a:N:{...}`) input because * different code paths persist these columns differently: most fields are * stored as JSON, but TripValidator::sanitize() uses `maybe_serialize()` for * a handful of array fields (including `frontend_tabs`). If we only tried * `json_decode`, the PHP-serialized payload would silently decode to null * → empty array → admin-saved icons / tab labels disappear on the public * trip page. Try `maybe_unserialize` first; fall back to JSON. * * NOTE on safety: `maybe_unserialize` is used on a trusted DB column written * by our own validator. There's no user-controlled payload path that lands * in this column without sanitisation. Pre-checking the `a:` / `s:` / * `O:` prefix avoids tripping unserialize on random non-serialized data. * * @param string|null $value JSON string OR PHP-serialized string OR null. * @return array Decoded array (empty on any failure). */ private function decodeJson(?string $value): array { if (empty($value) || !is_string($value)) { return []; } // Looks like PHP-serialized data — try maybe_unserialize first. if (preg_match('/^(?:a|s|O):\d+:/', $value)) { $decoded = maybe_unserialize($value); if (is_array($decoded)) { return $decoded; } } // Fall back to JSON. $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : []; } /** * Get availability dates for a trip using centralized resolution service * * @param int $trip_id Trip ID * @return array Array of availability date objects */ private function getAvailabilityDates(int $trip_id): array { // Use centralized AvailabilityResolutionService $resolutionService = new \Yatra\Services\AvailabilityResolutionService(); // Get dates for next 12 months $fromDate = date('Y-m-d'); $toDate = date('Y-m-d', strtotime('+12 months')); $availability = $resolutionService->getAllAvailabilityDates($trip_id, $fromDate, $toDate, \Yatra\Services\SettingsService::isEnabled('show_sold_out')); // Add calculated fields foreach ($availability as $avail) { // Calculate if limited availability $avail->is_limited = ($avail->seats_available <= 5 && $avail->seats_available > 0); $avail->is_sold_out = ($avail->seats_available <= 0 || $avail->status === 'sold_out'); } return $availability; } /** * Get price types for trip (traveler-based pricing) * * @param int $trip_id Trip ID * @return array Price types with category info */ private function getPriceTypes(int $trip_id): array { // Read price_types JSON from trips table $table = esc_sql(TripsTable::getTableName()); $json = $this->wpdb->get_var( $this->wpdb->prepare("SELECT price_types FROM `{$table}` WHERE id = %d", $trip_id) ); if (empty($json)) { return []; } $decoded = is_string($json) ? json_decode($json, true) : $json; if (!is_array($decoded) || empty($decoded)) { return []; } // Collect category IDs for enrichment $category_ids = []; foreach ($decoded as $pt) { if (!is_array($pt)) continue; $cat_id = $pt['category_id'] ?? null; if ($cat_id !== null && $cat_id !== '') { $category_ids[] = (int) $cat_id; } } // Fetch category metadata (label, slug, pricing_mode, age_min, age_max, min_pax, max_pax) $categories = []; if (!empty($category_ids)) { $classifications_table = esc_sql(ClassificationsTable::getTableName()); $placeholders = implode(',', array_fill(0, count($category_ids), '%d')); $rows = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT id, name, slug, metadata FROM `{$classifications_table}` WHERE id IN ({$placeholders})", ...$category_ids ) ); foreach ($rows as $row) { $meta = !empty($row->metadata) ? json_decode($row->metadata, true) : []; $categories[(int) $row->id] = (object) [ 'label' => $row->name, 'slug' => $row->slug, 'pricing_mode' => $meta['pricing_mode'] ?? 'per_person', 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null, 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null, 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null, 'max_pax' => isset($meta['max_pax']) ? (int) $meta['max_pax'] : null, 'group_overflow' => isset($meta['group_overflow']) && in_array($meta['group_overflow'], ['block', 'per_block'], true) ? $meta['group_overflow'] : 'block', 'max_quantity' => isset($meta['max_quantity']) ? (int) $meta['max_quantity'] : null, 'description' => $meta['description'] ?? '', ]; } } // Build enriched price_types as objects $result = []; foreach ($decoded as $pt) { if (!is_array($pt)) continue; $cat_id = isset($pt['category_id']) ? (int) $pt['category_id'] : null; $cat = ($cat_id !== null && isset($categories[$cat_id])) ? $categories[$cat_id] : null; $original = isset($pt['original_price']) ? (float) $pt['original_price'] : 0; $discounted = isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : 0; $effective = ($discounted > 0 && $discounted < $original) ? $discounted : $original; $result[] = (object) [ 'category_id' => $cat_id, 'category_label' => $cat ? $cat->label : ($pt['label'] ?? __('Traveler', 'yatra')), 'category_slug' => $cat ? $cat->slug : '', 'original_price' => $original, 'discounted_price' => $discounted, 'effective_price' => $effective, 'is_default' => !empty($pt['is_default']), 'pricing_mode' => $cat ? $cat->pricing_mode : ($pt['pricing_mode'] ?? 'per_person'), 'age_min' => $cat ? $cat->age_min : null, 'age_max' => $cat ? $cat->age_max : null, 'min_pax' => $cat ? $cat->min_pax : null, 'max_pax' => $cat ? $cat->max_pax : null, 'group_overflow' => $cat ? $cat->group_overflow : ($pt['group_overflow'] ?? 'block'), 'max_quantity' => $cat ? $cat->max_quantity : null, 'description' => $cat ? $cat->description : ($pt['description'] ?? ''), ]; } return $result; } /** * Get destinations for trip * * @param int $trip_id Trip ID * @return array Destinations */ private function getDestinations(int $trip_id): array { // Use TripClassificationsTable for trip-destination relationships $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); $destination_ids = $this->wpdb->get_col( $this->wpdb->prepare( "SELECT classification_id FROM {$tripClassificationsTable} WHERE trip_id = %d AND classification_id IN ( SELECT id FROM {$classificationsTable} WHERE type = 'destination' )", $trip_id ) ); if (empty($destination_ids)) { return []; } $placeholders = implode(',', array_fill(0, count($destination_ids), '%d')); $destinations = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$classificationsTable} WHERE id IN ({$placeholders})", ...$destination_ids ) ); return $destinations ?: []; } /** * Get activities for trip * * @param int $trip_id Trip ID * @return array Activities */ private function getActivities(int $trip_id): array { // Use TripClassificationsTable for trip-activity relationships $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); $activity_ids = $this->wpdb->get_col( $this->wpdb->prepare( "SELECT classification_id FROM {$tripClassificationsTable} WHERE trip_id = %d AND classification_id IN ( SELECT id FROM {$classificationsTable} WHERE type = 'activity' )", $trip_id ) ); if (empty($activity_ids)) { return []; } $placeholders = implode(',', array_fill(0, count($activity_ids), '%d')); return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$classificationsTable} WHERE id IN ({$placeholders})", ...$activity_ids ) ) ?: []; } /** * Get gallery images for trip * * @param int $trip_id Trip ID * @return array Gallery images with URLs */ private function getGalleryImages(int $trip_id): array { // Use TripContentTable for gallery images $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); // Check if table exists $table_exists = $this->wpdb->get_var( $this->wpdb->prepare( "SHOW TABLES LIKE %s", $tripContentTable ) ) === $tripContentTable; if (!$table_exists) { return []; } $images = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$tripContentTable} WHERE trip_id = %d AND content_type = 'image' ORDER BY sort_order ASC, id ASC", $trip_id ) ) ?: []; // Convert to array of URLs $gallery = []; foreach ($images as $img) { $url = ''; // Check metadata for attachment_id (stored as JSON) if (!empty($img->metadata)) { $metadata = json_decode($img->metadata, true); if (is_array($metadata) && !empty($metadata['attachment_id'])) { $url = wp_get_attachment_image_url((int) $metadata['attachment_id'], 'large'); } } // Fallback to content_url field (direct URL) if (empty($url) && !empty($img->content_url)) { $url = $img->content_url; } if (!empty($url)) { $gallery[] = $url; } } return $gallery; } /** * Get videos for trip * * @param int $trip_id Trip ID * @return array Videos with URLs and metadata */ private function getVideos(int $trip_id): array { $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); $table_exists = $this->wpdb->get_var( $this->wpdb->prepare( "SHOW TABLES LIKE %s", $tripContentTable ) ) === $tripContentTable; if (!$table_exists) { return []; } $videos = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$tripContentTable} WHERE trip_id = %d AND content_type = 'video' ORDER BY sort_order ASC, id ASC", $trip_id ) ) ?: []; $video_list = []; foreach ($videos as $video) { $video_data = [ 'id' => $video->id, 'title' => $video->title ?? '', 'description' => $video->description ?? '', 'url' => $video->content_url ?? '', 'thumbnail' => $video->thumbnail_url ?? '', 'duration' => '', 'file_size' => $video->file_size ?? 0 ]; // Parse metadata for additional info if (!empty($video->metadata)) { $metadata = json_decode($video->metadata, true); if (is_array($metadata)) { $video_data['duration'] = $metadata['duration'] ?? ''; $video_data['file_size'] = $metadata['file_size'] ?? $video_data['file_size']; } } if (!empty($video_data['url'])) { $video_list[] = $video_data; } } return $video_list; } /** * Get documents for trip * * @param int $trip_id Trip ID * @return array Documents with URLs and metadata */ private function getDocuments(int $trip_id): array { $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); $table_exists = $this->wpdb->get_var( $this->wpdb->prepare( "SHOW TABLES LIKE %s", $tripContentTable ) ) === $tripContentTable; if (!$table_exists) { return []; } $documents = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$tripContentTable} WHERE trip_id = %d AND content_type = 'document' ORDER BY sort_order ASC, id ASC", $trip_id ) ) ?: []; $document_list = []; foreach ($documents as $doc) { $doc_data = [ 'id' => $doc->id, 'title' => $doc->title ?? '', 'description' => $doc->description ?? '', 'url' => $doc->content_url ?? '', 'file_path' => $doc->file_path ?? '', 'file_size' => $doc->file_size ?? 0, 'file_type' => $doc->file_type ?? '', 'is_downloadable' => (bool) ($doc->is_downloadable ?? true) ]; if (!empty($doc_data['url']) || !empty($doc_data['file_path'])) { $document_list[] = $doc_data; } } return $document_list; } /** * Get YouTube videos for trip * * @param int $trip_id Trip ID * @return array YouTube videos with URLs and metadata */ private function getYoutubeVideos(int $trip_id): array { $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); $table_exists = $this->wpdb->get_var( $this->wpdb->prepare( "SHOW TABLES LIKE %s", $tripContentTable ) ) === $tripContentTable; if (!$table_exists) { return []; } // Debug: Check what content types exist for this trip if (defined('WP_DEBUG') && WP_DEBUG) { $all_content = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT content_type, COUNT(*) as count FROM {$tripContentTable} WHERE trip_id = %d GROUP BY content_type", $trip_id ) ); foreach ($all_content as $content) { } } $youtube_videos = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$tripContentTable} WHERE trip_id = %d AND content_type = 'youtube' ORDER BY sort_order ASC, id ASC", $trip_id ) ) ?: []; $video_list = []; foreach ($youtube_videos as $video) { $video_data = [ 'id' => $video->id, 'title' => $video->title ?? '', 'description' => $video->description ?? '', 'url' => $video->content_url ?? '', 'thumbnail' => $video->thumbnail_url ?? '', 'video_id' => '', 'duration' => '' ]; // Extract YouTube video ID from URL if (!empty($video_data['url'])) { $video_id = $this->extractYoutubeVideoId($video_data['url']); if ($video_id) { $video_data['video_id'] = $video_id; $video_data['thumbnail'] = $video_data['thumbnail'] ?: "https://img.youtube.com/vi/{$video_id}/maxresdefault.jpg"; $video_data['embed_url'] = "https://www.youtube.com/embed/{$video_id}"; } } // Parse metadata for additional info if (!empty($video->metadata)) { $metadata = json_decode($video->metadata, true); if (is_array($metadata)) { $video_data['duration'] = $metadata['duration'] ?? $video_data['duration']; } } if (!empty($video_data['url'])) { $video_list[] = $video_data; } } return $video_list; } /** * Get virtual tours (360°) for trip * * @param int $trip_id Trip ID * @return array Virtual tours with URLs and metadata */ private function getVirtualTours(int $trip_id): array { $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); $table_exists = $this->wpdb->get_var( $this->wpdb->prepare( "SHOW TABLES LIKE %s", $tripContentTable ) ) === $tripContentTable; if (!$table_exists) { return []; } $virtual_tours = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$tripContentTable} WHERE trip_id = %d AND content_type = 'virtual_tour' ORDER BY sort_order ASC, id ASC", $trip_id ) ) ?: []; $tour_list = []; foreach ($virtual_tours as $tour) { $tour_data = [ 'id' => $tour->id, 'title' => $tour->title ?? '', 'description' => $tour->description ?? '', 'url' => $tour->content_url ?? '', 'thumbnail' => $tour->thumbnail_url ?? '', 'tour_type' => '360', 'is_embeddable' => false ]; // Parse metadata for additional info if (!empty($tour->metadata)) { $metadata = json_decode($tour->metadata, true); if (is_array($metadata)) { $tour_data['tour_type'] = $metadata['tour_type'] ?? '360'; $tour_data['is_embeddable'] = $metadata['is_embeddable'] ?? false; } } if (!empty($tour_data['url'])) { $tour_list[] = $tour_data; } } return $tour_list; } /** * Get highlights for a trip from TripContentTable * * @param int $trip_id Trip ID * @return array Array of highlights */ private function getHighlights(int $trip_id): array { $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); // Check if table exists $table_exists = $this->wpdb->get_var( $this->wpdb->prepare( "SHOW TABLES LIKE %s", $tripContentTable ) ) === $tripContentTable; if (!$table_exists) { return []; } $highlights = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$tripContentTable} WHERE trip_id = %d AND content_type = 'highlight' ORDER BY sort_order ASC, id ASC", $trip_id ) ); // Convert to simple array of highlight titles $highlight_texts = []; foreach ($highlights as $highlight) { if (!empty($highlight->title)) { $highlight_texts[] = $highlight->title; } elseif (!empty($highlight->description)) { $highlight_texts[] = $highlight->description; } } return $highlight_texts; } /** * Get landmarks for a trip * * @param int $trip_id Trip ID * @return array Array of landmark texts */ private function getLandmarks(int $trip_id): array { $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); // Check if table exists $table_exists = $this->wpdb->get_var( $this->wpdb->prepare( "SHOW TABLES LIKE %s", $tripContentTable ) ) === $tripContentTable; if (!$table_exists) { return []; } $landmarks = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$tripContentTable} WHERE trip_id = %d AND content_type = 'landmark' ORDER BY sort_order ASC, id ASC", $trip_id ) ); // Convert to simple array of landmark texts $landmark_texts = []; foreach ($landmarks as $landmark) { if (!empty($landmark->title)) { $landmark_texts[] = $landmark->title; } elseif (!empty($landmark->description)) { $landmark_texts[] = $landmark->description; } } return $landmark_texts; } /** * Get FAQs for a trip * * @param int $trip_id Trip ID * @return array Array of FAQ objects */ private function getFaqs(int $trip_id): array { $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); // Check if table exists $table_exists = $this->wpdb->get_var( $this->wpdb->prepare( "SHOW TABLES LIKE %s", $tripContentTable ) ) === $tripContentTable; if (!$table_exists) { return []; } $faqs = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$tripContentTable} WHERE trip_id = %d AND content_type = 'faq' ORDER BY sort_order ASC, id ASC", $trip_id ) ); return $faqs ?: []; } /** * Get downloadable items for a trip * * @param int $trip_id Trip ID * @return array Array of downloadable item objects */ private function getDownloadableItems(int $trip_id): array { $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName(); // Check if table exists $table_exists = $this->wpdb->get_var( $this->wpdb->prepare( "SHOW TABLES LIKE %s", $tripContentTable ) ) === $tripContentTable; if (!$table_exists) { return []; } $downloads = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$tripContentTable} WHERE trip_id = %d AND content_type = 'download' ORDER BY sort_order ASC, id ASC", $trip_id ) ); if (!$downloads) { return []; } // Normalize downloads to match expected format $normalized = []; foreach ($downloads as $download) { $metadata = []; if (!empty($download->metadata)) { $decoded = json_decode($download->metadata, true); if (is_array($decoded)) { $metadata = $decoded; } } $attachmentId = $metadata['attachment_id'] ?? null; $protectedPath = $metadata['protected_path'] ?? null; $visibility = $metadata['visibility'] ?? 'booked_only'; $normalized[] = (object) [ 'id' => isset($download->id) ? (int) $download->id : 0, 'title' => $download->title ?? '', 'description' => $download->description ?? '', 'attachment_id' => $attachmentId ? (int) $attachmentId : null, 'protected_path' => $protectedPath, 'content_url' => $download->content_url ?? '', 'file_path' => $download->file_path ?? null, 'file_size' => isset($download->file_size) ? (int) $download->file_size : null, 'file_type' => $download->file_type ?? null, 'thumbnail_url' => $download->thumbnail_url ?? null, 'visibility' => $visibility, 'is_downloadable' => isset($download->is_downloadable) ? (bool) $download->is_downloadable : true, 'sort_order' => isset($download->sort_order) ? (int) $download->sort_order : 0, ]; } return $normalized; } /** * Extract YouTube video ID from URL * * @param string $url YouTube URL * @return string|null Video ID or null if not found */ private function extractYoutubeVideoId(string $url): ?string { $patterns = [ '/youtube\.com\/watch\?v=([^&]+)/', '/youtube\.com\/embed\/([^?]+)/', '/youtu\.be\/([^?]+)/', '/youtube\.com\/v\/([^?]+)/', // Shorts + live URLs (e.g. youtube.com/shorts/XFR9Ti-4RbM?si=...). // ID stops at ?, & or / so trailing query params are excluded. '/youtube\.com\/shorts\/([^?&\/]+)/', '/youtube\.com\/live\/([^?&\/]+)/' ]; foreach ($patterns as $pattern) { if (preg_match($pattern, $url, $matches)) { return $matches[1]; } } return null; } /** * Get trip categories for trip * * @param int $trip_id Trip ID * @return array Trip categories */ private function getTripCategories(int $trip_id): array { // Use new Classification tables $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName(); $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName(); // Check if relation table exists $table_exists = $this->wpdb->get_var( $this->wpdb->prepare( "SHOW TABLES LIKE %s", $tripClassificationsTable ) ) === $tripClassificationsTable; if (!$table_exists) { return []; } return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT c.id, c.name, c.slug FROM {$tripClassificationsTable} tc LEFT JOIN {$classificationsTable} c ON tc.classification_id = c.id WHERE tc.trip_id = %d AND c.type = 'category' ORDER BY tc.sort_order ASC, tc.id ASC", $trip_id ) ) ?: []; } /** * Get reviews for trip * * @param int $trip_id Trip ID * @return array Reviews */ private function getReviews(int $trip_id): array { $reviewRepo = new ReviewRepository(); if (!$reviewRepo->tableExists()) { return []; } $approved = $reviewRepo->sqlApprovedReviewsWhere('status'); return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$this->table_reviews} WHERE trip_id = %d AND {$approved} ORDER BY created_at DESC LIMIT 10", $trip_id ) ) ?: []; } /** * Get testimonials (selected reviews for this trip) * * @param int $trip_id Trip ID * @return array Testimonial reviews */ private function getTestimonials(int $trip_id): array { // Get testimonial_review_ids from trip data $testimonial_ids = $this->wpdb->get_var( $this->wpdb->prepare( "SELECT testimonial_review_ids FROM {$this->table_trips} WHERE id = %d LIMIT 1", $trip_id ) ); if (empty($testimonial_ids)) { return []; } // Decode JSON array of IDs $review_ids = json_decode($testimonial_ids, true); if (!is_array($review_ids) || empty($review_ids)) { return []; } // Filter out invalid IDs $review_ids = array_filter($review_ids, 'is_numeric'); if (empty($review_ids)) { return []; } // Get the actual review data $placeholders = implode(',', array_fill(0, count($review_ids), '%d')); return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT r.*, u.display_name as author_display_name FROM {$this->table_reviews} r LEFT JOIN {$this->wpdb->users} u ON r.user_id = u.ID WHERE r.id IN ($placeholders) AND " . (new ReviewRepository())->sqlApprovedReviewsWhere('r.status') . " ORDER BY r.created_at DESC", ...$review_ids ) ) ?: []; } /** * Get similar trips * * @param object $trip Current trip * @return array Similar trips */ private function getSimilarTrips(object $trip): array { $trip_id = (int) $trip->id; // Get similar trips based on category or difficulty $similar = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT id, title, slug, featured_image AS featured_image_id, '' AS featured_image_url, duration_days, duration_nights, original_price, sale_price, difficulty_level, short_description FROM {$this->table_trips} t WHERE t.id != %d AND t.status IN ('publish', 'published') AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00') AND ( EXISTS ( SELECT 1 FROM {$this->table_trip_cat_rel} tc WHERE tc.trip_id = t.id AND tc.classification_type = 'category' AND tc.classification_id IN ( SELECT tc2.classification_id FROM {$this->table_trip_cat_rel} tc2 WHERE tc2.trip_id = %d AND tc2.classification_type = 'category' ) ) OR t.difficulty_level = %s ) ORDER BY RAND() LIMIT 4", $trip_id, $trip_id, $trip->difficulty_level ?? '' ) ); // Fallback: Get any published trips if no similar found if (empty($similar)) { $similar = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT id, title, slug, featured_image AS featured_image_id, '' AS featured_image_url, duration_days, duration_nights, original_price, sale_price, difficulty_level, short_description FROM {$this->table_trips} WHERE id != %d AND status IN ('publish', 'published') AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00') ORDER BY RAND() LIMIT 4", $trip_id ) ); } // Prepare each similar trip foreach ($similar as &$s) { $s->highlights = []; // Empty array as default $s->duration_days = (int) ($s->duration_days ?? 1); $s->duration_nights = (int) ($s->duration_nights ?? 0); $s->original_price = (float) ($s->original_price ?? 0); $s->sale_price = (float) ($s->sale_price ?? $s->original_price); $s->currency = $s->currency ?? get_option('yatra_currency', 'USD'); // Handle featured image - use URL if available, otherwise get from ID if (!empty($s->featured_image_url)) { // URL is already set, keep it as is } elseif (!empty($s->featured_image_id)) { $s->featured_image_url = $this->getFeaturedImageUrl($s->featured_image_id); } else { $s->featured_image_url = $this->getFeaturedImageUrl(''); } // Calculate discount $s->discount_percentage = 0; if ($s->original_price > 0 && $s->sale_price < $s->original_price) { $s->discount_percentage = round((($s->original_price - $s->sale_price) / $s->original_price) * 100); } } return $similar ?: []; } /** * Get trip attributes with their values * * @param int $trip_id Trip ID * @return array Trip attributes with values */ private function getTripAttributes(int $trip_id): array { $repo = new TripAttributeRepository(); $rows = $repo->getTripAttributes($trip_id); $formatted_attributes = []; foreach ($rows as $attr) { if (!$this->isAttributeMetaFlagEnabled($attr->show_on_frontend ?? null)) { continue; } $value = $attr->value; $field_type = isset($attr->field_type) ? trim((string) $attr->field_type, '"') : 'text'; $field_options = $attr->field_options ?? null; if (is_string($field_options)) { $field_options = trim($field_options, '"'); } elseif (is_array($field_options)) { $field_options = wp_json_encode($field_options); } $icon_data = null; if (!empty($attr->icon)) { $icon_data = maybe_unserialize($attr->icon); if (is_array($icon_data) && $icon_data['type'] === 'image' && !empty($icon_data['value'])) { $icon_value = $icon_data['value']; $image_url = ''; if (is_numeric($icon_value)) { $maybe_url = wp_get_attachment_image_url((int) $icon_value, 'large'); if (!empty($maybe_url)) { $image_url = $maybe_url; } } elseif (is_string($icon_value) && filter_var($icon_value, FILTER_VALIDATE_URL)) { $image_url = $icon_value; } $icon_data['value'] = $image_url; } } $formatted_attributes[] = [ 'id' => (int) $attr->attribute_id, 'name' => (string) $attr->name, 'field_type' => $field_type, 'field_options' => $field_options, 'value' => $value, 'icon' => $icon_data, 'description' => (string) ($attr->description ?? ''), ]; } return $formatted_attributes; } /** * True when admin "Show on Frontend" (or similar) is enabled for JSON_EXTRACT / API values. */ private function isAttributeMetaFlagEnabled($raw): bool { if ($raw === null) { return false; } if (is_bool($raw)) { return $raw; } if (is_numeric($raw)) { return (int) $raw === 1; } $s = strtolower(trim((string) $raw, " \t\n\r\0\x0B\"")); return in_array($s, ['1', 'true', 'yes', 'on'], true); } /** * Average rating from loaded review rows (fallback when SQL AVG returns 0). * * @param array $reviews */ private function averageRatingFromReviewRows(array $reviews): float { if ($reviews === []) { return 0.0; } $total = 0.0; foreach ($reviews as $review) { $total += (float) ($review->rating ?? 0); } return round($total / count($reviews), 1); } /** * Get featured image URL * * @param string|int $image Image ID or URL * @return string Image URL */ private function getFeaturedImageUrl($image): string { if (empty($image)) { return ''; } // If it's a numeric ID, get the attachment URL if (is_numeric($image)) { $url = wp_get_attachment_url((int) $image); return $url ?: ''; } // If it's already a URL, return it if (filter_var($image, FILTER_VALIDATE_URL)) { return $image; } return ''; } /** * Get itinerary days from database tables * * @param int $trip_id Trip ID * @return array Itinerary days with entries */ private function getItineraryDays(int $trip_id): array { // Using proper table classes for itinerary system with classification integration // Note: Items and Item Types now use ClassificationsTable with unified approach $table_days = \Yatra\Database\Tables\TripItineraryDaysTable::getTableName(); $table_entries = \Yatra\Database\Tables\TripItineraryDayEntryTable::getTableName(); $table_classifications = ClassificationsTable::getTableName(); // Get all days for this trip $days = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$table_days} WHERE trip_id = %d ORDER BY day_number ASC", $trip_id ) ); if (empty($days)) { return []; } $itinerary = []; foreach ($days as $day) { // Get entries for this day $entries = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT e.*, i.name as item_name, it.name as item_type_name, it.icon as item_type_icon, it.color as item_type_color FROM {$table_entries} e LEFT JOIN {$table_classifications} i ON e.item_id = i.id AND i.type = 'item' LEFT JOIN {$table_classifications} it ON e.item_type_id = it.id AND it.type = 'item_type' WHERE e.day_id = %d ORDER BY e.order ASC, e.id ASC", $day->id ) ); $formatted_entries = []; foreach ($entries as $entry) { $iconPicker = null; if (!empty($entry->item_type_icon)) { $rawIcon = $entry->item_type_icon; // Classification `icon` column may store a serialized array from the icon picker. // Decode it into the array shape expected by yatra_stored_picker_icon_markup(). $maybe = is_string($rawIcon) ? maybe_unserialize($rawIcon) : $rawIcon; if (is_array($maybe) && isset($maybe['type'])) { $iconPicker = $maybe; } elseif (is_string($rawIcon) && $rawIcon !== '') { // Backward compatibility: treat as yatra svg slug. $iconPicker = [ 'type' => 'icon', 'value' => (string) $rawIcon, 'provider' => 'yatra', ]; } } $formatted_entries[] = [ 'title' => $entry->title ?: $entry->item_name, 'description' => $entry->description ?: '', 'item_type' => $entry->item_type_name ?: 'Activity', 'icon_picker' => $iconPicker, 'item_type_color' => !empty($entry->item_type_color) ? (string) $entry->item_type_color : '', 'start_time' => $entry->start_time ?: '', 'end_time' => $entry->end_time ?: '', // The public template needs time_type to know whether to render // exact times, the duration-only label, or "Flexible". Without // this, all rows fell through to the start_time branch and an // entry intended as "duration / flexible" still showed clock // values pulled from stale defaults. 'time_type' => $entry->time_type ?: 'exact', 'location' => $entry->location ?: '', 'duration' => $entry->duration ?: '', 'cost' => !empty($entry->cost) ? (float) $entry->cost : null, 'cost_per_person' => !empty($entry->cost_per_person) ? true : false, 'included' => !empty($entry->included_items) ? json_decode($entry->included_items, true) : [], 'excluded' => !empty($entry->excluded_items) ? json_decode($entry->excluded_items, true) : [], 'gallery' => !empty($entry->gallery) ? $this->decodeGallery($entry->gallery) : [], 'video_url' => $entry->video_url ?: '', // The admin "Notes / Instructions" textarea ("Additional notes // or special instructions for this activity") was stored but // never reached the public template — the array key was // simply absent. Without this, operators saw their notes // discarded silently on the live trip page. 'notes' => (string) ($entry->notes ?? ''), ]; } $itinerary[] = [ 'day' => (int) $day->day_number, /* translators: %d: itinerary day number. */ 'day_title' => $day->title ?: sprintf(__('Day %d', 'yatra'), $day->day_number), 'day_description' => $day->description ?: '', 'entries' => $formatted_entries, ]; } return $itinerary; } /** * Format time from 24h to 12h format * * @param string $time Time in 24h format (e.g., "14:00") * @return string Time in 12h format (e.g., "2:00 PM") */ public static function formatTime(string $time): string { if (empty($time) || $time === 'Flexible') { return $time; } // Try to parse and format the time $timestamp = strtotime($time); if ($timestamp !== false) { return date('g:i A', $timestamp); } return $time; } /** * Decode gallery JSON data and convert attachment IDs to URLs * * @param string|null $galleryJson JSON string from database * @return array Gallery items with URLs */ private function decodeGallery(?string $galleryJson): array { if (empty($galleryJson)) { return []; } $gallery = json_decode($galleryJson, true); if (!is_array($gallery)) { return []; } // Convert attachment IDs to URLs for frontend compatibility foreach ($gallery as &$item) { if (isset($item['attachment_id']) && $item['attachment_id'] > 0) { // Get attachment URL from WordPress $attachment_url = wp_get_attachment_url($item['attachment_id']); if ($attachment_url) { $item['url'] = $attachment_url; } // Get thumbnail URL for images if (isset($item['type']) && $item['type'] === 'image') { $thumbnail_url = wp_get_attachment_image_src($item['attachment_id'], 'medium'); if ($thumbnail_url) { $item['thumbnail_url'] = $thumbnail_url[0]; } } } } return $gallery; } /** * Get booking URL for a trip * * @param string $slug Trip slug * @return string Booking URL */ public static function getBookingUrl(string $slug): string { if (function_exists('yatra_get_booking_url')) { return yatra_get_booking_url($slug); } $booking_base = get_option('yatra_booking_base', 'book'); return home_url("/{$booking_base}/{$slug}/"); } /** * Render tabs based on frontend_tabs configuration * * @param object $trip Trip object with frontend_tabs data * @return void */ public static function renderFrontendTabs($trip) { $frontend_tabs = isset($trip->frontend_tabs) ? $trip->frontend_tabs : []; // Use the same merge logic as getStickyNavigationItems to ensure consistency if (!empty($frontend_tabs)) { $frontend_tabs = self::mergeFrontendTabsWithDefaults($frontend_tabs); } else { // Use default tabs if no database data exists $frontend_tabs = [ // Core sections (always present) (object) ['id' => 'overview', 'label' => 'Overview', 'enabled' => true, 'order' => 1, 'content_type' => 'overview', 'icon' => 'book'], (object) ['id' => 'itinerary', 'label' => 'Itinerary', 'enabled' => true, 'order' => 2, 'content_type' => 'itinerary', 'icon' => 'calendar'], (object) ['id' => 'included', 'label' => 'Included', 'enabled' => true, 'order' => 3, 'content_type' => 'included_excluded', 'icon' => 'check'], (object) ['id' => 'location', 'label' => 'Location', 'enabled' => true, 'order' => 4, 'content_type' => 'location', 'icon' => 'map-pin'], (object) ['id' => 'important_info', 'label' => 'Important Info', 'enabled' => true, 'order' => 5, 'content_type' => 'important_info', 'icon' => 'info'], // Conditional sections (enabled by default, shown conditionally on frontend) (object) ['id' => 'downloads', 'label' => 'Downloads', 'enabled' => true, 'order' => 6, 'content_type' => 'downloads', 'icon' => 'download'], (object) ['id' => 'faq', 'label' => 'FAQ', 'enabled' => true, 'order' => 7, 'content_type' => 'faq', 'icon' => 'help-circle'], (object) ['id' => 'trip_story', 'label' => 'Story', 'enabled' => true, 'order' => 8, 'content_type' => 'trip_story', 'custom_content' => '', 'icon' => 'book'], (object) ['id' => 'what_makes_special', 'label' => 'Special', 'enabled' => true, 'order' => 9, 'content_type' => 'what_makes_special', 'custom_content' => '', 'icon' => 'star'], (object) ['id' => 'testimonials', 'label' => 'Testimonials', 'enabled' => true, 'order' => 10, 'content_type' => 'testimonials', 'icon' => 'message-circle'], (object) ['id' => 'gallery', 'label' => 'Gallery', 'enabled' => false, 'order' => 11, 'content_type' => 'gallery', 'icon' => 'camera'], ]; } // Sort tabs by order and filter enabled tabs $enabled_tabs = array_filter($frontend_tabs, function($tab) { return isset($tab->enabled) && filter_var($tab->enabled, FILTER_VALIDATE_BOOLEAN); }); usort($enabled_tabs, function($a, $b) { return ($a->order ?? 999) - ($b->order ?? 999); }); // Render each enabled tab foreach ($enabled_tabs as $tab) { self::renderTabContent($tab, $trip); } } /** * Render individual tab content based on content type * * @param object $tab Tab configuration * @param object $trip Trip object * @return void */ private static function renderTabContent($tab, $trip) { switch ($tab->content_type) { case 'overview': yatra_get_template('partials/single-trip/content-overview', ['trip' => $trip, 'tab' => $tab, 'has_traveler_pricing' => true, 'has_availability' => true, 'base_price' => $trip->original_price]); break; case 'itinerary': yatra_get_template('partials/single-trip/content-itinerary', ['trip' => $trip, 'tab' => $tab]); break; case 'included_excluded': yatra_get_template('partials/single-trip/content-included-excluded', ['trip' => $trip, 'tab' => $tab]); break; case 'location': // Get itinerary entries with coordinates for map display $itinerary_repository = new \Yatra\Repositories\ItineraryRepository(); $itinerary_entries = $itinerary_repository->getEntriesWithCoordinatesForMap((int) $trip->id); yatra_get_template('partials/single-trip/content-location', [ 'trip' => $trip, 'tab' => $tab, 'itinerary_entries' => $itinerary_entries ]); break; case 'gallery': yatra_get_template('partials/single-trip/content-gallery', ['trip' => $trip, 'tab' => $tab]); break; case 'important_info': yatra_get_template('partials/single-trip/content-important-info', ['trip' => $trip, 'tab' => $tab]); break; case 'downloads': yatra_get_template('partials/single-trip/content-downloads', ['trip' => $trip, 'tab' => $tab]); break; case 'faq': yatra_get_template('partials/single-trip/content-faq', ['trip' => $trip, 'tab' => $tab]); break; case 'trip_story': yatra_get_template('partials/single-trip/content-trip-story', ['trip' => $trip, 'tab' => $tab]); break; case 'what_makes_special': yatra_get_template('partials/single-trip/content-whats-make-special', ['trip' => $trip, 'tab' => $tab]); break; case 'testimonials': yatra_get_template('partials/single-trip/content-testimonials', ['trip' => $trip, 'tab' => $tab]); break; case 'custom': // Delegated to a partial so the admin-chosen icon (and label, content) // flow through the same yatra_render_tab_icon() pipeline as every other // tab type. Previously this branch hardcoded yatra_svg_icon('book') // which silently dropped the icon admins selected in Trip Builder. yatra_get_template('partials/single-trip/content-custom', [ 'trip' => $trip, 'tab' => $tab, ]); break; } } /** * Get sticky navigation items based on frontend_tabs configuration * * @param object $trip Trip object with frontend_tabs data * @return array Navigation items */ public static function getStickyNavigationItems($trip) { $frontend_tabs = isset($trip->frontend_tabs) ? $trip->frontend_tabs : []; // Use the same merge logic as renderFrontendTabs to ensure consistency if (!empty($frontend_tabs)) { $frontend_tabs = self::mergeFrontendTabsWithDefaults($frontend_tabs); } else { // Use default tabs if no database data exists $frontend_tabs = [ // Core sections (always present) (object) ['id' => 'overview', 'label' => 'Overview', 'enabled' => true, 'order' => 1, 'content_type' => 'overview', 'icon' => 'book'], (object) ['id' => 'itinerary', 'label' => 'Itinerary', 'enabled' => true, 'order' => 2, 'content_type' => 'itinerary', 'icon' => 'calendar'], (object) ['id' => 'included', 'label' => 'Included', 'enabled' => true, 'order' => 3, 'content_type' => 'included_excluded', 'icon' => 'check'], (object) ['id' => 'location', 'label' => 'Location', 'enabled' => true, 'order' => 4, 'content_type' => 'location', 'icon' => 'map-pin'], (object) ['id' => 'important_info', 'label' => 'Important Info', 'enabled' => true, 'order' => 5, 'content_type' => 'important_info', 'icon' => 'info'], // Conditional sections (enabled by default, shown conditionally on frontend) (object) ['id' => 'downloads', 'label' => 'Downloads', 'enabled' => true, 'order' => 6, 'content_type' => 'downloads', 'icon' => 'download'], (object) ['id' => 'faq', 'label' => 'FAQ', 'enabled' => true, 'order' => 7, 'content_type' => 'faq', 'icon' => 'help-circle'], (object) ['id' => 'trip_story', 'label' => 'Story', 'enabled' => true, 'order' => 8, 'content_type' => 'trip_story', 'custom_content' => '', 'icon' => 'book'], (object) ['id' => 'what_makes_special', 'label' => 'Special', 'enabled' => true, 'order' => 9, 'content_type' => 'what_makes_special', 'custom_content' => '', 'icon' => 'star'], (object) ['id' => 'testimonials', 'label' => 'Testimonials', 'enabled' => true, 'order' => 10, 'content_type' => 'testimonials', 'icon' => 'message-circle'], (object) ['id' => 'gallery', 'label' => 'Gallery', 'enabled' => false, 'order' => 11, 'content_type' => 'gallery', 'icon' => 'camera'], ]; } // Sort tabs by order and filter enabled tabs $enabled_tabs = array_filter($frontend_tabs, function($tab) { return isset($tab->enabled) && $tab->enabled; }); usort($enabled_tabs, function($a, $b) { return ($a->order ?? 999) - ($b->order ?? 999); }); $navigation_items = []; foreach ($enabled_tabs as $tab) { $nav_item = self::getNavigationItemForTab($tab, $trip); if ($nav_item) { $navigation_items[] = $nav_item; } } return $navigation_items; } /** * Prepare traveler selector data for common component * * @param object $trip Trip object * @param string $context Context for IDs (sidebar, availability, enquiry) * @return array Traveler selector data */ public static function prepareTravelerSelectorData($trip, string $context = 'sidebar'): array { $trip_pricing_type = $trip->pricing_type ?? ''; $has_traveler_pricing = ($trip_pricing_type === 'traveler_based' && !empty($trip->price_types)); $traveler_rows = []; if ($has_traveler_pricing) { // Determine which price type should be selected by default (admin-selected default; else first) $default_index = 0; foreach ((array) $trip->price_types as $i => $pt_candidate) { $pt_candidate = is_array($pt_candidate) ? (object) $pt_candidate : $pt_candidate; if (!empty($pt_candidate->is_default)) { $default_index = (int) $i; break; } } // Traveler-Based Pricing: Show dynamic categories foreach ($trip->price_types as $index => $price_type) { // Normalize to object if array $price_type = is_array($price_type) ? (object) $price_type : $price_type; $pricing_mode = $price_type->pricing_mode ?? 'per_person'; $is_per_group = ($pricing_mode === 'per_group'); $pricing_label = ''; if ($is_per_group) { if (!empty($price_type->min_pax) && !empty($price_type->max_pax)) { /* translators: 1: minimum pax for the group price, 2: maximum pax. */ $pricing_label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $price_type->min_pax, $price_type->max_pax); } elseif (!empty($price_type->max_pax)) { /* translators: %d: maximum pax for the group price. */ $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $price_type->max_pax); } elseif (!empty($price_type->min_pax)) { /* translators: %d: minimum pax for the group price. */ $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $price_type->min_pax); } else { $pricing_label = __('per group', 'yatra'); } } $pt_arr = (array) $price_type; $eff_before_dp = (float) ($price_type->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt_arr)); $display_price_type = $eff_before_dp; if (apply_filters('yatra_dynamic_pricing_enabled', false)) { $trip_id = is_object($trip) && method_exists($trip, 'getId') ? $trip->getId() : ($trip->id ?? 0); $pt_orig_dp = (float) ($price_type->original_price ?? 0); $pt_disc_dp = (float) ($price_type->discounted_price ?? $price_type->sale_price ?? 0); if ($pt_disc_dp <= 0) { $pt_disc_dp = $eff_before_dp; } $display_price_type = apply_filters('yatra_trip_display_price', $eff_before_dp, $trip_id, [ 'departure_date' => null, 'spots_remaining' => null, 'price_type_id' => $price_type->id ?? null, 'original_price' => $pt_orig_dp > 0 ? $pt_orig_dp : $eff_before_dp, 'discounted_price' => $pt_disc_dp > 0 ? $pt_disc_dp : $eff_before_dp, ]); } $age_info = ''; $age_min = $price_type->age_min ?? null; $age_max = $price_type->age_max ?? null; if ($age_min !== null || $age_max !== null) { if ($age_min !== null && $age_max !== null) { /* translators: 1: minimum age, 2: maximum age. */ $age_info = sprintf(__('(Age %1$d-%2$d)', 'yatra'), $age_min, $age_max); } elseif ($age_min !== null) { /* translators: %d: minimum age. */ $age_info = sprintf(__('(Age %d+)', 'yatra'), $age_min); } else { /* translators: %d: maximum age. */ $age_info = sprintf(__('(Up to age %d)', 'yatra'), $age_max); } } $price_html = '
'; $price_html .= '' . yatra_format_price((float) $display_price_type) . ''; if ($is_per_group) { $price_html .= '' . esc_html($pricing_label) . ''; } $price_html .= '
'; $input_id = 'traveler_' . $price_type->category_id; $max_travelers = is_object($trip) && method_exists($trip, 'getMaxTravelers') ? $trip->getMaxTravelers() : ($trip->max_travelers ?? 20); $pt_max_qty = (int) ($price_type->max_quantity ?: $max_travelers); // A per-group category in "block" overflow mode caps the party at // the max group size. In "per_block" mode the party may exceed it // (it just buys additional group blocks), so we keep the trip's // normal cap there. if ($is_per_group && !empty($price_type->max_pax) && (($price_type->group_overflow ?? 'block') !== 'per_block')) { $pt_max_qty = (int) $price_type->max_pax; } $pt_value = ($index === $default_index) ? 1 : 0; $traveler_rows[] = [ 'label' => $price_type->category_label ?: __('Traveler', 'yatra'), 'subtitle' => $age_info, 'price_html' => $price_html, 'row_attrs' => [ 'data-category-id' => $price_type->category_id, 'data-price' => $price_type->effective_price, 'data-pricing-mode' => $pricing_mode, 'data-group-overflow' => $price_type->group_overflow ?? 'block', 'data-max-pax' => $price_type->max_pax ?? '', ], 'minus_disabled' => ($index !== $default_index), 'plus_disabled' => false, 'minus_attrs' => [ 'data-target' => $input_id, /* translators: %s: traveler category label (e.g. "Adult", "Child"). */ 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $price_type->category_label), ], 'plus_attrs' => [ 'data-target' => $input_id, /* translators: %s: traveler category label (e.g. "Adult", "Child"). */ 'aria-label' => sprintf(__('Increase %s', 'yatra'), $price_type->category_label), ], 'input_attrs' => [ 'id' => $input_id, 'name' => 'travelers[' . $price_type->category_id . ']', 'value' => $pt_value, 'min' => 0, 'max' => $pt_max_qty, 'data-category' => $price_type->category_id, 'data-category-label' => $price_type->category_label, 'data-price' => $price_type->effective_price, 'data-pricing-mode' => $pricing_mode, 'data-group-overflow' => $price_type->group_overflow ?? 'block', 'data-max-pax' => $price_type->max_pax ?? '', ], ]; } // Generate display text with all categories and their default values $display_parts = []; foreach ($trip->price_types as $index => $price_type) { $category_label = $price_type->category_label ?? __('Traveler', 'yatra'); $default_value = ($index === $default_index) ? 1 : 0; if ($default_value > 0) { $display_parts[] = $category_label . ' x ' . $default_value; } } $traveler_display_text = !empty($display_parts) ? implode(', ', $display_parts) : __('Select travelers', 'yatra'); } else { // Regular Pricing: Simple adult/children setup $traveler_rows = [ [ 'label' => __('Adult', 'yatra'), 'subtitle' => __('(Age 13-99)', 'yatra'), 'price_html' => '', 'row_attrs' => [], 'minus_disabled' => false, 'plus_disabled' => false, 'minus_attrs' => [ 'data-target' => $context . '_adults', 'aria-label' => __('Decrease adults', 'yatra'), ], 'plus_attrs' => [ 'data-target' => $context . '_adults', 'aria-label' => __('Increase adults', 'yatra'), ], 'input_attrs' => [ 'id' => $context . '_adults', 'name' => $context . '_adults', 'value' => 1, 'min' => 1, 'max' => 20, ], ], [ 'label' => __('Child', 'yatra'), 'subtitle' => __('(Age 4-12)', 'yatra'), 'price_html' => '', 'row_attrs' => [], 'minus_disabled' => true, 'plus_disabled' => false, 'minus_attrs' => [ 'data-target' => $context . '_children', 'aria-label' => __('Decrease children', 'yatra'), ], 'plus_attrs' => [ 'data-target' => $context . '_children', 'aria-label' => __('Increase children', 'yatra'), ], 'input_attrs' => [ 'id' => $context . '_children', 'name' => $context . '_children', 'value' => 0, 'min' => 0, 'max' => 10, ], ], ]; $traveler_display_text = __('Adult x 1', 'yatra'); } return [ 'has_traveler_pricing' => $has_traveler_pricing, 'traveler_rows' => $traveler_rows, 'traveler_display_text' => $traveler_display_text, 'context' => $context, ]; } /** * Prepare traveler selector data for availability section * * @param object $trip Trip data * @param array $card Availability card data * @param string $item_id Item ID * @param int $trip_id Trip ID * @param int $seats_available Seats available * @param int $max_travelers Max travelers * @param bool $dp_enabled Dynamic pricing enabled * @param array $initial_travelers Initial traveler counts from request (category_id => count) * @return array Traveler selector data */ public static function prepareAvailabilityTravelerSelectorData($trip, array $card, string $item_id, int $trip_id, int $seats_available, int $max_travelers, bool $dp_enabled, array $initial_travelers = []): array { $card_pricing_type = $card['pricing_type'] ?? 'regular'; $card_price_types = []; // Always use enriched trip-level price_types for traveler-based pricing if ($card_pricing_type === 'traveler_based' && !empty($trip->price_types)) { $card_price_types = $trip->price_types; } $has_traveler_pricing = ($card_pricing_type === 'traveler_based' && !empty($card_price_types)); $traveler_rows = []; if ($has_traveler_pricing) { // Normalize price_types $normalized_price_types = []; foreach ($card_price_types as $pt) { if (is_array($pt)) { $normalized_price_types[] = (object) $pt; } else { $normalized_price_types[] = $pt; } } // Build display text from initial travelers if provided $display_parts = []; // Default selection: if no initial travelers, pick admin-default category (else first) $default_category_id = null; foreach ($normalized_price_types as $pt_candidate) { if (!is_object($pt_candidate)) { continue; } if (!empty($pt_candidate->is_default) && !empty($pt_candidate->category_id)) { $default_category_id = (int) $pt_candidate->category_id; break; } } foreach ($normalized_price_types as $pt_index => $pt) { $pt_min = isset($pt->age_min) ? (int) $pt->age_min : 0; $pt_max = isset($pt->age_max) ? (int) $pt->age_max : 99; $pt_label = $pt->category_label ?? $pt->label ?? __('Traveler', 'yatra'); /* translators: 1: minimum age, 2: maximum age. */ $pt_age_text = ($pt_min > 0 || $pt_max < 99) ? sprintf(__('(Age %1$d-%2$d)', 'yatra'), $pt_min, $pt_max) : ''; // Use initial traveler count if provided, otherwise use default $pt_category_id = $pt->category_id ?? $pt_index; $pt_default = isset($initial_travelers[$pt_category_id]) ? (int) $initial_travelers[$pt_category_id] : (($default_category_id !== null && (int) $pt_category_id === (int) $default_category_id) ? 1 : (($default_category_id === null && $pt_index === 0) ? 1 : 0)); $pt_price = 0; if (isset($pt->effective_price) && $pt->effective_price > 0) { $pt_price = (float) $pt->effective_price; } elseif (isset($pt->sale_price) && $pt->sale_price > 0) { $pt_price = (float) $pt->sale_price; } elseif (isset($pt->discounted_price) && $pt->discounted_price > 0) { $pt_price = (float) $pt->discounted_price; } elseif (isset($pt->original_price) && $pt->original_price > 0) { $pt_price = (float) $pt->original_price; } // Apply dynamic pricing to traveler category prices if ($dp_enabled && $pt_price > 0) { $pt_orig_dp = (float) ($pt->original_price ?? 0); $pt_disc_dp = (float) ($pt->discounted_price ?? $pt->sale_price ?? $pt->effective_price ?? 0); if ($pt_disc_dp <= 0) { $pt_disc_dp = $pt_price; } $pt_price = apply_filters('yatra_availability_price', $pt_price, $trip_id, [ 'departure_date' => $card['date'] ?? null, 'spots_remaining' => $card['spots_remaining'] ?? null, 'availability_id' => $item_id, 'price_type_id' => $pt->id ?? ($pt->price_type_id ?? null), 'original_price' => $pt_orig_dp > 0 ? $pt_orig_dp : $pt_price, 'discounted_price' => $pt_disc_dp > 0 ? $pt_disc_dp : $pt_price, ]); } $pt_category_id = $pt->category_id ?? $pt_index; $pt_min_qty = 0; $pt_max_qty = (int) min($seats_available, $max_travelers); $pt_pricing_mode = $pt->pricing_mode ?? 'per_person'; $pt_is_per_group = ($pt_pricing_mode === 'per_group'); // Cap a per-group "block" category at its max group size (still // bounded by seats). "per_block" mode may exceed it, so skip. if ($pt_is_per_group && !empty($pt->max_pax) && (($pt->group_overflow ?? 'block') !== 'per_block')) { $pt_max_qty = (int) min($pt_max_qty, (int) $pt->max_pax); } // Build pricing label $pricing_label = ''; if ($pt_is_per_group) { if (!empty($pt->min_pax) && !empty($pt->max_pax)) { /* translators: 1: minimum pax for the group price, 2: maximum pax. */ $pricing_label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $pt->min_pax, $pt->max_pax); } elseif (!empty($pt->max_pax)) { /* translators: %d: maximum pax for the group price. */ $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $pt->max_pax); } elseif (!empty($pt->min_pax)) { /* translators: %d: minimum pax for the group price. */ $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $pt->min_pax); } else { $pricing_label = __('per group', 'yatra'); } } $price_html = '
'; $price_html .= '' . yatra_format_price($pt_price) . ''; if ($pt_is_per_group) { $price_html .= '' . esc_html($pricing_label) . ''; } $price_html .= '
'; $traveler_rows[] = [ 'label' => $pt_label, 'subtitle' => $pt_age_text, 'price_html' => $price_html, 'row_attrs' => [ 'data-category-id' => $pt_category_id, 'data-price' => $pt_price, 'data-pricing-mode' => $pt_pricing_mode, 'data-group-overflow' => $pt->group_overflow ?? 'block', 'data-max-pax' => $pt->max_pax ?? '', ], 'minus_disabled' => ($pt_default <= 0), 'plus_disabled' => false, 'minus_attrs' => [ 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id, /* translators: %s: traveler category label (e.g. "Adult", "Child"). */ 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $pt_label), ], 'plus_attrs' => [ 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id, /* translators: %s: traveler category label (e.g. "Adult", "Child"). */ 'aria-label' => sprintf(__('Increase %s', 'yatra'), $pt_label), ], 'input_attrs' => [ 'data-item' => $item_id, 'data-category' => $pt_category_id, 'data-price' => $pt_price, 'data-pricing-mode' => $pt_pricing_mode, 'data-group-overflow' => $pt->group_overflow ?? 'block', 'data-max-pax' => $pt->max_pax ?? '', 'value' => $pt_default, 'min' => $pt_min_qty, 'max' => $pt_max_qty, ], ]; // Build display text parts if ($pt_default > 0) { $display_parts[] = $pt_label . ' x ' . $pt_default; } } // Generate display text from parts $traveler_display_text = !empty($display_parts) ? implode(', ', $display_parts) : __('Select travelers', 'yatra'); } return [ 'has_traveler_pricing' => $has_traveler_pricing, 'traveler_rows' => $traveler_rows, 'traveler_display_text' => $traveler_display_text ?? __('Adult x 1', 'yatra'), 'item_id' => $item_id, ]; } /** * Get navigation item for a specific tab * * @param object $tab Tab configuration * @param object $trip Trip object * @return array|null Navigation item data */ private static function getNavigationItemForTab($tab, $trip) { // Only check if tab is enabled - content existence is handled by templates // All tabs should appear in navigation if enabled, regardless of content // Map content types to icons and hrefs $icon_map = [ 'overview' => 'book', 'itinerary' => 'calendar', 'included_excluded' => 'check', 'location' => 'map-pin', 'gallery' => 'camera', 'important_info' => 'info', 'downloads' => 'download', 'faq' => 'help-circle', 'trip_story' => 'book', 'what_makes_special' => 'star', 'testimonials' => 'message-circle', 'custom' => 'book' ]; $href_map = [ 'overview' => '#overview', 'itinerary' => '#itinerary', 'included' => '#included', 'location' => '#location', 'gallery' => '#gallery', 'important_info' => '#important-info', 'downloads' => '#downloads', 'faq' => '#faq', 'trip_story' => '#trip-story', 'what_makes_special' => '#what-makes-special', 'testimonials' => '#testimonials', 'custom' => '#custom' ]; // For custom tabs, use the actual tab ID to ensure unique anchors $href = isset($href_map[$tab->id]) ? $href_map[$tab->id] : '#' . $tab->id; // Use custom icon if available, otherwise fallback to default icon mapping $icon = 'book'; // default fallback $icon_data = null; if (isset($tab->icon) && !empty($tab->icon)) { // Handle both array and object formats for icon if (is_array($tab->icon)) { $icon_data = $tab->icon; } elseif (is_object($tab->icon)) { $icon_data = (array) $tab->icon; } elseif (is_string($tab->icon)) { // Convert string to icon data structure $icon_data = ['type' => 'icon', 'value' => $tab->icon]; } } else { // Fallback to default icon mapping $icon_data = ['type' => 'icon', 'value' => $icon_map[$tab->content_type] ?? 'book']; } return [ 'id' => $tab->id, 'label' => $tab->label, 'href' => $href, 'icon' => $icon_data ]; } /** * Merge database frontend_tabs with complete default array * Ensures all sections are always available in the backend * * @param array $db_tabs Database frontend_tabs data * @return array | object Merged frontend_tabs with all sections */ private static function mergeFrontendTabsWithDefaults($db_tabs) { $default_tabs = [ // Core sections (always present) (object) ['id' => 'overview', 'label' => 'Overview', 'enabled' => true, 'order' => 1, 'content_type' => 'overview', 'icon' => 'book'], (object) ['id' => 'itinerary', 'label' => 'Itinerary', 'enabled' => true, 'order' => 2, 'content_type' => 'itinerary', 'icon' => 'calendar'], (object) ['id' => 'included', 'label' => 'Included', 'enabled' => true, 'order' => 3, 'content_type' => 'included_excluded', 'icon' => 'check'], (object) ['id' => 'location', 'label' => 'Location', 'enabled' => true, 'order' => 4, 'content_type' => 'location', 'icon' => 'map-pin'], (object) ['id' => 'important_info', 'label' => 'Important Info', 'enabled' => true, 'order' => 5, 'content_type' => 'important_info', 'icon' => 'info'], // Conditional sections (enabled by default, shown conditionally on frontend) (object) ['id' => 'downloads', 'label' => 'Downloads', 'enabled' => true, 'order' => 6, 'content_type' => 'downloads', 'icon' => 'download'], (object) ['id' => 'faq', 'label' => 'FAQ', 'enabled' => true, 'order' => 7, 'content_type' => 'faq', 'icon' => 'help-circle'], (object) ['id' => 'trip_story', 'label' => 'Story', 'enabled' => true, 'order' => 8, 'content_type' => 'trip_story', 'custom_content' => '', 'icon' => 'book'], (object) ['id' => 'what_makes_special', 'label' => 'Special', 'enabled' => true, 'order' => 9, 'content_type' => 'what_makes_special', 'custom_content' => '', 'icon' => 'star'], (object) ['id' => 'testimonials', 'label' => 'Testimonials', 'enabled' => true, 'order' => 10, 'content_type' => 'testimonials', 'icon' => 'message-circle'], (object) ['id' => 'gallery', 'label' => 'Gallery', 'enabled' => false, 'order' => 11, 'content_type' => 'gallery', 'icon' => 'camera'], ]; // If no database data, return defaults if (empty($db_tabs) || !is_array($db_tabs)) { return $default_tabs; } // Create a map of database tabs by ID for easy lookup $db_tabs_map = []; foreach ($db_tabs as $db_tab) { // Handle both arrays and objects $tab_id = null; if (is_array($db_tab) && isset($db_tab['id'])) { $tab_id = $db_tab['id']; } elseif (is_object($db_tab) && isset($db_tab->id)) { $tab_id = $db_tab->id; } if ($tab_id) { $db_tabs_map[$tab_id] = $db_tab; } } // Merge defaults with database data $merged_tabs = []; foreach ($default_tabs as $default_tab) { $tab_id = $default_tab->id; if (isset($db_tabs_map[$tab_id])) { // Use database data, but ensure all required fields exist $db_tab = $db_tabs_map[$tab_id]; // Handle both arrays and objects $db_label = is_array($db_tab) ? ($db_tab['label'] ?? null) : ($db_tab->label ?? null); $db_enabled = is_array($db_tab) ? ($db_tab['enabled'] ?? null) : ($db_tab->enabled ?? null); $db_order = is_array($db_tab) ? ($db_tab['order'] ?? null) : ($db_tab->order ?? null); $db_custom_content = is_array($db_tab) ? ($db_tab['custom_content'] ?? null) : ($db_tab->custom_content ?? null); $db_icon = is_array($db_tab) ? ($db_tab['icon'] ?? null) : ($db_tab->icon ?? null); // Preserve full icon data structure $icon_data = null; if ($db_icon) { if (is_array($db_icon)) { $icon_data = $db_icon; } elseif (is_object($db_icon)) { $icon_data = (array) $db_icon; } elseif (is_string($db_icon)) { // Convert string to icon data structure $icon_data = ['type' => 'icon', 'value' => $db_icon]; } } else { // Use default icon as data structure $icon_data = ['type' => 'icon', 'value' => $default_tab->icon]; } $merged_tab = [ 'id' => $tab_id, 'label' => $db_label ?? $default_tab->label, 'enabled' => $db_enabled !== null ? filter_var($db_enabled, FILTER_VALIDATE_BOOLEAN) : $default_tab->enabled, 'order' => $db_order !== null ? (int) $db_order : $default_tab->order, // Always use the new content type from defaults to ensure consistency 'content_type' => $default_tab->content_type, 'icon' => $icon_data ]; // Add custom_content for custom tabs if exists if ($default_tab->content_type === 'custom') { $merged_tab['custom_content'] = $db_custom_content ?? $default_tab->custom_content ?? ''; } $merged_tabs[] = (object) $merged_tab; } else { // Use default for missing tabs $merged_tabs[] = (object) $default_tab; } } // Add custom tabs from database that aren't in the defaults foreach ($db_tabs_map as $tab_id => $db_tab) { // Skip if this tab was already processed in the defaults $found_in_defaults = false; foreach ($default_tabs as $default_tab) { if ($default_tab->id === $tab_id) { $found_in_defaults = true; break; } } // Handle both arrays and objects for content_type check $content_type = is_array($db_tab) ? ($db_tab['content_type'] ?? null) : ($db_tab->content_type ?? null); // If not found in defaults and it's a custom tab, add it if (!$found_in_defaults && $content_type === 'custom') { // Handle both arrays and objects for field access $db_label = is_array($db_tab) ? ($db_tab['label'] ?? null) : ($db_tab->label ?? null); $db_enabled = is_array($db_tab) ? ($db_tab['enabled'] ?? null) : ($db_tab->enabled ?? null); $db_order = is_array($db_tab) ? ($db_tab['order'] ?? null) : ($db_tab->order ?? null); $db_custom_content = is_array($db_tab) ? ($db_tab['custom_content'] ?? null) : ($db_tab->custom_content ?? null); $db_icon = is_array($db_tab) ? ($db_tab['icon'] ?? null) : ($db_tab->icon ?? null); $custom_tab = [ 'id' => $tab_id, 'label' => $db_label ?? __('Custom Tab', 'yatra'), 'enabled' => $db_enabled !== null ? filter_var($db_enabled, FILTER_VALIDATE_BOOLEAN) : true, 'order' => $db_order !== null ? (int) $db_order : 999, 'content_type' => 'custom', 'custom_content' => $db_custom_content ?? '', 'icon' => $db_icon ]; $merged_tabs[] = (object) $custom_tab; } } return $merged_tabs; } }