Add viator-collector plugin (Hop On Hop Off front-end reskin)

Consolidate the plugin into the TourWebsite repository (its separate
nested git repo has been removed). Includes the full new-design reskin:
scoped design stylesheet, SVG icon sprite, rebuilt listing/single/section
templates, and homepage hero/FAQ/testimonials shortcodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J4hNL1PSHxXbu94SCenpKj
This commit is contained in:
2026-09-12 21:11:20 +03:30
parent 99e5413fee
commit 7ada8860f8
42 changed files with 9019 additions and 0 deletions

View File

@@ -0,0 +1,557 @@
<?php
defined( 'ABSPATH' ) || exit;
function vc_get_option( $key = '', $default = null ) {
$options = get_option( 'vc_settings', array() );
if ( '' === $key ) {
return is_array( $options ) ? $options : array();
}
return isset( $options[ $key ] ) ? $options[ $key ] : $default;
}
function vc_update_option( $key, $value ) {
$options = get_option( 'vc_settings', array() );
if ( ! is_array( $options ) ) {
$options = array();
}
$options[ $key ] = $value;
update_option( 'vc_settings', $options );
}
function vc_get_api_key() {
if ( defined( 'VIATOR_API_KEY' ) && VIATOR_API_KEY ) {
return (string) VIATOR_API_KEY;
}
return (string) vc_get_option( 'api_key', '' );
}
function vc_json_decode_meta( $value, $default = array() ) {
if ( is_array( $value ) || is_object( $value ) ) {
return $value;
}
if ( ! is_string( $value ) || '' === $value ) {
return $default;
}
$decoded = json_decode( $value, true );
return JSON_ERROR_NONE === json_last_error() ? $decoded : $default;
}
function vc_array_get( $array, $path, $default = null ) {
if ( ! is_array( $array ) ) {
return $default;
}
$segments = is_array( $path ) ? $path : explode( '.', (string) $path );
$value = $array;
foreach ( $segments as $segment ) {
if ( is_array( $value ) && array_key_exists( $segment, $value ) ) {
$value = $value[ $segment ];
} else {
return $default;
}
}
return $value;
}
function vc_first_non_empty( array $values, $default = '' ) {
foreach ( $values as $value ) {
if ( is_string( $value ) ) {
$value = trim( $value );
}
if ( ! empty( $value ) || '0' === $value || 0 === $value ) {
return $value;
}
}
return $default;
}
function vc_get_template( $template_name, $args = array() ) {
$template_name = ltrim( str_replace( array( '..', '\\' ), '', $template_name ), '/' );
$theme_path = trailingslashit( get_stylesheet_directory() ) . 'viator-collector/' . $template_name;
$plugin_path = VC_PLUGIN_DIR . 'templates/' . $template_name;
$template = file_exists( $theme_path ) ? $theme_path : $plugin_path;
if ( ! file_exists( $template ) ) {
return '';
}
if ( is_array( $args ) ) {
extract( $args, EXTR_SKIP );
}
ob_start();
include $template;
return ob_get_clean();
}
function vc_clean_text( $value ) {
if ( is_array( $value ) || is_object( $value ) ) {
return '';
}
return sanitize_text_field( wp_unslash( (string) $value ) );
}
function vc_tour_image_html( $post_id, $class = 'vc-tour-image' ) {
$post_id = absint( $post_id );
if ( $post_id && has_post_thumbnail( $post_id ) ) {
return get_the_post_thumbnail( $post_id, 'large', array( 'class' => $class, 'loading' => 'lazy' ) );
}
$url = get_post_meta( $post_id, '_vc_primary_image_url', true );
if ( ! $url ) {
$urls = vc_json_decode_meta( get_post_meta( $post_id, '_vc_gallery_image_urls', true ), array() );
$url = ! empty( $urls ) ? reset( $urls ) : '';
}
if ( ! $url ) {
return '';
}
return '<img class="' . esc_attr( $class ) . '" src="' . esc_url( $url ) . '" alt="' . esc_attr( get_the_title( $post_id ) ) . '" loading="lazy">';
}
/**
* Resolve an image URL for a taxonomy term: a custom URL set on the term,
* otherwise the primary image of a tour in that term. Result is cached.
*/
/**
* Banner image for the listing hero.
*
* Resolution order:
* 1. On a term archive, that term's own image (term meta `_vc_image_url`).
* 2. The "Hero image" chosen in Settings → Viator Collector.
* 3. Nothing — the CSS falls back to a gradient.
*
* Filterable via `vc_hero_image_url` so a theme can override without touching
* the plugin.
*/
function vc_hero_image_url() {
$url = '';
if ( is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) {
$term = get_queried_object();
if ( $term && ! is_wp_error( $term ) ) {
$url = vc_term_image_url( $term->term_id, $term->taxonomy );
}
}
if ( ! $url ) {
$url = (string) vc_get_option( 'hero_image', '' );
}
return apply_filters( 'vc_hero_image_url', $url );
}
function vc_term_image_url( $term_id, $taxonomy = '' ) {
$term_id = absint( $term_id );
if ( ! $term_id ) {
return '';
}
$custom = get_term_meta( $term_id, '_vc_image_url', true );
if ( $custom ) {
return $custom;
}
$cache_key = 'vc_term_img_' . $term_id;
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
if ( '' === $taxonomy ) {
$term = get_term( $term_id );
if ( $term && ! is_wp_error( $term ) ) {
$taxonomy = $term->taxonomy;
}
}
$url = '';
if ( $taxonomy ) {
$query = new WP_Query(
array(
'post_type' => 'viator_tour',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'update_post_term_cache' => false,
'tax_query' => array(
array(
'taxonomy' => $taxonomy,
'field' => 'term_id',
'terms' => $term_id,
),
),
'meta_query' => array(
array(
'key' => '_vc_primary_image_url',
'value' => '',
'compare' => '!=',
),
),
)
);
if ( ! empty( $query->posts ) ) {
$url = (string) get_post_meta( (int) $query->posts[0], '_vc_primary_image_url', true );
}
}
set_transient( $cache_key, $url, 12 * HOUR_IN_SECONDS );
return $url;
}
/**
* Number of published tours assigned to a term.
*/
function vc_term_tour_count( $term ) {
if ( is_object( $term ) && isset( $term->count ) ) {
return (int) $term->count;
}
return 0;
}
/**
* Render a 5-star rating widget from a numeric rating (0-5).
*/
function vc_render_stars( $rating ) {
$rating = (float) $rating;
if ( $rating <= 0 ) {
return '';
}
$pct = max( 0, min( 100, ( $rating / 5 ) * 100 ) );
return '<span class="vc-stars" role="img" aria-label="' . esc_attr( sprintf( __( 'Rated %s out of 5', 'viator-collector' ), number_format_i18n( $rating, 1 ) ) ) . '"><span class="vc-stars-fill" style="width:' . esc_attr( $pct ) . '%"></span></span>';
}
/**
* Human label for a Viator inclusion / exclusion / additional-info object.
* These come in several shapes, so try the known text fields in order.
*/
function vc_feature_text( $item ) {
if ( is_scalar( $item ) ) {
return (string) $item;
}
if ( ! is_array( $item ) ) {
return '';
}
return (string) vc_first_non_empty(
array(
vc_array_get( $item, 'description' ),
vc_array_get( $item, 'otherDescription' ),
vc_array_get( $item, 'typeDescription' ),
vc_array_get( $item, 'categoryDescription' ),
vc_array_get( $item, 'name' ),
vc_array_get( $item, 'title' ),
)
);
}
/**
* Format a duration given in minutes as "45 min" or "2h 15m".
*/
function vc_duration_human( $minutes ) {
$minutes = (int) $minutes;
if ( $minutes <= 0 ) {
return '';
}
if ( $minutes < 60 ) {
return sprintf( _n( '%d min', '%d min', $minutes, 'viator-collector' ), $minutes );
}
$hours = intdiv( $minutes, 60 );
$rem = $minutes % 60;
return $rem ? sprintf( '%dh %dm', $hours, $rem ) : sprintf( _n( '%d hour', '%d hours', $hours, 'viator-collector' ), $hours );
}
/**
* Normalise a Viator itinerary object into a flat list of steps.
* Handles unstructured itineraries, standard itineraryItems, and multi-day
* itineraries (days[].items[]).
*
* @return array[] Each step: description, duration (min), admission (bool), passby (bool).
*/
function vc_itinerary_steps( $itinerary ) {
if ( ! is_array( $itinerary ) ) {
return array();
}
$steps = array();
$unstructured = vc_first_non_empty(
array(
vc_array_get( $itinerary, 'unstructuredItinerary' ),
vc_array_get( $itinerary, 'unstructuredDescription' ),
)
);
if ( $unstructured ) {
$steps[] = array( 'description' => $unstructured );
}
$items = vc_array_get( $itinerary, 'itineraryItems', array() );
if ( empty( $items ) ) {
foreach ( (array) vc_array_get( $itinerary, 'days', array() ) as $day ) {
foreach ( (array) vc_array_get( $day, 'items', array() ) as $item ) {
$items[] = $item;
}
}
}
foreach ( (array) $items as $item ) {
if ( ! is_array( $item ) ) {
continue;
}
$description = vc_first_non_empty( array( vc_array_get( $item, 'description' ), vc_array_get( $item, 'name' ) ) );
if ( '' === (string) $description ) {
continue;
}
$steps[] = array(
'description' => $description,
'duration' => (int) vc_first_non_empty(
array(
vc_array_get( $item, 'duration.fixedDurationInMinutes' ),
vc_array_get( $item, 'duration.variableDurationFromMinutes' ),
),
0
),
'admission' => 'YES' === strtoupper( (string) vc_array_get( $item, 'admissionIncluded' ) ),
'passby' => (bool) vc_array_get( $item, 'passByWithoutStopping', false ),
);
}
return $steps;
}
/**
* Star string for a rating: gold stars + outlined stars, 5 total.
*/
/**
* Gold/grey star markup used by the listing + single templates.
* Matches the .stars / .stars.muted rules in vc-design.css.
*/
function vc_stars_html( $rating ) {
$full = (int) round( (float) $rating );
$full = max( 0, min( 5, $full ) );
$out = '';
if ( $full > 0 ) {
$out .= '<span class="stars">' . str_repeat( '★', $full ) . '</span>';
}
if ( $full < 5 ) {
$out .= '<span class="stars muted">' . str_repeat( '★', 5 - $full ) . '</span>';
}
return $out;
}
function vc_star_string( $rating ) {
$n = (int) round( (float) $rating );
$n = max( 0, min( 5, $n ) );
return str_repeat( '★', $n ) . str_repeat( '☆', 5 - $n );
}
/**
* Turn a product's flags array into display badges.
*
* @return array[] Each: array( 'label' => string, 'type' => blue|amber|red ).
*/
function vc_badges_from_flags( $flags ) {
$flags = is_array( $flags ) ? array_map( 'strtoupper', $flags ) : array();
$map = array(
'FREE_CANCELLATION' => array( 'label' => __( 'Free Cancellation', 'viator-collector' ), 'type' => 'blue' ),
'SPECIAL_OFFER' => array( 'label' => __( 'Best Deal', 'viator-collector' ), 'type' => 'amber' ),
'LIKELY_TO_SELL_OUT' => array( 'label' => __( 'Likely to Sell Out', 'viator-collector' ), 'type' => 'red' ),
);
$out = array();
foreach ( $map as $flag => $badge ) {
if ( in_array( $flag, $flags, true ) ) {
$out[] = $badge;
}
}
return $out;
}
/**
* Pick ONE badge for a tour card, by scarcity.
*
* Nearly every Viator product carries FREE_CANCELLATION, so using it as the
* card badge makes every card look identical. Only genuinely distinguishing
* signals earn the badge; free cancellation is shown as a trust line instead.
*
* @return array|null array( 'label' => string, 'type' => red|amber|blue )
*/
function vc_card_badge( $post_id ) {
$flags = vc_json_decode_meta( get_post_meta( $post_id, '_vc_flags', true ), array() );
$flags = is_array( $flags ) ? array_map( 'strtoupper', $flags ) : array();
if ( in_array( 'LIKELY_TO_SELL_OUT', $flags, true ) ) {
return array( 'label' => __( 'Likely to Sell Out', 'viator-collector' ), 'type' => 'red' );
}
if ( in_array( 'SPECIAL_OFFER', $flags, true ) ) {
return array( 'label' => __( 'Best Deal', 'viator-collector' ), 'type' => 'amber' );
}
// Derived from data we already store — highlights standouts.
$rating = (float) get_post_meta( $post_id, '_vc_rating', true );
$reviews = (int) get_post_meta( $post_id, '_vc_review_count', true );
if ( $rating >= 4.8 && $reviews >= 50 ) {
return array( 'label' => __( 'Top Rated', 'viator-collector' ), 'type' => 'blue' );
}
return null;
}
/**
* Map a Viator additional-info type to a Lucide icon name.
*/
function vc_infotype_icon( $type ) {
$type = strtoupper( (string) $type );
$map = array(
'STROLLER_ACCESSIBLE' => 'baby',
'INFANT_SEATS_AVAILABLE' => 'baby',
'INFANTS_MUST_SIT_ON_LAPS' => 'baby',
'PETS_WELCOME' => 'dog',
'SERVICE_ANIMALS_ALLOWED' => 'dog',
'PUBLIC_TRANSPORTATION_NEARBY' => 'bus',
'WHEELCHAIR_ACCESSIBLE' => 'accessibility',
'NOT_WHEELCHAIR_ACCESSIBLE' => 'accessibility',
'PHYSICAL_EASY' => 'dumbbell',
'PHYSICAL_MODERATE' => 'dumbbell',
'PHYSICAL_DIFFICULT' => 'dumbbell',
);
return isset( $map[ $type ] ) ? $map[ $type ] : 'info';
}
/**
* Currency symbol for an ISO code. Unknown codes fall back to the code itself
* (e.g. "PEN 45") rather than guessing.
*/
function vc_currency_symbol( $currency ) {
$currency = strtoupper( sanitize_text_field( (string) $currency ) );
if ( '' === $currency ) {
return '';
}
$symbols = apply_filters(
'vc_currency_symbols',
array(
'USD' => '$',
'EUR' => '€',
'GBP' => '£',
'JPY' => '¥',
'CNY' => '¥',
'AUD' => 'A$',
'CAD' => 'CA$',
'NZD' => 'NZ$',
'SGD' => 'S$',
'HKD' => 'HK$',
'MXN' => 'MX$',
'BRL' => 'R$',
'INR' => '₹',
'KRW' => '₩',
'THB' => '฿',
'TRY' => '₺',
'ILS' => '₪',
'ZAR' => 'R',
)
);
return isset( $symbols[ $currency ] ) ? $symbols[ $currency ] : $currency . ' ';
}
/**
* Format a price the way travel sites do: "$38", "$1,200", "$79.50".
*
* Trailing ".00" is dropped — Viator returns whole-dollar "from" prices for most
* products, and "USD 130.00" reads like an invoice, not a price tag.
*/
function vc_money( $amount, $currency ) {
if ( '' === $amount || null === $amount ) {
return '';
}
$amount = (float) $amount;
$decimals = ( 0.0 === fmod( $amount, 1 ) ) ? 0 : 2;
return vc_currency_symbol( $currency ) . number_format_i18n( $amount, $decimals );
}
/**
* Page numbers for the listing pager: a compact run with ellipsis gaps.
* Mirrors the mockup's pager (first, current±1, last). `null` marks an ellipsis.
*
* @return array<int|null>
*/
function vc_pager_range( $current, $total ) {
$current = max( 1, (int) $current );
$total = max( 1, (int) $total );
if ( $total <= 7 ) {
return range( 1, $total );
}
$want = array( 1, $current - 1, $current, $current + 1, $total );
$want = array_values( array_unique( array_filter( $want, function ( $n ) use ( $total ) {
return $n >= 1 && $n <= $total;
} ) ) );
sort( $want );
$out = array();
$prev = 0;
foreach ( $want as $n ) {
if ( $prev && $n - $prev > 1 ) {
$out[] = null; // ellipsis
}
$out[] = $n;
$prev = $n;
}
return $out;
}
/**
* URL to a bundled plugin asset, e.g. vc_asset_url( 'img/tower.png' ).
*/
function vc_asset_url( $rel ) {
return VC_PLUGIN_URL . 'assets/' . ltrim( (string) $rel, '/' );
}
/**
* Print the "Hop On Hop Off" SVG icon sprite once per request. The sprite
* holds every <symbol id="i-…"> plus the #ringPathScroll circular text path
* used by the rotating "BOOK NOW" badges. Referenced via vc_icon() / <use>.
*/
function vc_print_icon_sprite() {
static $done = false;
if ( $done ) {
return;
}
$done = true;
$path = VC_PLUGIN_DIR . 'assets/icons/sprite.svg';
if ( is_readable( $path ) ) {
echo file_get_contents( $path ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
}
}
/**
* Inline-icon markup referencing a sprite symbol.
* e.g. vc_icon( 'star', 'rating__star' ) => <svg class="icon rating__star">…</svg>
*/
function vc_icon( $name, $extra_class = '' ) {
$name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . ltrim( (string) $name, 'i-' ) );
$class = 'icon' . ( $extra_class ? ' ' . $extra_class : '' );
return '<svg class="' . esc_attr( $class ) . '" aria-hidden="true"><use href="#' . esc_attr( $name ) . '"></use></svg>';
}
/**
* Rotating ring badge (the circular "BOOK NOW ·" anchor in the design).
*
* @param string $label Repeating label text (e.g. "BOOK NOW · BOOK NOW · ").
* @param string $url Link target.
* @param string $variant Modifier suffix: card|top|scroll|accent.
* @param bool $blank Open in a new tab (external booking links).
*/
function vc_ring_badge( $label, $url, $variant = 'card', $blank = false ) {
$aria = trim( str_replace( '·', '', $label ) );
$target = $blank ? ' target="_blank" rel="nofollow noopener"' : '';
ob_start();
?>
<a class="ring-badge ring-badge--<?php echo esc_attr( $variant ); ?>" href="<?php echo esc_url( $url ); ?>" aria-label="<?php echo esc_attr( $aria ); ?>"<?php echo $target; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
<svg class="ring-badge__ring" viewBox="0 0 100 100" aria-hidden="true">
<text class="ring-badge__text"><textPath href="#ringPathScroll" startOffset="0"><?php echo esc_html( $label ); ?></textPath></text>
</svg>
<span class="ring-badge__dot"><?php echo vc_icon( 'arrow-ne' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
</a>
<?php
return ob_get_clean();
}