Files
hoponhopofftickets/viator-collector/includes/helpers.php
Amir Bagheri 466ea0ac5f feat: one breadcrumb, same place and style on every inner page
- Plugin: vc_breadcrumb_trail()/vc_breadcrumb_html(); tours, region, city
  and tag pages show Home / (Region) / Page in the banner instead of the
  generic 'Tour & Travel' label; the tour page uses the same helper
- Theme: blog list shows Home / Blog; blog post title, breadcrumb and
  meta move into the banner like the other pages
- Shared style: title's colour (white in photo banners, black on About),
  softer links, current page bold and shortened if long - also on phones
- Plugin 1.9.6, theme 1.0.16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:32:09 +03:30

700 lines
22 KiB
PHP
Raw Permalink 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;
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>';
}
/**
* Breadcrumb trail for the current Viator view: list of [label, url] links
* (Home first); the current page itself is not included.
* tours archive Home
* region / city Home › Region (for a city)
* tag / category Home › Tours
* single tour Home › Region › City (its canonical city)
* other pages Home
*/
function vc_breadcrumb_trail() {
$trail = array( array( __( 'Home', 'viator-collector' ), home_url( '/' ) ) );
$city = null;
if ( is_tax( 'viator_destination' ) ) {
$term = get_queried_object();
if ( $term instanceof WP_Term && $term->parent ) {
$city = $term; // its parent (region) is added below
}
} elseif ( is_tax( array( 'viator_tag', 'viator_category' ) ) ) {
$trail[] = array( __( 'Tours', 'viator-collector' ), get_post_type_archive_link( 'viator_tour' ) );
} elseif ( is_singular( 'viator_tour' ) ) {
$post_id = get_queried_object_id();
$city_slug = class_exists( 'VC_Routing' ) ? VC_Routing::product_city( $post_id ) : '';
$city = $city_slug ? get_term_by( 'slug', $city_slug, 'viator_destination' ) : null;
if ( ! $city ) {
$terms = get_the_terms( $post_id, 'viator_destination' );
$city = ( $terms && ! is_wp_error( $terms ) ) ? $terms[0] : null;
}
}
if ( $city instanceof WP_Term ) {
if ( $city->parent ) {
$region = get_term( $city->parent, 'viator_destination' );
if ( $region instanceof WP_Term && ! is_wp_error( get_term_link( $region ) ) ) {
$trail[] = array( $region->name, get_term_link( $region ) );
}
}
// On a city archive the city is the current page, not a link.
if ( is_singular( 'viator_tour' ) && ! is_wp_error( get_term_link( $city ) ) ) {
$trail[] = array( $city->name, get_term_link( $city ) );
}
}
return apply_filters( 'vc_breadcrumb_trail', $trail );
}
/**
* Breadcrumb markup shared by every page (the theme's design pages print the
* same structure): links, then the current page in bold.
*/
function vc_breadcrumb_html( array $trail, $current, $extra_class = '' ) {
$out = '<nav class="crumbs' . ( $extra_class ? ' ' . esc_attr( $extra_class ) : '' ) . '" aria-label="' . esc_attr__( 'Breadcrumb', 'viator-collector' ) . '"><ol class="crumbs__list">';
foreach ( $trail as $item ) {
$out .= '<li><a href="' . esc_url( $item[1] ) . '">' . esc_html( $item[0] ) . '</a></li>';
}
return $out . '<li aria-current="page">' . esc_html( $current ) . '</li></ol></nav>';
}
/**
* 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();
}