- Theme design pages (About, Contact, FAQ, blog list) use the page's Featured Image (Posts page for the blog) and fall back to the design photo; no teal tint over a custom image - Plugin hero (homepage [viator_hero], [viator_listing] pages) prefers the hosting page's Featured Image over the Settings hero image - Theme 1.0.12, plugin 1.9.4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
642 lines
19 KiB
PHP
642 lines
19 KiB
PHP
<?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 );
|
|
}
|
|
|
|
/**
|
|
* Hero image *source* for the current view: an attachment ID when available
|
|
* (so it can be rendered responsively), otherwise a URL. Resolution order:
|
|
* 1. On a page (e.g. the homepage hosting [viator_hero]), its Featured Image.
|
|
* 2. On a term archive, that term's own image.
|
|
* 3. The "Hero image" chosen in Settings (attachment ID preferred).
|
|
* 4. Nothing.
|
|
*
|
|
* @return int|string Attachment ID, image URL, or '' .
|
|
*/
|
|
function vc_hero_image_source() {
|
|
if ( is_singular() && ! is_singular( 'viator_tour' ) ) {
|
|
$thumb = (int) get_post_thumbnail_id( get_queried_object_id() );
|
|
if ( $thumb ) {
|
|
return apply_filters( 'vc_hero_image_source', $thumb );
|
|
}
|
|
}
|
|
|
|
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 ) {
|
|
return apply_filters( 'vc_hero_image_source', $url );
|
|
}
|
|
}
|
|
}
|
|
|
|
$id = (int) vc_get_option( 'hero_image_id', 0 );
|
|
if ( $id ) {
|
|
return apply_filters( 'vc_hero_image_source', $id );
|
|
}
|
|
|
|
return apply_filters( 'vc_hero_image_source', (string) vc_get_option( 'hero_image', '' ) );
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// "img2": the v1 key cached empty results for 12h, which left cards blank
|
|
// long after their tours had photos. Empty results now expire in an hour.
|
|
$cache_key = 'vc_term_img2_' . $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 ) {
|
|
// A few recent tours in the term; the first with any photo wins. The
|
|
// photo is read in PHP (primary, else first gallery image) — the same
|
|
// fallback the tour cards use — rather than filtered in SQL.
|
|
$query = new WP_Query(
|
|
array(
|
|
'post_type' => 'viator_tour',
|
|
'post_status' => 'publish',
|
|
'posts_per_page' => 10,
|
|
'fields' => 'ids',
|
|
'no_found_rows' => true,
|
|
'update_post_term_cache' => false,
|
|
'tax_query' => array(
|
|
array(
|
|
'taxonomy' => $taxonomy,
|
|
'field' => 'term_id',
|
|
'terms' => $term_id,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
foreach ( $query->posts as $post_id ) {
|
|
$url = (string) get_post_meta( (int) $post_id, '_vc_primary_image_url', true );
|
|
if ( '' === $url ) {
|
|
$gallery = vc_json_decode_meta( get_post_meta( (int) $post_id, '_vc_gallery_image_urls', true ), array() );
|
|
$url = $gallery ? (string) reset( $gallery ) : '';
|
|
}
|
|
if ( '' !== $url ) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
set_transient( $cache_key, $url, '' !== $url ? 12 * HOUR_IN_SECONDS : 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, '/' );
|
|
}
|
|
|
|
/**
|
|
* Hero <img> markup. When the source is a WordPress media item (an attachment
|
|
* ID, or a URL that resolves to one), it is rendered with wp_get_attachment_image
|
|
* so the browser gets responsive `srcset`/`sizes` and device-appropriate files.
|
|
* Anything else (external URLs, bundled fallbacks) falls back to a plain <img>.
|
|
*
|
|
* @param int|string $src Attachment ID or image URL.
|
|
* @param string $class CSS class.
|
|
* @param bool $eager Prioritise loading (above-the-fold hero).
|
|
*/
|
|
function vc_hero_img_html( $src, $class = 'hero__img', $eager = true ) {
|
|
$loading = $eager ? 'eager' : 'lazy';
|
|
|
|
$id = is_numeric( $src ) ? (int) $src : (int) attachment_url_to_postid( (string) $src );
|
|
if ( $id ) {
|
|
$img = wp_get_attachment_image(
|
|
$id,
|
|
'full',
|
|
false,
|
|
array(
|
|
'class' => $class,
|
|
'loading' => $loading,
|
|
'alt' => '',
|
|
'sizes' => '100vw',
|
|
'fetchpriority' => $eager ? 'high' : 'auto',
|
|
)
|
|
);
|
|
if ( $img ) {
|
|
return $img;
|
|
}
|
|
}
|
|
|
|
if ( ! $src ) {
|
|
return '';
|
|
}
|
|
$fetch = $eager ? ' fetchpriority="high"' : '';
|
|
return '<img class="' . esc_attr( $class ) . '" src="' . esc_url( $src ) . '" alt="" loading="' . esc_attr( $loading ) . '"' . $fetch . '>';
|
|
}
|
|
|
|
/**
|
|
* 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 = '' ) {
|
|
// Strip an existing "i-" prefix (once), then re-add it. ltrim() with a
|
|
// charlist would eat any leading i/-, turning "instagram" into "nstagram".
|
|
$slug = preg_replace( '/^i-/', '', (string) $name );
|
|
$name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . $slug );
|
|
$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();
|
|
}
|