$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 '' . esc_attr( get_the_title( $post_id ) ) . ''; } /** * 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 term archive, that term's own image. * 2. The "Hero image" chosen in Settings (attachment ID preferred). * 3. Nothing. * * @return int|string Attachment ID, image URL, or '' . */ function vc_hero_image_source() { 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 ''; } /** * 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 .= '' . str_repeat( '★', $full ) . ''; } if ( $full < 5 ) { $out .= '' . str_repeat( '★', 5 - $full ) . ''; } 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 */ 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 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 . * * @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 ''; } /** * Print the "Hop On Hop Off" SVG icon sprite once per request. The sprite * holds every plus the #ringPathScroll circular text path * used by the rotating "BOOK NOW" badges. Referenced via vc_icon() / . */ 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' ) => … */ 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 ''; } /** * 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(); ?> >