post_content, $shortcode ) ) { return true; } } return false; } /** * Marker class so the full-width CSS can be gated to our pages only. */ public static function body_class( $classes ) { if ( self::is_vc_page() ) { $classes[] = 'vc-fullwidth'; } return $classes; } /** * Astra: drop the sidebar on Viator pages. */ public static function astra_page_layout( $layout ) { return self::is_vc_page() ? 'no-sidebar' : $layout; } /** * Astra: use the "page-builder" content layout, which removes the * .ast-container max-width/padding for a true full-width canvas. */ public static function astra_content_layout( $layout ) { return self::is_vc_page() ? 'page-builder' : $layout; } /** * Lazily fetch and cache full product detail the first time a tour page is * viewed, so itinerary/inclusions/etc. are available without crawling * detail for the whole catalogue up front. */ public static function maybe_enrich_single() { if ( is_admin() || ! is_singular( 'viator_tour' ) ) { return; } $post_id = get_queried_object_id(); if ( ! $post_id || get_post_meta( $post_id, '_vc_detail_fetched', true ) ) { return; } $code = get_post_meta( $post_id, '_vc_viator_product_code', true ); if ( ! $code ) { return; } // Avoid repeated calls (and hammering on errors) for the same product. $lock = 'vc_enrich_' . $post_id; if ( get_transient( $lock ) ) { return; } set_transient( $lock, 1, 10 * MINUTE_IN_SECONDS ); ( new VC_Importer() )->enrich_from_detail( $post_id, $code ); } public static function enqueue_assets() { $is_single = is_singular( 'viator_tour' ); $is_archive = is_post_type_archive( 'viator_tour' ) || is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ); $has_short = self::post_has_shortcode(); if ( ! $is_single && ! $is_archive && ! $has_short ) { return; } // Design typeface. wp_enqueue_style( 'vc-fonts', 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;700;800&display=swap', array(), null ); // One scoped stylesheet (.vc-app) covers listing, single, and homepage // sections — ported from the "Hop On Hop Off" design. Depend on the Astra // theme stylesheet (when present) so our rules load AFTER it and win ties. $deps = array( 'vc-fonts' ); if ( wp_style_is( 'astra-theme-css', 'registered' ) ) { $deps[] = 'astra-theme-css'; } wp_enqueue_style( 'vc-design', VC_PLUGIN_URL . 'assets/css/vc-design.css', $deps, VC_VERSION ); wp_add_inline_style( 'vc-design', self::astra_overrides() ); // Front-end interactions: card rails, FAQ accordion, testimonials // marquee, date field, and auto-submit on quick filter toggles. wp_enqueue_script( 'vc-design', VC_PLUGIN_URL . 'assets/js/vc-design.js', array(), VC_VERSION, true ); // The SVG icon sprite (+ ring-badge path) is printed once in the footer. add_action( 'wp_footer', 'vc_print_icon_sprite' ); } /** * CSS that neutralises Astra's content spacing/width on Viator pages and * makes the design typography win over the theme. Injected after vc-design.css. */ private static function astra_overrides() { return implode( '', array( // Full-bleed: strip Astra's page-builder article padding/width so // the hero and sections reach the viewport edges. !important beats // Astra's own high-specificity rules (e.g. the 2em article padding // that leaves a gap above the hero). '.vc-fullwidth .site-main>article,' . '.vc-fullwidth .ast-page-builder-template .site-main>article,' . '.vc-fullwidth.ast-page-builder-template .site-main>article' . '{padding:0!important;margin:0!important;border:0!important;max-width:none!important;}', '.vc-fullwidth #primary,.vc-fullwidth #content{margin:0!important;padding:0!important;}', '.vc-fullwidth .site-content .ast-container,.vc-fullwidth .ast-container' . '{max-width:none!important;padding-left:0!important;padding-right:0!important;}', '.vc-fullwidth .entry-content{margin-block:0!important;}', '.vc-fullwidth .site-content{padding:0!important;}', // Guard against a stray horizontal scrollbar from any full-bleed child. 'body.vc-fullwidth{overflow-x:hidden;}', // A gradient stands in when no hero image is set, so the banner is // never blank. '.vc-app .hero__media,.vc-app .hero-tour{background:linear-gradient(120deg,#101317,#2cafc2);}', // Normalize: stop Astra from decorating our components. Its // .entry-content rules (underlined links, list bullets/indent) // out-specify our scoped base, so reset them here (!important beats // e.g. `.ast-single-post .entry-content a`). The .vc-app.vc-app // doubling lifts specificity without touching design class rules. '.vc-app a,.vc-app.vc-app a{text-decoration:none;box-shadow:none;}', '.vc-app :is(ul,ol),.vc-app.vc-app :is(ul,ol){list-style:none!important;margin:0!important;padding:0!important;}', '.vc-app li{margin:0!important;}', // …but keep the tour "Overview" prose (the_content) readable. '.vc-app .tour__text a{text-decoration:underline;}', '.vc-app .tour__text ul{list-style:disc!important;margin:1em 0!important;padding-inline-start:1.5em!important;}', '.vc-app .tour__text ol{list-style:decimal!important;margin:1em 0!important;padding-inline-start:1.5em!important;}', '.vc-app .tour__text li{margin:.25em 0!important;}', // Typography: force the design font over Astra inside our scopes. '.vc-app,.vc-app h1,.vc-app h2,.vc-app h3,.vc-app h4,.vc-app h5,.vc-app h6,' . '.vc-app p,.vc-app a,.vc-app span,.vc-app li,.vc-app button,.vc-app input,' . '.vc-app select,.vc-app textarea,.vc-app blockquote' . '{font-family:"Plus Jakarta Sans",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}', ) ); } /** * Read + sanitize the current filter state from the query string. Shared by * the query builder and the archive template so both stay in sync. */ public static function get_filter_state() { $slug_list = function ( $key ) { $raw = isset( $_GET[ $key ] ) ? wp_unslash( $_GET[ $key ] ) : array(); // phpcs:ignore WordPress.Security.NonceVerification $raw = is_array( $raw ) ? $raw : array( $raw ); return array_values( array_filter( array_map( 'sanitize_title', $raw ) ) ); }; $pmin = isset( $_GET['pmin'] ) ? max( 0, absint( $_GET['pmin'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification $pmax = isset( $_GET['pmax'] ) ? absint( $_GET['pmax'] ) : 1000; // phpcs:ignore WordPress.Security.NonceVerification // A coarse "price" range select (value like "50-150") maps to pmin/pmax. if ( isset( $_GET['price'] ) && '' !== $_GET['price'] ) { // phpcs:ignore WordPress.Security.NonceVerification $parts = array_pad( explode( '-', sanitize_text_field( wp_unslash( $_GET['price'] ) ) ), 2, '' ); // phpcs:ignore WordPress.Security.NonceVerification $pmin = absint( $parts[0] ); $pmax = ( '' === $parts[1] ) ? 1000 : absint( $parts[1] ); } return array( 'q' => isset( $_GET['vc_q'] ) ? sanitize_text_field( wp_unslash( $_GET['vc_q'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification 'dest' => $slug_list( 'dest' ), 'cat' => $slug_list( 'cat' ), 'dur' => array_values( array_intersect( array( 'half', 'full', 'multi' ), $slug_list( 'dur' ) ) ), 'pmin' => $pmin, 'pmax' => $pmax, 'rating' => isset( $_GET['rating'] ) ? floatval( $_GET['rating'] ) : 0, // phpcs:ignore WordPress.Security.NonceVerification 'freecancel' => ! empty( $_GET['freecancel'] ) ? 1 : 0, // phpcs:ignore WordPress.Security.NonceVerification 'bestdeal' => ! empty( $_GET['bestdeal'] ) ? 1 : 0, // phpcs:ignore WordPress.Security.NonceVerification 'sellout' => ! empty( $_GET['sellout'] ) ? 1 : 0, // phpcs:ignore WordPress.Security.NonceVerification 'sort' => isset( $_GET['sort'] ) ? sanitize_key( $_GET['sort'] ) : 'newest', // phpcs:ignore WordPress.Security.NonceVerification ); } /** * Apply the filter state to the main tours query (archive + taxonomy pages). */ public static function filter_archive_query( $query ) { if ( is_admin() || ! $query->is_main_query() ) { return; } $is_archive = $query->is_post_type_archive( 'viator_tour' ); $is_tax = $query->is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ); if ( ! $is_archive && ! $is_tax ) { return; } $base_tax = array(); foreach ( (array) $query->get( 'tax_query' ) as $k => $clause ) { if ( 'relation' !== $k && is_array( $clause ) ) { $base_tax[] = $clause; } } foreach ( self::build_query_vars( $base_tax ) as $key => $value ) { $query->set( $key, $value ); } } /** * Build WP_Query vars from the current filter state (query string) plus any * locked base tax constraints. Shared by the archive query and the * [viator_listing] shortcode so both filter/sort identically. */ public static function build_query_vars( array $base_tax = array() ) { $f = self::get_filter_state(); $vars = array( 'posts_per_page' => 12 ); if ( '' !== $f['q'] ) { $vars['s'] = $f['q']; } $tax_query = array(); foreach ( $base_tax as $clause ) { if ( is_array( $clause ) ) { $tax_query[] = $clause; } } if ( ! empty( $f['dest'] ) ) { $tax_query[] = array( 'taxonomy' => 'viator_destination', 'field' => 'slug', 'terms' => $f['dest'] ); } if ( ! empty( $f['cat'] ) ) { $tax_query[] = array( 'taxonomy' => 'viator_tag', 'field' => 'slug', 'terms' => $f['cat'] ); } if ( count( $tax_query ) > 1 ) { $tax_query['relation'] = 'AND'; } if ( ! empty( $tax_query ) ) { $vars['tax_query'] = $tax_query; } $meta_query = array(); if ( $f['pmin'] > 0 || $f['pmax'] < 1000 ) { $meta_query[] = array( 'key' => '_vc_price_from', 'value' => array( $f['pmin'], $f['pmax'] ), 'type' => 'DECIMAL', 'compare' => 'BETWEEN' ); } if ( ! empty( $f['dur'] ) ) { $dur = array( 'relation' => 'OR' ); foreach ( $f['dur'] as $bucket ) { if ( 'half' === $bucket ) { $dur[] = array( 'key' => '_vc_duration_minutes', 'value' => array( 1, 300 ), 'type' => 'NUMERIC', 'compare' => 'BETWEEN' ); } elseif ( 'full' === $bucket ) { $dur[] = array( 'key' => '_vc_duration_minutes', 'value' => array( 301, 1439 ), 'type' => 'NUMERIC', 'compare' => 'BETWEEN' ); } elseif ( 'multi' === $bucket ) { $dur[] = array( 'key' => '_vc_duration_minutes', 'value' => 1440, 'type' => 'NUMERIC', 'compare' => '>=' ); } } if ( count( $dur ) > 1 ) { $meta_query[] = $dur; } } if ( $f['rating'] > 0 ) { $meta_query[] = array( 'key' => '_vc_rating', 'value' => $f['rating'], 'type' => 'DECIMAL', 'compare' => '>=' ); } if ( $f['freecancel'] ) { $meta_query[] = array( 'key' => '_vc_free_cancellation', 'value' => '1' ); } // _vc_flags is a JSON array, so match the flag name inside it. if ( $f['bestdeal'] ) { $meta_query[] = array( 'key' => '_vc_flags', 'value' => 'SPECIAL_OFFER', 'compare' => 'LIKE' ); } if ( $f['sellout'] ) { $meta_query[] = array( 'key' => '_vc_flags', 'value' => 'LIKELY_TO_SELL_OUT', 'compare' => 'LIKE' ); } // Featured tours always sort first, whatever the chosen sort. OR/NOT // EXISTS keeps non-featured tours in the results too. $meta_query['vc_featured'] = array( 'relation' => 'OR', array( 'key' => '_vc_featured', 'value' => '1', 'compare' => '=' ), array( 'key' => '_vc_featured', 'compare' => 'NOT EXISTS' ), ); $orderby = array( 'vc_featured' => 'DESC' ); switch ( $f['sort'] ) { case 'price_asc': $meta_query['vc_price'] = array( 'key' => '_vc_price_from', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ); $orderby['vc_price'] = 'ASC'; break; case 'price_desc': $meta_query['vc_price'] = array( 'key' => '_vc_price_from', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ); $orderby['vc_price'] = 'DESC'; break; case 'rating': $meta_query['vc_rating_sort'] = array( 'key' => '_vc_rating', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ); $orderby['vc_rating_sort'] = 'DESC'; break; } // Keep the selected sort as the primary order, then prefer the more // established tour when two products otherwise rank the same. $meta_query['vc_reviews_sort'] = array( 'key' => '_vc_review_count', 'type' => 'NUMERIC', 'compare' => 'EXISTS', ); $orderby['vc_reviews_sort'] = 'DESC'; $orderby['date'] = 'DESC'; // stable tiebreak if ( count( $meta_query ) > 1 ) { $meta_query['relation'] = 'AND'; } $vars['meta_query'] = $meta_query; $vars['orderby'] = $orderby; return $vars; } /** * [viator_listing tag="airport-hotel-transfers,private-transfers" title="Transportation"] * * Full listing UI (filters, sort, pagination) locked to a tag/destination. * Use on a normal WordPress Page to build e.g. a "Transportation" page. */ public static function shortcode_listing( $atts ) { $atts = shortcode_atts( array( 'tag' => '', 'destination' => '', 'title' => __( 'Tours', 'viator-collector' ), 'subtitle' => '', ), $atts, 'viator_listing' ); $base_tax = array(); if ( '' !== $atts['tag'] ) { $base_tax[] = array( 'taxonomy' => 'viator_tag', 'field' => 'slug', 'terms' => array_filter( array_map( 'sanitize_title', explode( ',', $atts['tag'] ) ) ) ); } if ( '' !== $atts['destination'] ) { $base_tax[] = array( 'taxonomy' => 'viator_destination', 'field' => 'slug', 'terms' => array_filter( array_map( 'sanitize_title', explode( ',', $atts['destination'] ) ) ) ); } $vars = self::build_query_vars( $base_tax ); $vars['post_type'] = 'viator_tour'; $vars['post_status'] = 'publish'; $vars['paged'] = max( 1, (int) get_query_var( 'paged' ), (int) get_query_var( 'page' ), isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 ); // phpcs:ignore WordPress.Security.NonceVerification $query = new WP_Query( $vars ); $inner = vc_get_template( 'listing.php', array( 'vc_title' => $atts['title'], 'vc_subtitle' => $atts['subtitle'], 'vc_query' => $query, 'vc_form_action' => get_permalink(), ) ); // .vc-app is the scope every rule in vc-design.css hangs off. return '