Files
hoponhopofftickets/viator-collector/includes/class-vc-display.php
amir 67c88c795b fix: Astra style conflicts, full-width, responsive hero images, header search
Plugin (viator-collector 1.8.1):
- is_vc_page() now matches any Viator shortcode, so shortcode Pages (home)
  get the full-width no-sidebar canvas
- load vc-design.css after Astra + inline overrides: strip Astra page-builder
  article padding (removes extra gap above hero), full-bleed containers,
  enforce design font inside .vc-app
- vc_hero_img_html(): render hero from the WP media library with responsive
  srcset when the image is an attachment; used in listing + [viator_hero]
  ([viator_hero image_id=".."])

Theme (astra child):
- remove the user/profile header item
- working header search (expands, submits to the tours archive ?vc_q=)
- load chrome.css after Astra + enforce design font inside .hoho
- rebuild installable ZIP

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J4hNL1PSHxXbu94SCenpKj
2026-09-15 17:32:07 +03:30

801 lines
27 KiB
PHP

<?php
defined( 'ABSPATH' ) || exit;
class VC_Display {
const SHORTCODES = array(
'viator_tours',
'viator_listing',
'viator_hero',
'viator_hero_slider',
'viator_top_tours',
'viator_top_destinations',
'viator_top_attractions',
'viator_browse_activities',
'viator_faq',
'viator_testimonials',
'viator_tour_card',
'viator_itinerary',
'viator_inclusions',
'viator_exclusions',
'viator_booking_button',
'viator_duration',
'viator_rating',
'viator_gallery',
);
public static function init() {
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
add_action( 'template_redirect', array( __CLASS__, 'maybe_enrich_single' ) );
add_action( 'pre_get_posts', array( __CLASS__, 'filter_archive_query' ) );
add_filter( 'single_template', array( __CLASS__, 'single_template' ) );
add_filter( 'archive_template', array( __CLASS__, 'archive_template' ) );
add_filter( 'taxonomy_template', array( __CLASS__, 'taxonomy_template' ) );
// Full-width layout, applied only to Viator pages.
add_filter( 'body_class', array( __CLASS__, 'body_class' ) );
add_filter( 'astra_page_layout', array( __CLASS__, 'astra_page_layout' ) );
add_filter( 'astra_get_content_layout', array( __CLASS__, 'astra_content_layout' ) );
add_shortcode( 'viator_tours', array( __CLASS__, 'shortcode_tours' ) );
add_shortcode( 'viator_listing', array( __CLASS__, 'shortcode_listing' ) );
// `viator_top_tours` is registered by VC_Sections (landing-page design).
// `viator_top_destinations` is registered by VC_Sections (landing-page design).
// `viator_top_attractions` is registered by VC_Sections (landing-page design).
// `viator_browse_activities` is registered by VC_Sections (landing-page design).
add_shortcode( 'viator_tour_card', array( __CLASS__, 'shortcode_card' ) );
add_shortcode( 'viator_itinerary', array( __CLASS__, 'shortcode_itinerary' ) );
add_shortcode( 'viator_inclusions', array( __CLASS__, 'shortcode_inclusions' ) );
add_shortcode( 'viator_exclusions', array( __CLASS__, 'shortcode_exclusions' ) );
add_shortcode( 'viator_booking_button', array( __CLASS__, 'shortcode_booking_button' ) );
add_shortcode( 'viator_duration', array( __CLASS__, 'shortcode_duration' ) );
add_shortcode( 'viator_rating', array( __CLASS__, 'shortcode_rating' ) );
add_shortcode( 'viator_gallery', array( __CLASS__, 'shortcode_gallery' ) );
}
/**
* True on the tours archive, a Viator term archive, or a single tour.
* Everything layout-related keys off this so nothing leaks site-wide.
*/
public static function is_vc_page() {
if ( is_singular( 'viator_tour' )
|| is_post_type_archive( 'viator_tour' )
|| is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) {
return true;
}
// Any Page hosting a Viator shortcode (hero, sections, listing, …) also
// wants the full-width, no-sidebar canvas.
$post = ( is_singular() ) ? get_post() : null;
if ( ! $post ) {
return false;
}
foreach ( self::SHORTCODES as $shortcode ) {
if ( has_shortcode( $post->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 (fixes the extra
// top gap above the hero and the non-full-width sections).
'.vc-fullwidth .site-main>article,.vc-fullwidth .ast-page-builder-template .site-main>article{padding:0;margin:0;border:0;}',
'.vc-fullwidth #primary{margin:0;padding:0;}',
'.vc-fullwidth .site-content .ast-container{max-width:none;padding-inline:0;}',
'.vc-fullwidth .ast-container{max-width:none;}',
'.vc-fullwidth .entry-content{margin-block:0;}',
'.vc-fullwidth .site-content{padding:0;}',
// 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 '<div class="vc-app vc-listing">' . $inner . '</div>';
}
private static function post_has_shortcode() {
if ( ! is_singular() ) {
return false;
}
$post = get_post();
if ( ! $post ) {
return false;
}
foreach ( self::SHORTCODES as $shortcode ) {
if ( has_shortcode( $post->post_content, $shortcode ) ) {
return true;
}
}
return false;
}
public static function single_template( $template ) {
if ( is_singular( 'viator_tour' ) ) {
$path = get_stylesheet_directory() . '/viator-collector/single-viator_tour.php';
if ( file_exists( $path ) ) {
return $path;
}
$plugin = VC_PLUGIN_DIR . 'templates/single-viator_tour.php';
if ( file_exists( $plugin ) ) {
return $plugin;
}
}
return $template;
}
public static function archive_template( $template ) {
if ( is_post_type_archive( 'viator_tour' ) ) {
$path = get_stylesheet_directory() . '/viator-collector/archive-viator_tour.php';
if ( file_exists( $path ) ) {
return $path;
}
$plugin = VC_PLUGIN_DIR . 'templates/archive-viator_tour.php';
if ( file_exists( $plugin ) ) {
return $plugin;
}
}
return $template;
}
public static function taxonomy_template( $template ) {
if ( is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) {
$path = get_stylesheet_directory() . '/viator-collector/taxonomy-viator.php';
if ( file_exists( $path ) ) {
return $path;
}
$plugin = VC_PLUGIN_DIR . 'templates/taxonomy-viator.php';
if ( file_exists( $plugin ) ) {
return $plugin;
}
}
return $template;
}
public static function shortcode_tours( $atts ) {
$atts = shortcode_atts(
array(
'limit' => 12,
'destination' => '',
'tag' => '',
'category' => '',
'orderby' => 'date',
'order' => 'DESC',
),
$atts,
'viator_tours'
);
$primary_orderby = sanitize_key( $atts['orderby'] );
$primary_order = 'ASC' === strtoupper( $atts['order'] ) ? 'ASC' : 'DESC';
$args = array(
'post_type' => 'viator_tour',
'post_status' => 'publish',
'posts_per_page' => max( 1, absint( $atts['limit'] ) ),
);
if ( 'rand' === $primary_orderby ) {
$args['orderby'] = 'rand';
} else {
$args['meta_query'] = array(
'vc_reviews_sort' => array(
'key' => '_vc_review_count',
'type' => 'NUMERIC',
'compare' => 'EXISTS',
),
);
$args['orderby'] = array(
$primary_orderby => $primary_order,
'vc_reviews_sort' => 'DESC',
);
if ( 'date' !== $primary_orderby ) {
$args['orderby']['date'] = 'DESC';
}
}
$tax_query = array();
$tax_map = array(
'destination' => 'viator_destination',
'tag' => 'viator_tag',
'category' => 'viator_category',
);
foreach ( $tax_map as $att => $taxonomy ) {
if ( ! empty( $atts[ $att ] ) ) {
$tax_query[] = array(
'taxonomy' => $taxonomy,
'field' => 'slug',
'terms' => array_map( 'sanitize_title', array_map( 'trim', explode( ',', $atts[ $att ] ) ) ),
);
}
}
if ( $tax_query ) {
$args['tax_query'] = $tax_query;
}
return self::render_tour_grid( $args );
}
/**
* "Top Tours" section: a titled grid of tours, highest rated first.
*/
public static function shortcode_top_tours( $atts ) {
$atts = shortcode_atts(
array(
'limit' => 8,
'title' => __( 'Top Tours', 'viator-collector' ),
'eyebrow' => __( 'Hand-picked', 'viator-collector' ),
'view_all' => '',
'orderby' => 'rating',
),
$atts,
'viator_top_tours'
);
$args = array(
'post_type' => 'viator_tour',
'post_status' => 'publish',
'posts_per_page' => max( 1, absint( $atts['limit'] ) ),
);
if ( 'rating' === $atts['orderby'] ) {
$args['meta_key'] = '_vc_rating';
$args['orderby'] = 'meta_value_num';
$args['order'] = 'DESC';
} else {
$args['orderby'] = 'date';
$args['order'] = 'DESC';
}
$grid = self::render_tour_grid( $args );
if ( '' === $grid ) {
return '';
}
return self::render_section( 'vc-section--tours', $atts['eyebrow'], $atts['title'], $atts['view_all'], $grid );
}
/**
* "Top Destinations" section: a grid of destination term cards.
*/
public static function shortcode_top_destinations( $atts ) {
$atts = shortcode_atts(
array(
'count' => 8,
'ids' => '',
'title' => __( 'Top Destinations', 'viator-collector' ),
'eyebrow' => __( 'Explore', 'viator-collector' ),
'view_all' => '',
),
$atts,
'viator_top_destinations'
);
$terms = self::get_section_terms( 'viator_destination', $atts['count'], $atts['ids'] );
if ( empty( $terms ) ) {
return '';
}
ob_start();
echo '<div class="vc-destination-grid">';
foreach ( $terms as $term ) {
echo vc_get_template( 'card-destination.php', array( 'term' => $term ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</div>';
$grid = ob_get_clean();
return self::render_section( 'vc-section--destinations', $atts['eyebrow'], $atts['title'], $atts['view_all'], $grid );
}
/**
* "Top Attractions" section. Backed by top-rated tours rendered as large
* feature cards (repoint to a dedicated attraction taxonomy once ingested).
*/
public static function shortcode_top_attractions( $atts ) {
$atts = shortcode_atts(
array(
'limit' => 3,
'title' => __( 'Top Attractions', 'viator-collector' ),
'eyebrow' => __( 'Must see', 'viator-collector' ),
'view_all' => '',
),
$atts,
'viator_top_attractions'
);
$query = new WP_Query(
array(
'post_type' => 'viator_tour',
'post_status' => 'publish',
'posts_per_page' => max( 1, absint( $atts['limit'] ) ),
'meta_key' => '_vc_rating',
'orderby' => 'meta_value_num',
'order' => 'DESC',
)
);
if ( ! $query->have_posts() ) {
return '';
}
ob_start();
echo '<div class="vc-attraction-grid">';
while ( $query->have_posts() ) {
$query->the_post();
echo vc_get_template( 'card-attraction.php', array( 'post_id' => get_the_ID() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</div>';
wp_reset_postdata();
$grid = ob_get_clean();
return self::render_section( 'vc-section--attractions', $atts['eyebrow'], $atts['title'], $atts['view_all'], $grid );
}
/**
* "Browse By Activity" section: a list of activity (tag) rows.
*/
public static function shortcode_browse_activities( $atts ) {
$atts = shortcode_atts(
array(
'count' => 8,
'ids' => '',
'title' => __( 'Browse By Activity', 'viator-collector' ),
'eyebrow' => __( 'Find your style', 'viator-collector' ),
'view_all' => '',
),
$atts,
'viator_browse_activities'
);
$terms = self::get_section_terms( 'viator_tag', $atts['count'], $atts['ids'] );
if ( empty( $terms ) ) {
return '';
}
ob_start();
echo '<div class="vc-activity-list">';
foreach ( $terms as $term ) {
echo vc_get_template( 'card-activity.php', array( 'term' => $term ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</div>';
$grid = ob_get_clean();
return self::render_section( 'vc-section--activities', $atts['eyebrow'], $atts['title'], $atts['view_all'], $grid );
}
/**
* Shared: render a WP_Query of tours into the card grid markup.
*/
private static function render_tour_grid( array $args ) {
$query = new WP_Query( $args );
if ( ! $query->have_posts() ) {
wp_reset_postdata();
return '';
}
ob_start();
echo '<div class="vc-app vc-listing"><ul class="tgrid">';
while ( $query->have_posts() ) {
$query->the_post();
echo vc_get_template( 'card-tour.php', array( 'post_id' => get_the_ID(), 'variant' => 'wide' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo '</ul></div>';
wp_reset_postdata();
return ob_get_clean();
}
/**
* Shared: fetch terms for a section, either an explicit id list or the
* most-used terms in the taxonomy.
*/
private static function get_section_terms( $taxonomy, $count, $ids ) {
$args = array(
'taxonomy' => $taxonomy,
'hide_empty' => true,
'number' => max( 1, absint( $count ) ),
);
$ids = array_filter( array_map( 'absint', array_map( 'trim', explode( ',', (string) $ids ) ) ) );
if ( ! empty( $ids ) ) {
$args['include'] = $ids;
$args['orderby'] = 'include';
unset( $args['number'] );
} else {
$args['orderby'] = 'count';
$args['order'] = 'DESC';
}
$terms = get_terms( $args );
return ( is_wp_error( $terms ) || empty( $terms ) ) ? array() : $terms;
}
/**
* Shared: wrap inner markup in a titled, reveal-animated section.
*/
private static function render_section( $modifier, $eyebrow, $title, $view_all, $inner ) {
ob_start();
?>
<section class="vc-section <?php echo esc_attr( $modifier ); ?>">
<div class="vc-container">
<header class="vc-section__head vc-reveal">
<?php if ( $eyebrow ) : ?><span class="vc-eyebrow"><?php echo esc_html( $eyebrow ); ?></span><?php endif; ?>
<h2 class="vc-section__title"><?php echo esc_html( $title ); ?></h2>
<?php if ( $view_all ) : ?><a class="vc-view-all" href="<?php echo esc_url( $view_all ); ?>"><?php echo esc_html__( 'View all', 'viator-collector' ); ?> <span aria-hidden="true">&rarr;</span></a><?php endif; ?>
</header>
<?php echo $inner; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</section>
<?php
return ob_get_clean();
}
public static function shortcode_card( $atts ) {
$post_id = self::post_id_from_atts( $atts );
if ( ! $post_id ) {
return '';
}
$card = vc_get_template( 'card-tour.php', array( 'post_id' => $post_id, 'variant' => 'wide' ) );
return '<div class="vc-app vc-listing"><ul class="tgrid">' . $card . '</ul></div>';
}
public static function shortcode_itinerary( $atts ) {
return vc_get_template( 'itinerary.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) );
}
public static function shortcode_inclusions( $atts ) {
return vc_get_template( 'inclusions.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) );
}
public static function shortcode_exclusions( $atts ) {
return vc_get_template( 'exclusions.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) );
}
public static function shortcode_booking_button( $atts ) {
return vc_get_template( 'booking-button.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) );
}
public static function shortcode_duration( $atts ) {
return vc_get_template( 'duration.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) );
}
public static function shortcode_rating( $atts ) {
return vc_get_template( 'rating.php', array( 'post_id' => self::post_id_from_atts( $atts ) ) );
}
public static function shortcode_gallery( $atts ) {
$post_id = self::post_id_from_atts( $atts );
// Legacy installs may still have downloaded attachments.
$ids = vc_json_decode_meta( get_post_meta( $post_id, '_vc_gallery_image_ids', true ), array() );
if ( ! empty( $ids ) ) {
$out = '<div class="vc-gallery">';
foreach ( array_map( 'absint', $ids ) as $id ) {
$out .= wp_get_attachment_image( $id, 'large', false, array( 'class' => 'vc-gallery-image' ) );
}
$out .= '</div>';
return $out;
}
$urls = vc_json_decode_meta( get_post_meta( $post_id, '_vc_gallery_image_urls', true ), array() );
if ( empty( $urls ) ) {
return '';
}
$title = esc_attr( get_the_title( $post_id ) );
$out = '<div class="vc-gallery">';
foreach ( (array) $urls as $url ) {
$out .= '<img class="vc-gallery-image" src="' . esc_url( $url ) . '" alt="' . $title . '" loading="lazy">';
}
$out .= '</div>';
return $out;
}
private static function post_id_from_atts( $atts ) {
$atts = shortcode_atts( array( 'post_id' => get_the_ID() ), (array) $atts );
$post_id = absint( $atts['post_id'] );
return 'viator_tour' === get_post_type( $post_id ) ? $post_id : 0;
}
}