Files
hoponhopofftickets/viator-collector/includes/class-vc-display.php
2026-10-29 15:23:40 +03:30

889 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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_bus_tours',
'viator_about',
'viator_articles',
'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 100 so the theme (Astra + child) has already enqueued ALL of
// its stylesheets — including the parent style.css and the child's chrome
// CSS, which are enqueued by the theme at priority 20 and would otherwise
// print AFTER us (plugins run before themes at equal priority) and win
// equal-specificity ties. Loading last makes our design win those ties.
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ), 100 );
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. Declare every theme
// stylesheet that could fight ours as a dependency (when present) so our
// rules always print AFTER them and win equal-specificity ties. We enqueue
// at priority 100, so by now the theme (priority 20) has registered them.
$deps = array( 'vc-fonts' );
foreach ( array( 'astra-theme-css', 'astra-parent', 'hoho-chrome' ) as $handle ) {
if ( wp_style_is( $handle, 'registered' ) ) {
$deps[] = $handle;
}
}
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;}',
// display:block undoes Astra's `.site-content .ast-container{display:flex}`
// (≥922px), which otherwise sizes our content to its contents instead
// of filling the full-width container.
'.vc-fullwidth .site-content .ast-container,.vc-fullwidth .ast-container'
. '{max-width:none!important;padding-left:0!important;padding-right:0!important;display:block!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.vc-app .hero__media,.vc-app.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`). Every selector matches
// the doubled `.vc-app.vc-app` scope of the main stylesheet so these
// patches keep winning against it, not just against the theme.
'.vc-app.vc-app a{text-decoration:none;box-shadow:none;}',
// No !important on margin/padding: the doubled .vc-app.vc-app scope
// already out-specifies Astra's list rules, and !important here was
// steamrolling design lists that need their own spacing — e.g. the
// rail (<ul class="rail">) whose vertical padding gives the hover glow
// room. list-style stays !important (purely cosmetic; prose lists
// re-enable bullets via a higher-specificity rule).
'.vc-app.vc-app :is(ul,ol){list-style:none!important;margin:0;padding:0;}',
'.vc-app.vc-app li{margin:0;}',
// …but keep the tour "Overview" prose (the_content) readable.
'.vc-app.vc-app .tour__text a{text-decoration:underline;}',
'.vc-app.vc-app .tour__text ul{list-style:disc!important;margin:1em 0!important;padding-inline-start:1.5em!important;}',
'.vc-app.vc-app .tour__text ol{list-style:decimal!important;margin:1em 0!important;padding-inline-start:1.5em!important;}',
'.vc-app.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.
// …but not ring-badges: they set their own colour on the <a>, and this
// inherit rule out-specifies .ring-badge--top/--scroll, which turned
// the badge text white (inherited) on light backgrounds.
'.vc-app.vc-app a:not(.btn):not(.ring-badge){color:inherit;}',
'.vc-app.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.vc-app .dcard__title{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}',
'.vc-app.vc-app .titem__desc{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}',
// Real tour descriptions run long; the on-hover card blurb clamps to
// three lines with an ellipsis instead of spilling over the card.
'.vc-app.vc-app .dcard__desc p{display:-webkit-box;-webkit-line-clamp:3;line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}',
'.vc-app.vc-app .dcard__meta .meta__v{white-space:nowrap;}',
// Astra's page-builder layout centres/limits every entry-content child
// (max-width:none; margin:auto), which flattened the hero inset. Our
// sections are full-bleed, so re-assert 0 side-margins; !important
// beats Astra's 4-class selector. The hero keeps its small inset — the
// only section with an intentional side margin.
'.vc-app.vc-app>*{max-width:none!important;margin-inline:0!important;}',
// Heroes keep their small inset (rounded card look) — .hero on the
// home/archive pages and .hero-tour on the single-tour page. Without
// this they went full-bleed after the margin-inline:0 reset above.
'.vc-app.vc-app>.hero,.vc-app.vc-app>.hero-tour{margin:var(--page-inset)!important;}',
// Let vertical decorations (rotating badges, card glow, promo bus, FAQ
// watermark) escape, but keep the horizontal axis clipped so a
// full-bleed section or decorative element can't cause a mobile
// sideways scroll. overflow-x:clip (unlike hidden) does NOT force
// overflow-y to auto, so the y-axis stays truly visible.
'.vc-app.vc-app{overflow-x:clip;overflow-y:visible;}',
// Astra wraps the content in article/.entry-content/.ast-container,
// some of which clip overflow — that cut the card hover glow at the
// rail edges. Open them up on our pages only (page scroll stays
// contained by body.vc-fullwidth{overflow-x:hidden}).
'.vc-fullwidth .site-main,.vc-fullwidth #primary,.vc-fullwidth #content,'
. '.vc-fullwidth .site-content,.vc-fullwidth .ast-container,'
. '.vc-fullwidth .entry-content,.vc-fullwidth .site-main>article'
. '{overflow:visible!important;}',
// FAQ question buttons must stay flat — Astra gives <button> a shadow.
'.vc-app.vc-app .acc__q{box-shadow:none!important;}',
// Astra colours every heading on the element itself (h1–h6 and
// .entry-content :where(h1..h6)), beating our container-inherited
// colours. Make headings inherit again — no !important, so design
// classes (.hero__title, the white .tcard titles) keep their own.
'.vc-app.vc-app :is(h1,h2,h3,h4,h5,h6){color:inherit;}',
// Typography: force the design font over Astra inside our scopes.
'.vc-app.vc-app,.vc-app.vc-app h1,.vc-app.vc-app h2,.vc-app.vc-app h3,.vc-app.vc-app h4,.vc-app.vc-app h5,.vc-app.vc-app h6,'
. '.vc-app.vc-app p,.vc-app.vc-app a,.vc-app.vc-app span,.vc-app.vc-app li,.vc-app.vc-app button,.vc-app.vc-app input,'
. '.vc-app.vc-app select,.vc-app.vc-app textarea,.vc-app.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( 'viator_destination' ) && get_term_meta( get_queried_object_id(), '_vc_region', true ) ) {
$path = get_stylesheet_directory() . '/viator-collector/region.php';
return file_exists( $path ) ? $path : VC_PLUGIN_DIR . 'templates/region.php';
}
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;
}
}