The archive attached an always-on _vc_featured NOT EXISTS join and a _vc_review_count join (plus SQL_CALC_FOUND_ROWS) to every request, which made /tours/ crawl/time out over a ~70k-tour catalogue. Default sort now orders by post date with no postmeta joins; price/rating sorts add a single meta_key join on demand; meta_query is only attached when a filter is active. Bump 1.8.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J4hNL1PSHxXbu94SCenpKj
828 lines
30 KiB
PHP
828 lines
30 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() {
|
|
// Priority 20 so Astra has already registered/enqueued its stylesheet;
|
|
// our CSS then loads after it and wins equal-specificity ties.
|
|
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ), 20 );
|
|
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. !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;}',
|
|
// Links must inherit the design colour (Astra colours links with its
|
|
// theme colour, which turned white card titles/destination names into
|
|
// coloured text). Buttons keep their own colour.
|
|
'.vc-app a:not(.btn){color:inherit;}',
|
|
'.vc-app .tour__text a{color:var(--c-teal);}',
|
|
// Real tour titles are far longer than the mockup\'s city names — clamp
|
|
// them and stop price/duration values wrapping mid-number.
|
|
'.vc-app .dcard__title{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}',
|
|
'.vc-app .titem__desc{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}',
|
|
'.vc-app .dcard__meta .meta__v{white-space:nowrap;}',
|
|
// 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, 'ignore_sticky_posts' => true );
|
|
|
|
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' );
|
|
}
|
|
// Sort. Performance: the default (newest) orders by post date only — no
|
|
// postmeta JOIN and no featured/review tiebreak — so the unfiltered
|
|
// archive stays fast even with a very large catalogue (tens of thousands
|
|
// of tours). Price/rating sorts add a single meta_key JOIN, on demand.
|
|
switch ( $f['sort'] ) {
|
|
case 'price_asc':
|
|
$vars['meta_key'] = '_vc_price_from';
|
|
$vars['orderby'] = 'meta_value_num';
|
|
$vars['order'] = 'ASC';
|
|
break;
|
|
case 'price_desc':
|
|
$vars['meta_key'] = '_vc_price_from';
|
|
$vars['orderby'] = 'meta_value_num';
|
|
$vars['order'] = 'DESC';
|
|
break;
|
|
case 'rating':
|
|
$vars['meta_key'] = '_vc_rating';
|
|
$vars['orderby'] = 'meta_value_num';
|
|
$vars['order'] = 'DESC';
|
|
break;
|
|
default:
|
|
$vars['orderby'] = 'date';
|
|
$vars['order'] = 'DESC';
|
|
}
|
|
|
|
// Only attach a meta_query when a filter is actually active, so the plain
|
|
// archive query carries no postmeta joins at all.
|
|
if ( ! empty( $meta_query ) ) {
|
|
if ( count( $meta_query ) > 1 ) {
|
|
$meta_query['relation'] = 'AND';
|
|
}
|
|
$vars['meta_query'] = $meta_query;
|
|
}
|
|
|
|
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">→</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;
|
|
}
|
|
}
|