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,217 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_API {
public function get( $endpoint ) {
return $this->request( 'GET', $endpoint );
}
public function post( $endpoint, array $body = array() ) {
return $this->request( 'POST', $endpoint, $body );
}
public function request( $method, $endpoint, array $body = array() ) {
$api_key = vc_get_api_key();
if ( '' === $api_key ) {
return new WP_Error( 'vc_missing_api_key', __( 'Viator API key is missing.', 'viator-collector' ) );
}
$this->throttle();
$base = untrailingslashit( esc_url_raw( vc_get_option( 'api_base_url', 'https://api.viator.com/partner' ) ) );
$endpoint = '/' . ltrim( $endpoint, '/' );
$url = $base . $endpoint;
$timeout = max( 5, absint( vc_get_option( 'timeout', 20 ) ) );
$args = array(
'method' => strtoupper( $method ),
'timeout' => $timeout,
'headers' => array(
'Accept' => 'application/json;version=2.0',
'Accept-Language' => sanitize_text_field( vc_get_option( 'language', 'en-US' ) ),
'Content-Type' => 'application/json',
'exp-api-key' => $api_key,
),
);
if ( ! empty( $body ) && 'GET' !== strtoupper( $method ) ) {
$args['body'] = wp_json_encode( $body );
}
$response = wp_remote_request( $url, $args );
if ( is_wp_error( $response ) ) {
VC_Logger::log( 'API request failed: ' . $response->get_error_message(), true );
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$raw = wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
$message = sprintf( __( 'Viator API request failed with HTTP status %d.', 'viator-collector' ), $code );
$data = json_decode( $raw, true );
if ( is_array( $data ) && ! empty( $data['message'] ) ) {
$message .= ' ' . sanitize_text_field( $data['message'] );
}
VC_Logger::log( $message, true );
return new WP_Error( 'vc_api_http_error', $message, array( 'status' => $code ) );
}
$data = json_decode( $raw, true );
if ( JSON_ERROR_NONE !== json_last_error() ) {
return new WP_Error( 'vc_api_json_error', __( 'Viator API returned invalid JSON.', 'viator-collector' ) );
}
return is_array( $data ) ? $data : array();
}
/**
* Block until we are under the configured per-minute request budget.
*
* Timestamps of recent requests are persisted in an option so the limit
* holds across the separate PHP processes that WP-Cron continuation
* ticks run in, not just within a single request.
*/
private function throttle() {
$limit = max( 1, (int) vc_get_option( 'rate_limit_per_minute', 25 ) );
$now = microtime( true );
$window = get_option( 'vc_rate_window', array() );
if ( ! is_array( $window ) ) {
$window = array();
}
$window = array_values(
array_filter(
$window,
static function ( $t ) use ( $now ) {
return ( $now - (float) $t ) < 60.0;
}
)
);
if ( count( $window ) >= $limit ) {
$oldest = min( $window );
$wait = 60.0 - ( $now - (float) $oldest ) + 0.05;
if ( $wait > 0 ) {
usleep( (int) min( 60.0, $wait ) * 1000000 );
}
$now = microtime( true );
$window = array_values(
array_filter(
$window,
static function ( $t ) use ( $now ) {
return ( $now - (float) $t ) < 60.0;
}
)
);
}
$window[] = $now;
update_option( 'vc_rate_window', $window, false );
}
/**
* Search products within a single destination. This is the only way to
* enumerate the catalogue on Basic access: a destination filter is
* required and results are returned in pages (max 50 per page).
*
* @return array|WP_Error Decoded response, typically with `products` and `totalCount`.
*/
public function search_products( $destination, $start = 1, $count = 50, $currency = 'USD', array $extra_filters = array() ) {
$filtering = array_merge( array( 'destination' => (string) $destination ), $extra_filters );
$body = array(
'filtering' => $filtering,
'pagination' => array(
'start' => max( 1, (int) $start ),
'count' => max( 1, min( 50, (int) $count ) ),
),
'currency' => sanitize_text_field( $currency ),
);
return $this->post( '/products/search', $body );
}
public function get_product( string $product_code ) {
return $this->get( '/products/' . rawurlencode( $product_code ) );
}
public function get_destinations() {
$cache_key = 'vc_destinations_' . md5( vc_get_option( 'language', 'en-US' ) );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$data = $this->get( '/destinations' );
if ( ! is_wp_error( $data ) ) {
set_transient( $cache_key, $data, 25 * HOUR_IN_SECONDS );
}
return $data;
}
public function get_tags() {
$cache_key = 'vc_tags_' . md5( vc_get_option( 'language', 'en-US' ) );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$data = $this->get( '/products/tags' );
if ( ! is_wp_error( $data ) ) {
set_transient( $cache_key, $data, 25 * HOUR_IN_SECONDS );
}
return $data;
}
/**
* Destination dictionary as a map of destinationId => name.
*/
public function get_destination_map() {
$data = $this->get_destinations();
if ( is_wp_error( $data ) ) {
return $data;
}
$map = array();
foreach ( (array) vc_array_get( $data, 'destinations', array() ) as $destination ) {
$id = vc_array_get( $destination, 'destinationId' );
$name = vc_first_non_empty( array( vc_array_get( $destination, 'name' ), vc_array_get( $destination, 'destinationName' ) ) );
if ( null !== $id && '' !== (string) $name ) {
$map[ (string) $id ] = $name;
}
}
return $map;
}
/**
* Tag dictionary as a map of tagId => localized name.
*/
public function get_tag_map() {
$data = $this->get_tags();
if ( is_wp_error( $data ) ) {
return $data;
}
$lang = vc_get_option( 'language', 'en-US' );
$short = strtolower( substr( (string) $lang, 0, 2 ) );
$map = array();
foreach ( (array) vc_array_get( $data, 'tags', array() ) as $tag ) {
$id = vc_array_get( $tag, 'tagId' );
$names = vc_array_get( $tag, 'allNamesByLocale', array() );
$name = '';
if ( is_array( $names ) && ! empty( $names ) ) {
if ( isset( $names[ $lang ] ) ) {
$name = $names[ $lang ];
} elseif ( isset( $names[ $short ] ) ) {
$name = $names[ $short ];
} elseif ( isset( $names['en'] ) ) {
$name = $names['en'];
} else {
$name = reset( $names );
}
}
if ( '' === (string) $name ) {
$name = vc_array_get( $tag, 'name', '' );
}
if ( null !== $id && '' !== (string) $name ) {
$map[ (string) $id ] = $name;
}
}
return $map;
}
}

View File

@@ -0,0 +1,85 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_CPT {
public static function init() {
add_action( 'init', array( __CLASS__, 'register' ) );
// "Featured" flag: sorts a tour first in search/filter results.
add_action( 'add_meta_boxes', array( __CLASS__, 'add_featured_box' ) );
add_action( 'save_post_viator_tour', array( __CLASS__, 'save_featured' ) );
add_filter( 'manage_viator_tour_posts_columns', array( __CLASS__, 'featured_column' ) );
add_action( 'manage_viator_tour_posts_custom_column', array( __CLASS__, 'featured_column_value' ), 10, 2 );
}
public static function register() {
register_post_type(
'viator_tour',
array(
'labels' => array(
'name' => __( 'Viator Tours', 'viator-collector' ),
'singular_name' => __( 'Viator Tour', 'viator-collector' ),
'add_new_item' => __( 'Add New Viator Tour', 'viator-collector' ),
'edit_item' => __( 'Edit Viator Tour', 'viator-collector' ),
),
'public' => true,
'has_archive' => true,
'rewrite' => array( 'slug' => 'tours' ),
'show_in_rest' => true,
'menu_icon' => 'dashicons-location-alt',
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ),
)
);
}
public static function add_featured_box() {
add_meta_box(
'vc_featured',
__( 'Featured', 'viator-collector' ),
array( __CLASS__, 'render_featured_box' ),
'viator_tour',
'side',
'high'
);
}
public static function render_featured_box( $post ) {
wp_nonce_field( 'vc_featured', 'vc_featured_nonce' );
$on = (int) get_post_meta( $post->ID, '_vc_featured', true );
?>
<label>
<input type="checkbox" name="vc_featured" value="1" <?php checked( $on, 1 ); ?>>
<?php echo esc_html__( 'Show this tour first in search & filter results.', 'viator-collector' ); ?>
</label>
<?php
}
public static function save_featured( $post_id ) {
if ( ! isset( $_POST['vc_featured_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['vc_featured_nonce'] ), 'vc_featured' ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
if ( ! empty( $_POST['vc_featured'] ) ) {
update_post_meta( $post_id, '_vc_featured', '1' );
} else {
delete_post_meta( $post_id, '_vc_featured' );
}
}
public static function featured_column( $cols ) {
$cols['vc_featured'] = __( 'Featured', 'viator-collector' );
return $cols;
}
public static function featured_column_value( $col, $post_id ) {
if ( 'vc_featured' === $col && get_post_meta( $post_id, '_vc_featured', true ) ) {
echo '<span class="dashicons dashicons-star-filled" style="color:#f5a623" title="' . esc_attr__( 'Featured', 'viator-collector' ) . '"></span>';
}
}
}

View File

@@ -0,0 +1,344 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* Drives the catalogue sync.
*
* Basic access has no product "modified-since" feed, so the catalogue is
* enumerated by crawling /products/search one destination at a time. Each
* daily run rebuilds the destination queue and re-crawls everything, updating
* existing posts in place (products are deduplicated by code within a run).
*
* Because the catalogue is large, a run is processed across many short ticks:
* each tick crawls a few search pages, saves its position in the queue, and
* (if work remains) schedules an immediate follow-up tick. Requests are
* throttled by VC_API to stay under the per-minute budget.
*/
class VC_Cron {
const HOOK = 'vc_cron_sync'; // Recurring daily trigger.
const CONTINUE = 'vc_cron_continue'; // Single follow-up tick within a run.
const LOCK = 'vc_sync_lock';
const PAGE_SIZE = 50; // Search maximum per page.
const MAX_RUN_ERRORS = 6;
public static function init() {
add_action( self::HOOK, array( __CLASS__, 'run' ) );
add_action( self::CONTINUE, array( __CLASS__, 'run' ) );
add_action( 'update_option_vc_settings', array( __CLASS__, 'settings_updated' ), 10, 2 );
}
public static function settings_updated( $old, $new ) {
if ( ! empty( $new['automatic_sync'] ) ) {
self::maybe_schedule();
} else {
self::unschedule();
}
}
public static function maybe_schedule() {
if ( ! vc_get_option( 'automatic_sync', false ) ) {
return;
}
if ( ! wp_next_scheduled( self::HOOK ) ) {
wp_schedule_event( time() + MINUTE_IN_SECONDS, 'daily', self::HOOK );
}
}
public static function unschedule() {
foreach ( array( self::HOOK, self::CONTINUE ) as $hook ) {
$timestamp = wp_next_scheduled( $hook );
while ( $timestamp ) {
wp_unschedule_event( $timestamp, $hook );
$timestamp = wp_next_scheduled( $hook );
}
}
}
/**
* Abort any in-progress run and clear progress so the next run restarts.
*/
public static function reset() {
delete_option( 'vc_ingest_in_progress' );
delete_option( 'vc_crawl_run_id' );
delete_option( 'vc_crawl_queue' );
delete_option( 'vc_crawl_start' );
delete_option( 'vc_ingest_totals' );
delete_option( 'vc_ingest_error_count' );
delete_option( 'vc_log_last_count' );
delete_transient( self::LOCK );
VC_Logger::info( 'Ingestion progress reset.' );
}
/**
* Run one tick of the current sync run, starting a new run if idle.
*/
public static function run() {
if ( get_transient( self::LOCK ) ) {
VC_Logger::log( 'Sync tick skipped: another tick holds the lock.' );
return;
}
set_transient( self::LOCK, 1, 3 * MINUTE_IN_SECONDS );
// Keep working even if the triggering HTTP request goes away.
if ( function_exists( 'ignore_user_abort' ) ) {
ignore_user_abort( true );
}
try {
$importer = new VC_Importer();
if ( ! get_option( 'vc_ingest_in_progress' ) ) {
if ( is_wp_error( self::start_run( $importer ) ) ) {
return;
}
} else {
$importer->load_dictionaries();
}
self::process_tick( $importer, self::deadline() );
} finally {
delete_transient( self::LOCK );
// Spawn the next tick only after the lock is free, so the loopback
// request does not collide with this one.
if ( wp_next_scheduled( self::CONTINUE ) ) {
self::spawn();
}
}
}
/**
* Wall-clock time by which a tick must stop, so it never hits the PHP
* max_execution_time and dies mid-page (which would strand the lock).
*/
private static function deadline() {
$max = (int) ini_get( 'max_execution_time' );
$budget = ( $max > 0 ) ? min( 25, max( 8, $max - 5 ) ) : 25;
return microtime( true ) + $budget;
}
/**
* Products requested per search page (Viator caps this at 50).
*/
private static function page_size() {
return max( 1, min( 50, absint( vc_get_option( 'products_per_page', 50 ) ) ) );
}
/**
* Fire a non-blocking loopback request so WP-Cron processes the queued
* continuation tick even when the site has no visitor traffic.
*/
private static function spawn() {
if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
// A real server cron is responsible for running events.
return;
}
$url = site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) );
wp_remote_post(
$url,
array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
'headers' => array( 'Cache-Control' => 'no-cache' ),
)
);
}
/**
* Initialise a run: refresh dictionaries and build the destination queue.
*/
private static function start_run( VC_Importer $importer ) {
$primed = $importer->prime_dictionaries();
if ( is_wp_error( $primed ) ) {
VC_Logger::error( 'Sync aborted, could not load dictionaries: ' . $primed->get_error_message() );
return $primed;
}
$map = get_transient( 'vc_destination_map' );
// A destination allowlist (comma-separated IDs) narrows the crawl to a
// focused set; empty means the whole catalogue.
$allow = array_filter( array_map( 'trim', explode( ',', (string) vc_get_option( 'crawl_destinations', '' ) ) ) );
if ( ! empty( $allow ) ) {
$queue = array_values( array_unique( $allow ) );
} else {
$queue = is_array( $map ) ? array_keys( $map ) : array();
}
if ( empty( $queue ) ) {
VC_Logger::error( 'Sync aborted: destination list is empty.' );
return new WP_Error( 'vc_no_destinations', 'No destinations to crawl.' );
}
update_option( 'vc_crawl_run_id', gmdate( 'Y-m-d\TH:i:s\Z' ) . '-' . wp_rand( 1000, 9999 ), false );
update_option( 'vc_crawl_queue', array_values( $queue ), false );
update_option( 'vc_crawl_start', 1, false );
update_option( 'vc_ingest_error_count', 0, false );
update_option( 'vc_log_last_count', 0, false );
update_option(
'vc_ingest_totals',
array(
'destinations_total' => count( $queue ),
'destinations_done' => 0,
'pages' => 0,
'imported' => 0,
'updated' => 0,
'duplicate' => 0,
'deactivated' => 0,
'failed' => 0,
),
false
);
update_option( 'vc_ingest_in_progress', 1, false );
update_option( 'vc_last_sync_started_at', current_time( 'mysql' ), false );
VC_Logger::info( sprintf( 'Sync run started — %d destinations queued.', count( $queue ) ) );
return true;
}
private static function process_tick( VC_Importer $importer, $deadline ) {
// With full-detail ON every product costs an extra API call, so each
// tick must stay small: one short page per cron minute. Without it,
// search-only is cheap and we can sweep many pages per tick.
if ( vc_get_option( 'fetch_full_detail', 0 ) ) {
$pages_per_run = 1;
$page_size = max( 1, min( 25, absint( vc_get_option( 'detail_batch_size', 15 ) ) ) );
} else {
$pages_per_run = max( 1, absint( vc_get_option( 'pages_per_run', 10 ) ) );
$page_size = self::page_size();
}
$run_id = (string) get_option( 'vc_crawl_run_id', '' );
$totals = get_option( 'vc_ingest_totals', array() );
for ( $i = 0; $i < $pages_per_run; $i++ ) {
$queue = get_option( 'vc_crawl_queue', array() );
if ( empty( $queue ) ) {
self::finish_run( $totals );
return;
}
// Stop cleanly before we risk a PHP timeout; resume next tick.
if ( $i > 0 && microtime( true ) >= $deadline ) {
break;
}
$destination = (string) $queue[0];
$start = max( 1, (int) get_option( 'vc_crawl_start', 1 ) );
$result = $importer->crawl_destination( $destination, $start, $page_size, $run_id );
if ( is_wp_error( $result ) ) {
$errors = (int) get_option( 'vc_ingest_error_count', 0 ) + 1;
update_option( 'vc_ingest_error_count', $errors, false );
VC_Logger::error( 'Error on destination ' . $destination . ' (failure ' . $errors . ' of ' . self::MAX_RUN_ERRORS . '): ' . $result->get_error_message() );
if ( $errors >= self::MAX_RUN_ERRORS ) {
VC_Logger::error( 'Sync aborted after too many errors. It will retry on the next scheduled run.' );
update_option( 'vc_ingest_in_progress', 0, false );
return;
}
self::schedule_continue( MINUTE_IN_SECONDS );
return;
}
update_option( 'vc_ingest_error_count', 0, false );
$totals['pages'] = (int) $totals['pages'] + 1;
$totals['imported'] = (int) $totals['imported'] + (int) $result['imported'];
$totals['updated'] = (int) $totals['updated'] + (int) $result['updated'];
$totals['duplicate'] = (int) $totals['duplicate'] + (int) $result['duplicate'];
$totals['deactivated'] = (int) $totals['deactivated'] + (int) $result['deactivated'];
$totals['failed'] = (int) $totals['failed'] + (int) $result['failed'];
$returned = (int) $result['count'];
$total = (int) $result['total'];
$consumed = ( $start - 1 ) + $returned;
$has_more = $returned >= $page_size && ( $total <= 0 || $consumed < $total );
if ( $has_more ) {
update_option( 'vc_crawl_start', $start + $page_size, false );
} else {
array_shift( $queue );
update_option( 'vc_crawl_queue', array_values( $queue ), false );
update_option( 'vc_crawl_start', 1, false );
$totals['destinations_done'] = (int) $totals['destinations_done'] + 1;
}
update_option( 'vc_ingest_totals', $totals, false );
self::maybe_log_progress( $totals );
if ( empty( $queue ) && ! $has_more ) {
self::finish_run( $totals );
return;
}
}
self::schedule_continue( 0 );
}
/**
* Emit an info entry each time the processed-product count crosses the
* configured step (default every 10 products).
*/
private static function maybe_log_progress( array $totals ) {
$processed = (int) $totals['imported'] + (int) $totals['updated'] + (int) $totals['deactivated'];
$step = max( 1, absint( vc_get_option( 'log_every', 10 ) ) );
$last = (int) get_option( 'vc_log_last_count', 0 );
if ( $processed - $last < $step ) {
return;
}
update_option( 'vc_log_last_count', $processed, false );
VC_Logger::info( sprintf(
'Progress: %d products processed (new %d, updated %d, deactivated %d, failed %d) across %d pages; %d/%d destinations done.',
$processed,
(int) $totals['imported'],
(int) $totals['updated'],
(int) $totals['deactivated'],
(int) $totals['failed'],
(int) $totals['pages'],
(int) $totals['destinations_done'],
(int) $totals['destinations_total']
) );
}
private static function finish_run( array $totals ) {
update_option( 'vc_ingest_in_progress', 0, false );
update_option( 'vc_last_sync_completed_at', current_time( 'mysql' ), false );
update_option( 'vc_last_sync_totals', $totals, false );
delete_option( 'vc_crawl_queue' );
delete_option( 'vc_crawl_start' );
VC_Logger::info( sprintf(
'Sync run complete: %d new, %d updated, %d deactivated, %d failed (%d pages, %d destinations).',
(int) $totals['imported'],
(int) $totals['updated'],
(int) $totals['deactivated'],
(int) $totals['failed'],
(int) $totals['pages'],
(int) $totals['destinations_total']
) );
}
private static function schedule_continue( $delay ) {
if ( ! wp_next_scheduled( self::CONTINUE ) ) {
// The loopback spawn in run() runs due events, so default to "now".
wp_schedule_single_event( time() + max( 0, (int) $delay ), self::CONTINUE );
}
}
/**
* Human-readable status used by the settings screen.
*/
public static function status() {
$queue = get_option( 'vc_crawl_queue', array() );
return array(
'in_progress' => (bool) get_option( 'vc_ingest_in_progress' ),
'queue_remaining' => is_array( $queue ) ? count( $queue ) : 0,
'started_at' => get_option( 'vc_last_sync_started_at', '' ),
'completed_at' => get_option( 'vc_last_sync_completed_at', '' ),
'totals' => get_option( 'vc_ingest_totals', array() ),
'last_totals' => get_option( 'vc_last_sync_totals', array() ),
'next_scheduled' => wp_next_scheduled( self::HOOK ),
);
}
}

View File

@@ -0,0 +1,759 @@
<?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_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() {
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
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;
}
// A Page hosting the full-listing shortcode also wants full width.
$post = ( is_singular() ) ? get_post() : null;
return $post && has_shortcode( $post->post_content, 'viator_listing' );
}
/**
* 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.
wp_enqueue_style( 'vc-design', VC_PLUGIN_URL . 'assets/css/vc-design.css', array( 'vc-fonts' ), VC_VERSION );
// 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' );
}
/**
* 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 );
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' );
}
// Featured tours always sort first, whatever the chosen sort. OR/NOT
// EXISTS keeps non-featured tours in the results too.
$meta_query['vc_featured'] = array(
'relation' => 'OR',
array( 'key' => '_vc_featured', 'value' => '1', 'compare' => '=' ),
array( 'key' => '_vc_featured', 'compare' => 'NOT EXISTS' ),
);
$orderby = array( 'vc_featured' => 'DESC' );
switch ( $f['sort'] ) {
case 'price_asc':
$meta_query['vc_price'] = array( 'key' => '_vc_price_from', 'type' => 'DECIMAL', 'compare' => 'EXISTS' );
$orderby['vc_price'] = 'ASC';
break;
case 'price_desc':
$meta_query['vc_price'] = array( 'key' => '_vc_price_from', 'type' => 'DECIMAL', 'compare' => 'EXISTS' );
$orderby['vc_price'] = 'DESC';
break;
case 'rating':
$meta_query['vc_rating_sort'] = array( 'key' => '_vc_rating', 'type' => 'DECIMAL', 'compare' => 'EXISTS' );
$orderby['vc_rating_sort'] = 'DESC';
break;
}
// Keep the selected sort as the primary order, then prefer the more
// established tour when two products otherwise rank the same.
$meta_query['vc_reviews_sort'] = array(
'key' => '_vc_review_count',
'type' => 'NUMERIC',
'compare' => 'EXISTS',
);
$orderby['vc_reviews_sort'] = 'DESC';
$orderby['date'] = 'DESC'; // stable tiebreak
if ( count( $meta_query ) > 1 ) {
$meta_query['relation'] = 'AND';
}
$vars['meta_query'] = $meta_query;
$vars['orderby'] = $orderby;
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( 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;
}
}

View File

@@ -0,0 +1,518 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_Importer {
private $api;
private $destination_map = array();
private $tag_map = array();
public function __construct( VC_API $api = null ) {
$this->api = $api ? $api : new VC_API();
}
/**
* Force-refresh the destination and tag dictionaries and cache them.
* Returns a WP_Error if either lookup fails (so a run can abort early).
*/
public function prime_dictionaries() {
$dest = $this->api->get_destination_map();
if ( is_wp_error( $dest ) ) {
return $dest;
}
$tags = $this->api->get_tag_map();
if ( is_wp_error( $tags ) ) {
return $tags;
}
set_transient( 'vc_destination_map', $dest, 25 * HOUR_IN_SECONDS );
set_transient( 'vc_tag_map', $tags, 25 * HOUR_IN_SECONDS );
$this->destination_map = $dest;
$this->tag_map = $tags;
return true;
}
/**
* Load dictionaries from cache, fetching them if the cache is cold.
*/
public function load_dictionaries() {
$dest = get_transient( 'vc_destination_map' );
if ( false === $dest ) {
$dest = $this->api->get_destination_map();
$dest = is_wp_error( $dest ) ? array() : $dest;
set_transient( 'vc_destination_map', $dest, 25 * HOUR_IN_SECONDS );
}
$tags = get_transient( 'vc_tag_map' );
if ( false === $tags ) {
$tags = $this->api->get_tag_map();
$tags = is_wp_error( $tags ) ? array() : $tags;
set_transient( 'vc_tag_map', $tags, 25 * HOUR_IN_SECONDS );
}
$this->destination_map = is_array( $dest ) ? $dest : array();
$this->tag_map = is_array( $tags ) ? $tags : array();
}
/**
* Crawl one page of products for a single destination.
*
* Each product is deduplicated by code within a run (via $run_id) so its
* full detail is only fetched once even though it surfaces under several
* destinations.
*
* @return array|WP_Error Per-page summary including totalCount and page size.
*/
public function crawl_destination( $destination_id, $start, $count, $run_id ) {
$currency = vc_get_option( 'currency', 'USD' );
$response = $this->api->search_products( $destination_id, $start, $count, $currency );
if ( is_wp_error( $response ) ) {
return $response;
}
$products = vc_array_get( $response, 'products', array() );
$summary = array(
'count' => 0,
'imported' => 0,
'updated' => 0,
'duplicate' => 0,
'deactivated' => 0,
'failed' => 0,
'total' => (int) vc_first_non_empty( array( vc_array_get( $response, 'totalCount' ), vc_array_get( $response, 'total' ) ), 0 ),
);
foreach ( (array) $products as $product ) {
if ( ! is_array( $product ) ) {
continue;
}
$summary['count']++;
$outcome = $this->process_search_product( $product, $destination_id, $run_id );
if ( isset( $summary[ $outcome ] ) ) {
$summary[ $outcome ]++;
}
}
return $summary;
}
/**
* Process one product returned by search: skip if already handled this
* run, otherwise optionally fetch full detail and create/update the post.
*
* @return string One of imported|updated|duplicate|deactivated|failed.
*/
private function process_search_product( array $product, $destination_id, $run_id ) {
$code = (string) vc_first_non_empty( array( vc_array_get( $product, 'productCode' ), vc_array_get( $product, 'code' ) ) );
if ( '' === $code ) {
return 'failed';
}
$existing = $this->find_existing_tour( $code );
if ( $existing && (string) get_post_meta( $existing, '_vc_synced_run', true ) === (string) $run_id ) {
// Already fully processed under another destination during this run.
return 'duplicate';
}
// Optionally enrich the summary with full product detail.
if ( vc_get_option( 'fetch_full_detail', 0 ) ) {
$detail = $this->api->get_product( $code );
if ( ! is_wp_error( $detail ) && is_array( $detail ) ) {
$product = $this->merge_detail( $product, $detail );
} elseif ( is_wp_error( $detail ) ) {
VC_Logger::log( 'Detail fetch failed (' . $code . '): ' . $detail->get_error_message() );
}
}
$product = $this->ensure_destination( $product, $destination_id );
$inactive = $this->is_product_inactive( $product );
$result = $this->create_or_update_tour( $product, $inactive );
if ( is_wp_error( $result ) ) {
VC_Logger::error( 'Product import failed (' . $code . '): ' . $result->get_error_message() );
return 'failed';
}
update_post_meta( (int) $result, '_vc_synced_run', (string) $run_id );
if ( $inactive ) {
return 'deactivated';
}
return $existing ? 'updated' : 'imported';
}
/**
* Overlay full-detail fields onto the search summary while keeping
* summary-only fields (pricing/destinations/tags) when detail omits them.
*/
private function merge_detail( array $summary, array $detail ) {
$merged = array_replace( $summary, $detail );
foreach ( array( 'pricing', 'destinations', 'tags', 'reviews', 'images' ) as $key ) {
if ( empty( $merged[ $key ] ) && ! empty( $summary[ $key ] ) ) {
$merged[ $key ] = $summary[ $key ];
}
}
return $merged;
}
/**
* Make sure the destination we searched is present on the product so its
* taxonomy term is assigned even if the payload omits it.
*/
private function ensure_destination( array $product, $destination_id ) {
$destinations = vc_array_get( $product, 'destinations', array() );
if ( ! is_array( $destinations ) ) {
$destinations = array();
}
foreach ( $destinations as $destination ) {
$ref = vc_first_non_empty( array( vc_array_get( $destination, 'ref' ), vc_array_get( $destination, 'destinationId' ), is_scalar( $destination ) ? $destination : null ) );
if ( (string) $ref === (string) $destination_id ) {
return $product;
}
}
$destinations[] = array( 'ref' => (string) $destination_id );
$product['destinations'] = $destinations;
return $product;
}
/**
* Fetch full product detail for an already-imported post and merge it into
* the stored summary. Used for lazy, on-view enrichment so we only pay the
* per-product request for tours that are actually viewed.
*
* @return bool True on success.
*/
public function enrich_from_detail( $post_id, $product_code ) {
$product_code = sanitize_text_field( (string) $product_code );
if ( '' === $product_code ) {
return false;
}
$detail = $this->api->get_product( $product_code );
if ( is_wp_error( $detail ) || ! is_array( $detail ) ) {
if ( is_wp_error( $detail ) ) {
VC_Logger::log( 'Detail enrichment failed (' . $product_code . '): ' . $detail->get_error_message() );
}
return false;
}
$this->load_dictionaries();
$summary = vc_json_decode_meta( get_post_meta( $post_id, '_vc_raw_response', true ), array() );
$product = is_array( $summary ) && ! empty( $summary ) ? $this->merge_detail( $summary, $detail ) : $detail;
$inactive = $this->is_product_inactive( $product );
$this->save_tour_meta( $post_id, $product, $inactive );
$this->assign_taxonomies( $post_id, $product );
$this->save_image_urls( $post_id, $product );
update_post_meta( $post_id, '_vc_detail_fetched', current_time( 'mysql' ) );
return true;
}
public function import_product_by_code( $product_code ) {
$product_code = sanitize_text_field( $product_code );
if ( '' === $product_code ) {
return new WP_Error( 'vc_missing_product_code', __( 'Product code is required.', 'viator-collector' ) );
}
$product = $this->api->get_product( $product_code );
if ( is_wp_error( $product ) ) {
return $product;
}
$this->load_dictionaries();
return $this->create_or_update_tour( $product, $this->is_product_inactive( $product ) );
}
public function find_existing_tour( $product_code ) {
$query = new WP_Query(
array(
'post_type' => 'viator_tour',
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'meta_query' => array(
array(
'key' => '_vc_viator_product_code',
'value' => sanitize_text_field( (string) $product_code ),
),
),
)
);
return ! empty( $query->posts ) ? (int) $query->posts[0] : 0;
}
public function create_or_update_tour( array $product, $inactive = false ) {
$product_code = vc_first_non_empty( array( vc_array_get( $product, 'productCode' ), vc_array_get( $product, 'code' ) ) );
if ( '' === (string) $product_code ) {
return new WP_Error( 'vc_missing_product_code', __( 'Product data is missing a Viator product code.', 'viator-collector' ) );
}
$title = vc_first_non_empty( array( vc_array_get( $product, 'title' ), vc_array_get( $product, 'name' ), $product_code ) );
$desc = vc_first_non_empty( array( vc_array_get( $product, 'description' ), vc_array_get( $product, 'overview' ) ) );
$short = vc_first_non_empty( array( vc_array_get( $product, 'shortDescription' ), vc_array_get( $product, 'summary' ) ) );
if ( $inactive ) {
$status = 'draft';
} else {
$status = vc_get_option( 'default_post_status', 'publish' );
if ( ! in_array( $status, array( 'publish', 'draft', 'pending' ), true ) ) {
$status = 'publish';
}
}
$postarr = array(
'post_type' => 'viator_tour',
'post_title' => wp_strip_all_tags( $title ),
'post_content' => wp_kses_post( $desc ),
'post_excerpt' => wp_kses_post( $short ),
'post_status' => $status,
);
$post_id = $this->find_existing_tour( $product_code );
if ( $post_id ) {
$postarr['ID'] = $post_id;
$result = wp_update_post( wp_slash( $postarr ), true );
} else {
$result = wp_insert_post( wp_slash( $postarr ), true );
}
if ( is_wp_error( $result ) ) {
return $result;
}
$post_id = (int) $result;
update_post_meta( $post_id, '_vc_viator_product_code', sanitize_text_field( (string) $product_code ) );
$this->save_tour_meta( $post_id, $product, $inactive );
$this->assign_taxonomies( $post_id, $product );
$this->save_image_urls( $post_id, $product );
return $post_id;
}
public function save_tour_meta( $post_id, array $product, $inactive = false ) {
$price = vc_first_non_empty(
array(
vc_array_get( $product, 'pricing.summary.fromPrice' ),
vc_array_get( $product, 'price.fromPrice' ),
vc_array_get( $product, 'fromPrice' ),
)
);
$currency = vc_first_non_empty(
array(
vc_array_get( $product, 'pricing.currency' ),
vc_array_get( $product, 'currency' ),
vc_get_option( 'currency', 'USD' ),
)
);
$duration_minutes = vc_first_non_empty(
array(
vc_array_get( $product, 'duration.fixedDurationInMinutes' ),
vc_array_get( $product, 'duration.variableDurationFromMinutes' ),
vc_array_get( $product, 'durationMinutes' ),
)
);
$duration_text = vc_first_non_empty(
array(
vc_array_get( $product, 'duration.displayDuration' ),
vc_array_get( $product, 'durationText' ),
)
);
$booking_url = vc_first_non_empty(
array(
vc_array_get( $product, 'productUrl' ),
vc_array_get( $product, 'bookingUrl' ),
vc_array_get( $product, 'bookingLink' ),
)
);
$supplier = vc_first_non_empty(
array(
vc_array_get( $product, 'supplier.name' ),
vc_array_get( $product, 'supplierName' ),
)
);
$rating = vc_first_non_empty(
array(
vc_array_get( $product, 'reviews.combinedAverageRating' ),
vc_array_get( $product, 'rating' ),
)
);
$review_count = vc_first_non_empty(
array(
vc_array_get( $product, 'reviews.totalReviews' ),
vc_array_get( $product, 'reviewCount' ),
)
);
// Marketing flags (FREE_CANCELLATION, SPECIAL_OFFER, LIKELY_TO_SELL_OUT, …).
$flags = vc_array_get( $product, 'flags', array() );
$flags = is_array( $flags ) ? array_values( array_map( 'sanitize_text_field', $flags ) ) : array();
// Maximum group size (present in full product detail only).
$max_travelers = vc_first_non_empty(
array(
vc_array_get( $product, 'bookingRequirements.maxTravelersPerBooking' ),
vc_array_get( $product, 'bookingRequirements.maxTravelers' ),
vc_array_get( $product, 'maxTravelers' ),
)
);
$meta = array(
'_vc_booking_url' => esc_url_raw( $booking_url ),
'_vc_price_from' => $price,
'_vc_currency' => sanitize_text_field( $currency ),
'_vc_duration_minutes' => absint( $duration_minutes ),
'_vc_duration_text' => sanitize_text_field( $duration_text ),
'_vc_itinerary' => wp_json_encode( vc_array_get( $product, 'itinerary', array() ) ),
'_vc_inclusions' => wp_json_encode( vc_array_get( $product, 'inclusions', array() ) ),
'_vc_exclusions' => wp_json_encode( vc_array_get( $product, 'exclusions', array() ) ),
'_vc_additional_info' => wp_json_encode( vc_array_get( $product, 'additionalInfo', array() ) ),
'_vc_cancellation_policy' => wp_json_encode( vc_first_non_empty( array( vc_array_get( $product, 'cancellationPolicy' ), vc_array_get( $product, 'cancellation' ) ), array() ) ),
'_vc_supplier' => sanitize_text_field( $supplier ),
'_vc_destinations' => wp_json_encode( vc_array_get( $product, 'destinations', array() ) ),
'_vc_tags' => wp_json_encode( vc_array_get( $product, 'tags', array() ) ),
'_vc_rating' => sanitize_text_field( (string) $rating ),
'_vc_review_count' => absint( $review_count ),
'_vc_flags' => wp_json_encode( $flags ),
'_vc_free_cancellation' => in_array( 'FREE_CANCELLATION', $flags, true ) ? 1 : 0,
'_vc_max_travelers' => absint( $max_travelers ),
'_vc_is_active' => $inactive ? 0 : 1,
'_vc_last_synced_at' => current_time( 'mysql' ),
);
foreach ( $meta as $key => $value ) {
update_post_meta( $post_id, $key, $value );
}
// The full API response is always stored so any field can be surfaced later.
update_post_meta( $post_id, '_vc_raw_response', wp_json_encode( $product ) );
}
public function assign_taxonomies( $post_id, array $product ) {
$destination_ids = array();
foreach ( (array) vc_array_get( $product, 'destinations', array() ) as $destination ) {
$ref = vc_first_non_empty(
array(
vc_array_get( $destination, 'ref' ),
vc_array_get( $destination, 'destinationId' ),
is_scalar( $destination ) ? $destination : null,
)
);
if ( '' === (string) $ref ) {
continue;
}
$name = isset( $this->destination_map[ (string) $ref ] ) ? $this->destination_map[ (string) $ref ] : '';
$term_id = VC_Taxonomies::get_or_create_destination_by_id( $ref, $name );
if ( $term_id ) {
$destination_ids[] = (int) $term_id;
}
}
$tag_ids = array();
foreach ( (array) vc_array_get( $product, 'tags', array() ) as $tag ) {
$id = is_scalar( $tag ) ? $tag : vc_first_non_empty( array( vc_array_get( $tag, 'tagId' ), vc_array_get( $tag, 'id' ) ) );
if ( '' === (string) $id ) {
continue;
}
$name = isset( $this->tag_map[ (string) $id ] ) ? $this->tag_map[ (string) $id ] : '';
$term_id = VC_Taxonomies::get_or_create_tag_by_id( $id, $name );
if ( $term_id ) {
$tag_ids[] = (int) $term_id;
}
}
wp_set_object_terms( $post_id, array_values( array_unique( $destination_ids ) ), 'viator_destination', false );
wp_set_object_terms( $post_id, array_values( array_unique( $tag_ids ) ), 'viator_tag', false );
}
/**
* Store image URLs (and a primary URL) without downloading anything.
*/
public function save_image_urls( $post_id, array $product ) {
$images = vc_first_non_empty(
array(
vc_array_get( $product, 'images', array() ),
vc_array_get( $product, 'media.images', array() ),
),
array()
);
$urls = array();
$primary = '';
foreach ( (array) $images as $image ) {
$url = $this->best_image_url( $image );
if ( '' === $url ) {
continue;
}
$urls[] = $url;
if ( '' === $primary && ( is_scalar( $image ) || vc_array_get( $image, 'isCover' ) ) ) {
$primary = $url;
}
}
$urls = array_values( array_unique( array_filter( $urls ) ) );
if ( '' === $primary && ! empty( $urls ) ) {
$primary = $urls[0];
}
update_post_meta( $post_id, '_vc_gallery_image_urls', wp_json_encode( $urls ) );
update_post_meta( $post_id, '_vc_primary_image_url', esc_url_raw( $primary ) );
}
/**
* Pick the highest-resolution variant available for an image entry.
*/
private function best_image_url( $image ) {
if ( is_string( $image ) ) {
return esc_url_raw( $image );
}
if ( ! is_array( $image ) ) {
return '';
}
$variants = vc_array_get( $image, 'variants', array() );
if ( is_array( $variants ) && ! empty( $variants ) ) {
$best = '';
$best_width = -1;
foreach ( $variants as $variant ) {
$url = vc_array_get( $variant, 'url' );
if ( ! $url ) {
continue;
}
$width = (int) vc_array_get( $variant, 'width', 0 );
if ( $width >= $best_width ) {
$best_width = $width;
$best = $url;
}
}
if ( '' !== $best ) {
return esc_url_raw( $best );
}
}
$url = vc_first_non_empty(
array(
vc_array_get( $image, 'url' ),
vc_array_get( $image, 'large' ),
vc_array_get( $image, 'medium' ),
vc_array_get( $image, 'imageSource' ),
)
);
return $url ? esc_url_raw( $url ) : '';
}
/**
* Detect whether the feed reports this product as no longer bookable.
*/
private function is_product_inactive( array $product ) {
$status = strtoupper( (string) vc_first_non_empty( array( vc_array_get( $product, 'status' ), vc_array_get( $product, 'productStatus' ) ) ) );
if ( '' !== $status && in_array( $status, array( 'INACTIVE', 'DEACTIVATED', 'EXPIRED', 'REMOVED', 'DELETED' ), true ) ) {
return true;
}
if ( false === vc_array_get( $product, 'active', null ) ) {
return true;
}
if ( vc_array_get( $product, 'isDeactivated', false ) ) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,103 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_Logger {
const STORE = 'vc_activity_log';
const MAX = 300;
public static function log( $message, $force = false ) {
if ( ! $force && ! vc_get_option( 'debug_logging', false ) ) {
return;
}
if ( is_array( $message ) || is_object( $message ) ) {
$message = wp_json_encode( self::redact( $message ) );
} else {
$message = self::redact_string( (string) $message );
}
error_log( '[Viator Collector] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
/**
* Record an informational event in the admin activity log.
*/
public static function info( $message ) {
self::record( 'info', $message );
self::log( $message );
}
/**
* Record an error event in the admin activity log and the PHP error log.
*/
public static function error( $message ) {
self::record( 'error', $message );
self::log( $message, true );
}
/**
* Append an entry to the rolling, in-database activity log shown in admin.
*/
public static function record( $level, $message ) {
if ( is_array( $message ) || is_object( $message ) ) {
$message = wp_json_encode( self::redact( $message ) );
} else {
$message = self::redact_string( (string) $message );
}
$log = get_option( self::STORE, array() );
if ( ! is_array( $log ) ) {
$log = array();
}
$log[] = array(
't' => current_time( 'mysql' ),
'level' => ( 'error' === $level ? 'error' : 'info' ),
'msg' => $message,
);
if ( count( $log ) > self::MAX ) {
$log = array_slice( $log, -self::MAX );
}
update_option( self::STORE, $log, false );
}
/**
* Recent activity log entries, newest first.
*/
public static function get_log() {
$log = get_option( self::STORE, array() );
if ( ! is_array( $log ) ) {
return array();
}
return array_reverse( $log );
}
public static function clear_log() {
delete_option( self::STORE );
}
private static function redact( $value ) {
if ( is_array( $value ) ) {
foreach ( $value as $key => $item ) {
if ( preg_match( '/api.?key|exp-api-key|authorization|token|secret/i', (string) $key ) ) {
$value[ $key ] = '[redacted]';
} else {
$value[ $key ] = self::redact( $item );
}
}
} elseif ( is_string( $value ) ) {
$value = self::redact_string( $value );
}
return $value;
}
private static function redact_string( $value ) {
$key = vc_get_api_key();
if ( $key ) {
$value = str_replace( $key, '[redacted]', $value );
}
return preg_replace( '/(exp-api-key|api_key|apiKey|token|secret)\s*[:=]\s*[^,\s]+/i', '$1=[redacted]', $value );
}
}

View File

@@ -0,0 +1,64 @@
<?php
defined( 'ABSPATH' ) || exit;
final class VC_Plugin {
private static $instance = null;
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {}
public function init() {
$this->load_files();
VC_CPT::init();
VC_Taxonomies::init();
VC_Settings::init();
VC_Cron::init();
VC_Display::init();
VC_Sections::init();
}
private function load_files() {
$files = array(
'includes/helpers.php',
'includes/class-vc-logger.php',
'includes/class-vc-cpt.php',
'includes/class-vc-taxonomies.php',
'includes/class-vc-api.php',
'includes/class-vc-importer.php',
'includes/class-vc-cron.php',
'includes/class-vc-settings.php',
'includes/class-vc-display.php',
'includes/class-vc-sections.php',
);
foreach ( $files as $file ) {
require_once VC_PLUGIN_DIR . $file;
}
}
public static function activate() {
require_once VC_PLUGIN_DIR . 'includes/helpers.php';
require_once VC_PLUGIN_DIR . 'includes/class-vc-cpt.php';
require_once VC_PLUGIN_DIR . 'includes/class-vc-taxonomies.php';
require_once VC_PLUGIN_DIR . 'includes/class-vc-cron.php';
VC_CPT::register();
VC_Taxonomies::register();
VC_Cron::maybe_schedule();
flush_rewrite_rules();
}
public static function deactivate() {
require_once VC_PLUGIN_DIR . 'includes/class-vc-cron.php';
VC_Cron::unschedule();
delete_transient( 'vc_sync_lock' );
flush_rewrite_rules();
}
}

View File

@@ -0,0 +1,517 @@
<?php
defined( 'ABSPATH' ) || exit;
/**
* Front-page section shortcodes — "Hop On Hop Off" design.
*
* All markup is scoped under a .vc-app wrapper and styled by assets/css/
* vc-design.css. Assets are enqueued by VC_Display when a page contains one of
* these shortcodes, so nothing loads on pages that don't use them.
*/
class VC_Sections {
const HERO = 'viator_hero';
const TOP_DESTS = 'viator_top_destinations';
const TOP_ATTRS = 'viator_top_attractions';
const ACTIVITIES = 'viator_browse_activities';
const TOP_TOURS = 'viator_top_tours';
const FAQ = 'viator_faq';
const REVIEWS = 'viator_testimonials';
/** Per-request counter so each rail gets a unique id for its nav buttons. */
private static $rail_seq = 0;
public static function init() {
add_shortcode( self::HERO, array( __CLASS__, 'hero' ) );
add_shortcode( 'viator_hero_slider', array( __CLASS__, 'hero' ) ); // back-compat alias
add_shortcode( self::TOP_DESTS, array( __CLASS__, 'top_destinations' ) );
add_shortcode( self::TOP_ATTRS, array( __CLASS__, 'top_attractions' ) );
add_shortcode( self::ACTIVITIES, array( __CLASS__, 'browse_activities' ) );
add_shortcode( self::TOP_TOURS, array( __CLASS__, 'top_tours' ) );
add_shortcode( self::FAQ, array( __CLASS__, 'faq' ) );
add_shortcode( self::REVIEWS, array( __CLASS__, 'testimonials' ) );
}
/**
* [viator_hero title="Set out on the journey." image="https://…"]
*
* The booking hero: a location/date/seat form that routes to the tours
* archive pre-filtered by the chosen destination.
*/
public static function hero( $atts ) {
$atts = shortcode_atts(
array(
'title' => __( 'Set out on the journey.', 'viator-collector' ),
'eyebrow' => __( 'Tour & Travel', 'viator-collector' ),
'image' => '',
),
$atts,
self::HERO
);
$img = $atts['image'] ? $atts['image'] : vc_hero_image_url();
if ( ! $img ) {
$img = vc_asset_url( 'img/hero.png' );
}
$archive = get_post_type_archive_link( 'viator_tour' );
$archive = $archive ? $archive : home_url( '/' );
$dests = get_terms( array( 'taxonomy' => 'viator_destination', 'hide_empty' => true, 'number' => 12, 'orderby' => 'count', 'order' => 'DESC' ) );
$dests = is_wp_error( $dests ) ? array() : $dests;
ob_start();
?>
<div class="vc-app vc-sections">
<section class="hero">
<div class="hero__media">
<img class="hero__img" src="<?php echo esc_url( $img ); ?>" alt="" fetchpriority="high">
<div class="hero__scrim" aria-hidden="true"></div>
</div>
<div class="container hero__content">
<p class="pill hero__eyebrow"><?php echo esc_html( $atts['eyebrow'] ); ?></p>
<h1 class="hero__title"><?php echo esc_html( $atts['title'] ); ?></h1>
<form class="booking" method="get" action="<?php echo esc_url( $archive ); ?>" novalidate>
<div class="booking__row">
<div class="field">
<span class="field__label"><?php echo vc_icon( 'location' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><label for="vc-f-location"><?php echo esc_html__( 'Location', 'viator-collector' ); ?></label></span>
<div class="select">
<select class="select__input" id="vc-f-location" name="dest[]">
<option value=""><?php echo esc_html__( 'All destinations', 'viator-collector' ); ?></option>
<?php foreach ( $dests as $t ) : ?>
<option value="<?php echo esc_attr( $t->slug ); ?>"><?php echo esc_html( $t->name ); ?></option>
<?php endforeach; ?>
</select>
<?php echo vc_icon( 'chevron', 'select__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</div>
<div class="field">
<span class="field__label"><?php echo vc_icon( 'calendar' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><label for="vc-f-date"><?php echo esc_html__( 'Date', 'viator-collector' ); ?></label></span>
<div class="select">
<input class="select__input select__input--date" id="vc-f-date" name="date" type="text" placeholder="<?php echo esc_attr__( 'Select Date', 'viator-collector' ); ?>" data-datefield>
<?php echo vc_icon( 'chevron', 'select__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</div>
<div class="field">
<span class="field__label"><?php echo vc_icon( 'seat' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><label for="vc-f-seat"><?php echo esc_html__( 'Seat', 'viator-collector' ); ?></label></span>
<div class="select">
<select class="select__input" id="vc-f-seat" name="seat">
<option value="1"><?php echo esc_html__( 'One Seat', 'viator-collector' ); ?></option>
<option value="2"><?php echo esc_html__( 'Two Seats', 'viator-collector' ); ?></option>
<option value="3"><?php echo esc_html__( 'Three Seats', 'viator-collector' ); ?></option>
<option value="4"><?php echo esc_html__( 'Four Seats', 'viator-collector' ); ?></option>
</select>
<?php echo vc_icon( 'chevron', 'select__chevron' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</div>
<button class="btn btn--dark booking__submit" type="submit"><?php echo esc_html__( 'Book now', 'viator-collector' ); ?></button>
</div>
</form>
</div>
<?php echo vc_ring_badge( 'SCROLL DOWN · SCROLL DOWN · ', $archive, 'scroll' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</section>
</div>
<?php
return ob_get_clean();
}
/**
* Open a titled rail section and return its unique rail id. Caller fills the
* <ul> with cards and then calls close_rail().
*/
private static function open_rail( $title, $lead, $view_all_url, $view_all_text ) {
$id = 'vcRail' . ( ++self::$rail_seq );
ob_start();
?>
<div class="vc-app vc-sections">
<section class="section">
<div class="container">
<div class="sec-head">
<h2 class="sec-head__title"><?php echo wp_kses( $title, array( 'br' => array() ) ); ?></h2>
<?php if ( $lead ) : ?><p class="sec-head__lead"><?php echo esc_html( $lead ); ?></p><?php endif; ?>
<div class="sec-head__actions">
<?php if ( $view_all_url ) : ?>
<a class="btn btn--ghost btn--wide" href="<?php echo esc_url( $view_all_url ); ?>"><span><?php echo esc_html( $view_all_text ); ?></span><?php echo vc_icon( 'arrow-ne' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></a>
<?php endif; ?>
<div class="rail-nav">
<button class="btn btn--ghost rail-nav__btn" type="button" data-rail-prev="<?php echo esc_attr( $id ); ?>" aria-label="<?php echo esc_attr__( 'Previous', 'viator-collector' ); ?>"><?php echo vc_icon( 'arrow-left' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></button>
<button class="btn btn--ghost rail-nav__btn" type="button" data-rail-next="<?php echo esc_attr( $id ); ?>" aria-label="<?php echo esc_attr__( 'Next', 'viator-collector' ); ?>"><?php echo vc_icon( 'arrow-right' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></button>
</div>
</div>
</div>
<ul class="rail rail--dest" id="<?php echo esc_attr( $id ); ?>">
<?php
return array( 'id' => $id, 'head' => ob_get_clean() );
}
private static function close_rail() {
return '</ul></div></section></div>';
}
/**
* [viator_top_tours limit="8" title="Most Liked Tours"]
*/
public static function top_tours( $atts ) {
$atts = shortcode_atts(
array(
'ids' => '',
'codes' => '',
'destination' => '',
'tag' => '',
'limit' => 8,
'orderby' => 'rating',
'title' => __( 'Most Liked<br>Tours', 'viator-collector' ),
'lead' => __( 'Hand-picked tours travellers loved most.', 'viator-collector' ),
),
$atts,
self::TOP_TOURS
);
return self::tour_rail( $atts );
}
/**
* [viator_top_attractions limit="8"]
*/
public static function top_attractions( $atts ) {
$atts = shortcode_atts(
array(
'ids' => '',
'codes' => '',
'destination' => '',
'tag' => '',
'limit' => 8,
'orderby' => 'rating',
'title' => __( 'Top<br>Attractions', 'viator-collector' ),
'lead' => __( 'The must-see experiences everyone books.', 'viator-collector' ),
),
$atts,
self::TOP_ATTRS
);
return self::tour_rail( $atts );
}
/**
* [viator_browse_activities limit="8"]
*/
public static function browse_activities( $atts ) {
$atts = shortcode_atts(
array(
'ids' => '',
'codes' => '',
'destination' => '',
'tag' => '',
'limit' => 8,
'orderby' => 'rating',
'title' => __( 'Browse By<br>Activity', 'viator-collector' ),
'lead' => __( 'Find your style — from city sights to river cruises.', 'viator-collector' ),
),
$atts,
self::ACTIVITIES
);
return self::tour_rail( $atts );
}
/**
* Shared: a rail of tour cards from a curated/filtered query.
*/
private static function tour_rail( array $atts ) {
$query = self::tour_query( $atts );
if ( ! $query->have_posts() ) {
return '';
}
$archive = get_post_type_archive_link( 'viator_tour' );
$rail = self::open_rail( $atts['title'], $atts['lead'], $archive, __( 'See all', 'viator-collector' ) );
ob_start();
echo $rail['head']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
while ( $query->have_posts() ) {
$query->the_post();
echo vc_get_template( 'card-tour.php', array( 'post_id' => get_the_ID(), 'variant' => 'rail' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
echo self::close_rail(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_reset_postdata();
return ob_get_clean();
}
/**
* [viator_top_destinations limit="8" title="Where you wanna go?"]
*
* A rail of destination-term cards; each links to the term archive.
*/
public static function top_destinations( $atts ) {
$atts = shortcode_atts(
array(
'ids' => '',
'slugs' => '',
'limit' => 8,
'orderby' => 'count',
'title' => __( 'Where you<br>wanna go?', 'viator-collector' ),
'lead' => __( 'Popular destinations our travellers explore.', 'viator-collector' ),
'link_text' => __( 'View tours', 'viator-collector' ),
),
$atts,
self::TOP_DESTS
);
$args = array( 'taxonomy' => 'viator_destination', 'hide_empty' => true );
if ( '' !== $atts['ids'] ) {
$args['include'] = array_filter( array_map( 'absint', explode( ',', $atts['ids'] ) ) );
$args['orderby'] = 'include';
} elseif ( '' !== $atts['slugs'] ) {
$args['slug'] = array_filter( array_map( 'sanitize_title', explode( ',', $atts['slugs'] ) ) );
$args['orderby'] = 'name';
} else {
$args['number'] = max( 1, absint( $atts['limit'] ) );
$args['orderby'] = ( 'name' === $atts['orderby'] ) ? 'name' : 'count';
$args['order'] = ( 'name' === $atts['orderby'] ) ? 'ASC' : 'DESC';
}
$terms = get_terms( $args );
if ( is_wp_error( $terms ) || empty( $terms ) ) {
return '';
}
$archive = get_post_type_archive_link( 'viator_tour' );
$rail = self::open_rail( $atts['title'], $atts['lead'], $archive, __( 'See all', 'viator-collector' ) );
ob_start();
echo $rail['head']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
foreach ( $terms as $term ) {
$link = get_term_link( $term );
if ( is_wp_error( $link ) ) {
continue;
}
$image = vc_term_image_url( $term->term_id, 'viator_destination' );
?>
<li class="dcard">
<?php if ( $image ) : ?>
<img class="dcard__img" src="<?php echo esc_url( $image ); ?>" alt="<?php echo esc_attr( $term->name ); ?>" loading="lazy">
<?php endif; ?>
<div class="dcard__body">
<h3 class="dcard__title"><a href="<?php echo esc_url( $link ); ?>"><?php echo esc_html( $term->name ); ?></a></h3>
<div class="dcard__meta">
<div class="meta">
<span class="meta__k"><?php echo esc_html__( 'Tours', 'viator-collector' ); ?></span>
<span class="meta__v"><?php echo esc_html( number_format_i18n( (int) $term->count ) ); ?></span>
</div>
<?php echo vc_ring_badge( 'VIEW TOURS · VIEW TOURS · ', $link, 'card' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</div>
</li>
<?php
}
echo self::close_rail(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
return ob_get_clean();
}
/**
* [viator_faq title="FAQ's" items="Question|Answer;;Question 2|Answer 2"]
*
* Items may also be supplied via the `vc_faq_items` filter.
*/
public static function faq( $atts ) {
$atts = shortcode_atts(
array(
'title' => __( "FAQ's", 'viator-collector' ),
'lead' => __( 'Everything you need to know before you book.', 'viator-collector' ),
'items' => '',
),
$atts,
self::FAQ
);
$items = array();
if ( '' !== $atts['items'] ) {
foreach ( explode( ';;', $atts['items'] ) as $pair ) {
$parts = array_map( 'trim', explode( '|', $pair, 2 ) );
if ( '' !== $parts[0] && isset( $parts[1] ) ) {
$items[] = array( 'q' => $parts[0], 'a' => $parts[1] );
}
}
}
if ( empty( $items ) ) {
$items = apply_filters(
'vc_faq_items',
array(
array( 'q' => __( 'Is the booking process fully online?', 'viator-collector' ), 'a' => __( 'Yes — you can browse, choose a date and complete your booking entirely online in a few minutes.', 'viator-collector' ) ),
array( 'q' => __( 'How can I book a tour?', 'viator-collector' ), 'a' => __( 'Pick a tour, choose your date and number of seats, then confirm. You will receive your tickets by email.', 'viator-collector' ) ),
array( 'q' => __( 'Can I cancel or change my booking?', 'viator-collector' ), 'a' => __( 'Most tours offer free cancellation up to 24 hours before the start time. Check each tour for details.', 'viator-collector' ) ),
array( 'q' => __( 'Do I need to print my ticket?', 'viator-collector' ), 'a' => __( 'No — a mobile ticket is accepted for the vast majority of tours.', 'viator-collector' ) ),
)
);
}
if ( empty( $items ) ) {
return '';
}
ob_start();
?>
<div class="vc-app vc-sections">
<section class="faq">
<span class="faq__watermark" aria-hidden="true">FAQ</span>
<div class="container faq__inner">
<div class="faq__intro">
<h2 class="faq__title"><?php echo esc_html( $atts['title'] ); ?></h2>
<?php if ( $atts['lead'] ) : ?><p class="faq__lead"><?php echo esc_html( $atts['lead'] ); ?></p><?php endif; ?>
</div>
<div class="accordion" data-accordion>
<?php foreach ( $items as $i => $item ) : ?>
<div class="acc">
<h3>
<button class="acc__q" type="button" aria-expanded="<?php echo 0 === $i ? 'true' : 'false'; ?>">
<span><?php echo esc_html( $item['q'] ); ?></span>
<?php echo vc_icon( 'plus', 'icon--14 acc__plus' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php echo vc_icon( 'minus', 'icon--14 acc__minus' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</button>
</h3>
<div class="acc__a"><p><?php echo esc_html( $item['a'] ); ?></p></div>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
</div>
<?php
return ob_get_clean();
}
/**
* [viator_testimonials title="Your Comments"]
*
* Items may be supplied via the `vc_testimonials` filter
* (each: name, role, text, avatar).
*/
public static function testimonials( $atts ) {
$atts = shortcode_atts(
array(
'title' => __( 'Your Comments', 'viator-collector' ),
'lead' => __( 'What travellers say about their tours.', 'viator-collector' ),
),
$atts,
self::REVIEWS
);
$a1 = vc_asset_url( 'img/ellipse-17.png' );
$a2 = vc_asset_url( 'img/ellipse-17-2.png' );
$items = apply_filters(
'vc_testimonials',
array(
array( 'name' => 'Malika Favre', 'role' => __( 'Paris Tour', 'viator-collector' ), 'text' => __( 'An unforgettable day — the open-top ride and the guide made the whole city feel new. Booking was effortless.', 'viator-collector' ), 'avatar' => $a1, 'size' => 'lg' ),
array( 'name' => 'James Carter', 'role' => __( 'London Tour', 'viator-collector' ), 'text' => __( 'Great value and flexible timing. We hopped on and off all day and saw everything on our list.', 'viator-collector' ), 'avatar' => $a2, 'size' => '' ),
array( 'name' => 'Sofia Rossi', 'role' => __( 'Sydney Tour', 'viator-collector' ), 'text' => __( 'Smooth from start to finish. Highly recommended.', 'viator-collector' ), 'avatar' => $a1, 'size' => 'sm' ),
array( 'name' => 'Liam Nguyen', 'role' => __( 'San Diego Tour', 'viator-collector' ), 'text' => __( 'Friendly crew, comfortable seats and the best views of the coast.', 'viator-collector' ), 'avatar' => $a2, 'size' => '' ),
)
);
if ( empty( $items ) ) {
return '';
}
ob_start();
?>
<div class="vc-app vc-sections">
<section class="section testimonials">
<div class="container">
<div class="testimonials__head">
<h2 class="testimonials__title"><?php echo esc_html( $atts['title'] ); ?></h2>
<?php if ( $atts['lead'] ) : ?><p class="testimonials__lead"><?php echo esc_html( $atts['lead'] ); ?></p><?php endif; ?>
</div>
</div>
<div class="marquee" data-marquee>
<div class="marquee__track">
<?php foreach ( $items as $item ) : ?>
<?php $size = ! empty( $item['size'] ) ? ' quote--' . sanitize_html_class( $item['size'] ) : ''; ?>
<figure class="quote<?php echo esc_attr( $size ); ?>">
<figcaption class="quote__head">
<?php if ( ! empty( $item['avatar'] ) ) : ?>
<img class="quote__avatar" src="<?php echo esc_url( $item['avatar'] ); ?>" alt="" loading="lazy">
<?php endif; ?>
<span><b><?php echo esc_html( $item['name'] ); ?></b><i><?php echo esc_html( $item['role'] ); ?></i></span>
</figcaption>
<blockquote><?php echo esc_html( $item['text'] ); ?></blockquote>
</figure>
<?php endforeach; ?>
</div>
</div>
</section>
</div>
<?php
return ob_get_clean();
}
/**
* Shared tour query for the landing sections. Supports curation (ids /
* product codes) and filtering (destination / tag), with a sane fallback.
*/
private static function tour_query( array $atts ) {
$args = array(
'post_type' => 'viator_tour',
'post_status' => 'publish',
'posts_per_page' => max( 1, absint( $atts['limit'] ) ),
'no_found_rows' => true,
'ignore_sticky_posts' => true,
);
if ( '' !== $atts['ids'] ) {
$args['post__in'] = array_filter( array_map( 'absint', explode( ',', $atts['ids'] ) ) );
$args['orderby'] = 'post__in';
} elseif ( '' !== $atts['codes'] ) {
$args['meta_query'] = array(
'vc_product_codes' => array(
'key' => '_vc_viator_product_code',
'value' => array_filter( array_map( 'sanitize_text_field', explode( ',', $atts['codes'] ) ) ),
'compare' => 'IN',
),
);
}
$tax_query = array();
if ( '' !== $atts['destination'] ) {
$tax_query[] = array( 'taxonomy' => 'viator_destination', 'field' => 'slug', 'terms' => array_map( 'sanitize_title', explode( ',', $atts['destination'] ) ) );
}
if ( '' !== $atts['tag'] ) {
$tax_query[] = array( 'taxonomy' => 'viator_tag', 'field' => 'slug', 'terms' => array_map( 'sanitize_title', explode( ',', $atts['tag'] ) ) );
}
if ( $tax_query ) {
$args['tax_query'] = $tax_query;
}
if ( ! isset( $args['orderby'] ) ) {
switch ( $atts['orderby'] ) {
case 'price':
$args['meta_key'] = '_vc_price_from';
$args['orderby'] = 'meta_value_num';
$args['order'] = 'ASC';
break;
case 'rand':
$args['orderby'] = 'rand';
break;
case 'date':
$args['orderby'] = 'date';
$args['order'] = 'DESC';
break;
default:
$args['meta_key'] = '_vc_rating';
$args['orderby'] = 'meta_value_num';
$args['order'] = 'DESC';
}
}
$query = new WP_Query( $args );
// Ordering by a meta key silently drops posts that lack it; fall back to
// newest so the section never empties.
if ( ! $query->have_posts() && isset( $args['meta_key'] ) ) {
unset( $args['meta_key'] );
$args['orderby'] = 'date';
$args['order'] = 'DESC';
$query = new WP_Query( $args );
}
return $query;
}
}

View File

@@ -0,0 +1,542 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_Settings {
public static function init() {
add_action( 'admin_menu', array( __CLASS__, 'admin_menu' ) );
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
add_action( 'admin_post_vc_test_api', array( __CLASS__, 'handle_test_api' ) );
add_action( 'admin_post_vc_manual_sync', array( __CLASS__, 'handle_manual_sync' ) );
add_action( 'admin_post_vc_reset_sync', array( __CLASS__, 'handle_reset_sync' ) );
add_action( 'admin_post_vc_delete_all', array( __CLASS__, 'handle_delete_all' ) );
add_action( 'admin_post_vc_clear_log', array( __CLASS__, 'handle_clear_log' ) );
add_action( 'admin_post_vc_import_product', array( __CLASS__, 'handle_import_product' ) );
add_action( 'wp_ajax_vc_search_destinations', array( __CLASS__, 'ajax_search_destinations' ) );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'admin_assets' ) );
}
/**
* Load the WordPress media library on our settings screen so the hero
* image can be picked/uploaded without editing plugin files.
*/
public static function admin_assets( $hook ) {
if ( 'settings_page_viator-collector' === $hook ) {
wp_enqueue_media();
}
}
/**
* Keep only numeric, comma-separated destination IDs.
*/
public static function sanitize_id_list( $value ) {
$ids = array_filter( array_map( 'trim', explode( ',', (string) $value ) ), 'ctype_digit' );
return implode( ',', array_values( array_unique( $ids ) ) );
}
/**
* Live destination lookup for the settings screen so the exact Viator IDs
* can be found and added to the crawl filter.
*/
public static function ajax_search_destinations() {
check_ajax_referer( 'vc_search_destinations' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Not allowed.', 'viator-collector' ) ) );
}
$query = isset( $_POST['q'] ) ? sanitize_text_field( wp_unslash( $_POST['q'] ) ) : '';
$map = ( new VC_API() )->get_destination_map();
if ( is_wp_error( $map ) ) {
wp_send_json_error( array( 'message' => $map->get_error_message() ) );
}
$needle = strtolower( $query );
$results = array();
foreach ( (array) $map as $id => $name ) {
if ( '' === $query || false !== strpos( strtolower( $name ), $needle ) ) {
$results[] = array(
'id' => (string) $id,
'name' => $name,
);
if ( count( $results ) >= 40 ) {
break;
}
}
}
wp_send_json_success( array( 'results' => $results ) );
}
public static function defaults() {
return array(
'api_key' => '',
'api_base_url' => 'https://api.viator.com/partner',
'language' => 'en-US',
'currency' => 'USD',
'timeout' => 20,
'default_post_status' => 'publish',
'hero_image' => '',
'hero_slides' => '',
'crawl_destinations' => '',
'rate_limit_per_minute' => 25,
'products_per_page' => 50,
'pages_per_run' => 10,
'fetch_full_detail' => 0,
'detail_batch_size' => 15,
'log_every' => 10,
'automatic_sync' => 0,
'debug_logging' => 0,
);
}
public static function admin_menu() {
add_options_page(
__( 'Viator Collector', 'viator-collector' ),
__( 'Viator Collector', 'viator-collector' ),
'manage_options',
'viator-collector',
array( __CLASS__, 'render_page' )
);
}
public static function register_settings() {
register_setting(
'vc_settings_group',
'vc_settings',
array(
'type' => 'array',
'sanitize_callback' => array( __CLASS__, 'sanitize_settings' ),
'default' => self::defaults(),
)
);
}
public static function sanitize_settings( $input ) {
$input = is_array( $input ) ? $input : array();
$defaults = self::defaults();
$output = array(
'api_key' => sanitize_text_field( $input['api_key'] ?? '' ),
'api_base_url' => esc_url_raw( $input['api_base_url'] ?? $defaults['api_base_url'] ),
'language' => sanitize_text_field( $input['language'] ?? 'en-US' ),
'currency' => strtoupper( sanitize_text_field( $input['currency'] ?? 'USD' ) ),
'timeout' => max( 5, absint( $input['timeout'] ?? 20 ) ),
'default_post_status' => sanitize_key( $input['default_post_status'] ?? 'publish' ),
'hero_image' => esc_url_raw( $input['hero_image'] ?? '' ),
'hero_slides' => self::sanitize_id_list( $input['hero_slides'] ?? '' ),
'crawl_destinations' => self::sanitize_id_list( $input['crawl_destinations'] ?? '' ),
'rate_limit_per_minute' => max( 1, min( 60, absint( $input['rate_limit_per_minute'] ?? 25 ) ) ),
'products_per_page' => max( 1, min( 50, absint( $input['products_per_page'] ?? 50 ) ) ),
'pages_per_run' => max( 1, min( 100, absint( $input['pages_per_run'] ?? 10 ) ) ),
'fetch_full_detail' => ! empty( $input['fetch_full_detail'] ) ? 1 : 0,
'detail_batch_size' => max( 1, min( 25, absint( $input['detail_batch_size'] ?? 15 ) ) ),
'log_every' => max( 1, min( 1000, absint( $input['log_every'] ?? 10 ) ) ),
'automatic_sync' => ! empty( $input['automatic_sync'] ) ? 1 : 0,
'debug_logging' => ! empty( $input['debug_logging'] ) ? 1 : 0,
);
if ( ! in_array( $output['default_post_status'], array( 'publish', 'draft', 'pending' ), true ) ) {
$output['default_post_status'] = 'publish';
}
return $output;
}
public static function render_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$options = wp_parse_args( vc_get_option( '', array() ), self::defaults() );
$notice = isset( $_GET['vc_notice'] ) ? sanitize_text_field( wp_unslash( $_GET['vc_notice'] ) ) : '';
$status = VC_Cron::status();
?>
<div class="wrap">
<h1><?php echo esc_html__( 'Viator Collector', 'viator-collector' ); ?></h1>
<?php if ( $notice ) : ?>
<div class="notice notice-info is-dismissible"><p><?php echo esc_html( $notice ); ?></p></div>
<?php endif; ?>
<h2><?php echo esc_html__( 'Sync status', 'viator-collector' ); ?></h2>
<table class="widefat striped" style="max-width:640px;margin-bottom:1.5em;">
<tbody>
<tr><td><?php echo esc_html__( 'State', 'viator-collector' ); ?></td><td><?php echo $status['in_progress'] ? esc_html__( 'Running', 'viator-collector' ) : esc_html__( 'Idle', 'viator-collector' ); ?></td></tr>
<tr><td><?php echo esc_html__( 'Destinations left in current run', 'viator-collector' ); ?></td><td><?php echo esc_html( (string) $status['queue_remaining'] ); ?></td></tr>
<tr><td><?php echo esc_html__( 'Last started', 'viator-collector' ); ?></td><td><?php echo esc_html( $status['started_at'] ? $status['started_at'] : '—' ); ?></td></tr>
<tr><td><?php echo esc_html__( 'Last completed', 'viator-collector' ); ?></td><td><?php echo esc_html( $status['completed_at'] ? $status['completed_at'] : '—' ); ?></td></tr>
<tr><td><?php echo esc_html__( 'Current run progress', 'viator-collector' ); ?></td><td><?php echo esc_html( wp_json_encode( $status['totals'] ) ); ?></td></tr>
<tr><td><?php echo esc_html__( 'Next automatic run', 'viator-collector' ); ?></td><td><?php echo esc_html( $status['next_scheduled'] ? gmdate( 'Y-m-d H:i:s', (int) $status['next_scheduled'] ) . ' UTC' : __( 'Not scheduled', 'viator-collector' ) ); ?></td></tr>
</tbody>
</table>
<form method="post" action="options.php">
<?php settings_fields( 'vc_settings_group' ); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="vc_api_key"><?php echo esc_html__( 'API key', 'viator-collector' ); ?></label></th>
<td>
<input id="vc_api_key" type="password" class="regular-text" name="vc_settings[api_key]" value="<?php echo esc_attr( $options['api_key'] ); ?>" autocomplete="off">
<?php if ( defined( 'VIATOR_API_KEY' ) && VIATOR_API_KEY ) : ?>
<p class="description"><?php echo esc_html__( 'VIATOR_API_KEY is defined in wp-config.php and will be used instead.', 'viator-collector' ); ?></p>
<?php endif; ?>
</td>
</tr>
<tr><th scope="row"><label for="vc_api_base_url"><?php echo esc_html__( 'API base URL', 'viator-collector' ); ?></label></th><td><input id="vc_api_base_url" type="url" class="regular-text" name="vc_settings[api_base_url]" value="<?php echo esc_attr( $options['api_base_url'] ); ?>"></td></tr>
<tr><th scope="row"><label for="vc_language"><?php echo esc_html__( 'Language', 'viator-collector' ); ?></label></th><td><input id="vc_language" type="text" name="vc_settings[language]" value="<?php echo esc_attr( $options['language'] ); ?>"></td></tr>
<tr><th scope="row"><label for="vc_currency"><?php echo esc_html__( 'Currency', 'viator-collector' ); ?></label></th><td><input id="vc_currency" type="text" name="vc_settings[currency]" value="<?php echo esc_attr( $options['currency'] ); ?>" maxlength="3"></td></tr>
<tr><th scope="row"><label for="vc_timeout"><?php echo esc_html__( 'Timeout (seconds)', 'viator-collector' ); ?></label></th><td><input id="vc_timeout" type="number" min="5" name="vc_settings[timeout]" value="<?php echo esc_attr( $options['timeout'] ); ?>"></td></tr>
<tr>
<th scope="row"><label for="vc_default_post_status"><?php echo esc_html__( 'Default post status', 'viator-collector' ); ?></label></th>
<td>
<select id="vc_default_post_status" name="vc_settings[default_post_status]">
<?php foreach ( array( 'publish', 'draft', 'pending' ) as $post_status ) : ?>
<option value="<?php echo esc_attr( $post_status ); ?>" <?php selected( $options['default_post_status'], $post_status ); ?>><?php echo esc_html( ucfirst( $post_status ) ); ?></option>
<?php endforeach; ?>
</select>
<p class="description"><?php echo esc_html__( 'Deactivated products are always set to draft regardless of this setting.', 'viator-collector' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="vc_hero_image"><?php echo esc_html__( 'Hero image', 'viator-collector' ); ?></label></th>
<td>
<input id="vc_hero_image" type="url" class="regular-text" name="vc_settings[hero_image]" value="<?php echo esc_attr( $options['hero_image'] ); ?>" placeholder="https://…">
<button type="button" class="button" id="vc-hero-pick"><?php echo esc_html__( 'Select image', 'viator-collector' ); ?></button>
<button type="button" class="button" id="vc-hero-clear"><?php echo esc_html__( 'Clear', 'viator-collector' ); ?></button>
<p class="description"><?php echo esc_html__( 'Banner shown at the top of the tours listing. Leave empty for a gradient. Destination pages use their own image when one is set.', 'viator-collector' ); ?></p>
<div id="vc-hero-preview" style="margin-top:8px;">
<?php if ( $options['hero_image'] ) : ?>
<img src="<?php echo esc_url( $options['hero_image'] ); ?>" alt="" style="max-width:320px;height:auto;border-radius:8px;">
<?php endif; ?>
</div>
<script>
jQuery(function ($) {
var frame;
$('#vc-hero-pick').on('click', function (e) {
e.preventDefault();
if (frame) { frame.open(); return; }
frame = wp.media({
title: <?php echo wp_json_encode( __( 'Select hero image', 'viator-collector' ) ); ?>,
button: { text: <?php echo wp_json_encode( __( 'Use this image', 'viator-collector' ) ); ?> },
library: { type: 'image' },
multiple: false
});
frame.on('select', function () {
var img = frame.state().get('selection').first().toJSON();
$('#vc_hero_image').val(img.url);
$('#vc-hero-preview').html($('<img>', { src: img.url, alt: '' }).css({ maxWidth: '320px', height: 'auto', borderRadius: '8px' }));
});
frame.open();
});
$('#vc-hero-clear').on('click', function (e) {
e.preventDefault();
$('#vc_hero_image').val('');
$('#vc-hero-preview').empty();
});
});
</script>
</td>
</tr>
<tr>
<th scope="row"><label for="vc_hero_slides"><?php echo esc_html__( 'Hero slider images', 'viator-collector' ); ?></label></th>
<td>
<input id="vc_hero_slides" type="hidden" name="vc_settings[hero_slides]" value="<?php echo esc_attr( $options['hero_slides'] ); ?>">
<button type="button" class="button" id="vc-slides-pick"><?php echo esc_html__( 'Select images', 'viator-collector' ); ?></button>
<button type="button" class="button" id="vc-slides-clear"><?php echo esc_html__( 'Clear', 'viator-collector' ); ?></button>
<p class="description">
<?php echo esc_html__( 'Slides for the [viator_hero_slider] shortcode. Drag is not supported — reselect to change the order.', 'viator-collector' ); ?>
</p>
<div id="vc-slides-preview" style="display:flex;flex-wrap:wrap;gap:8px;margin-top:10px;">
<?php
$vc_slide_ids = array_filter( array_map( 'absint', explode( ',', (string) $options['hero_slides'] ) ) );
foreach ( $vc_slide_ids as $vc_slide_id ) {
echo wp_get_attachment_image( $vc_slide_id, 'thumbnail', false, array( 'style' => 'width:80px;height:60px;object-fit:cover;border-radius:6px;' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
?>
</div>
<script>
jQuery(function ($) {
var frame;
function paint(list) {
var $p = $('#vc-slides-preview').empty();
list.forEach(function (item) {
$('<img>', { src: item.url }).css({
width: '80px', height: '60px', objectFit: 'cover', borderRadius: '6px'
}).appendTo($p);
});
}
$('#vc-slides-pick').on('click', function (e) {
e.preventDefault();
frame = wp.media({
title: <?php echo wp_json_encode( __( 'Select hero slides', 'viator-collector' ) ); ?>,
button: { text: <?php echo wp_json_encode( __( 'Use these images', 'viator-collector' ) ); ?> },
library: { type: 'image' },
multiple: 'add'
});
frame.on('select', function () {
var ids = [], list = [];
frame.state().get('selection').each(function (a) {
var j = a.toJSON();
ids.push(j.id);
list.push({ url: (j.sizes && j.sizes.thumbnail) ? j.sizes.thumbnail.url : j.url });
});
$('#vc_hero_slides').val(ids.join(','));
paint(list);
});
frame.open();
});
$('#vc-slides-clear').on('click', function (e) {
e.preventDefault();
$('#vc_hero_slides').val('');
$('#vc-slides-preview').empty();
});
});
</script>
</td>
</tr>
<tr>
<th scope="row"><label for="vc_crawl_destinations"><?php echo esc_html__( 'Destination filter', 'viator-collector' ); ?></label></th>
<td>
<input id="vc_crawl_destinations" type="text" class="large-text" name="vc_settings[crawl_destinations]" value="<?php echo esc_attr( $options['crawl_destinations'] ); ?>" placeholder="<?php echo esc_attr__( 'e.g. 664, 662, 761', 'viator-collector' ); ?>">
<p class="description"><?php echo esc_html__( 'Comma-separated Viator destination IDs to crawl. Leave empty to crawl the whole catalogue. Use the search below to find IDs.', 'viator-collector' ); ?></p>
<div class="vc-dest-finder" style="margin-top:8px;max-width:640px;">
<input type="search" id="vc-dest-search" class="regular-text" placeholder="<?php echo esc_attr__( 'Search destinations, e.g. Orlando', 'viator-collector' ); ?>">
<button type="button" class="button" id="vc-dest-search-btn"><?php echo esc_html__( 'Search', 'viator-collector' ); ?></button>
<div id="vc-dest-results" style="margin-top:8px;"></div>
</div>
<script>
(function(){
var btn=document.getElementById('vc-dest-search-btn'),
q=document.getElementById('vc-dest-search'),
out=document.getElementById('vc-dest-results'),
field=document.getElementById('vc_crawl_destinations'),
nonce=<?php echo wp_json_encode( wp_create_nonce( 'vc_search_destinations' ) ); ?>;
function addId(id){
var ids=field.value.split(',').map(function(s){return s.trim();}).filter(Boolean);
if(ids.indexOf(id)===-1){ids.push(id);} field.value=ids.join(', ');
}
function search(){
out.textContent='<?php echo esc_js( __( 'Searching…', 'viator-collector' ) ); ?>';
var d=new FormData();
d.append('action','vc_search_destinations');
d.append('_ajax_nonce',nonce);
d.append('q',q.value);
fetch(ajaxurl,{method:'POST',credentials:'same-origin',body:d})
.then(function(r){return r.json();})
.then(function(res){
if(!res||!res.success){out.textContent=(res&&res.data&&res.data.message)||'<?php echo esc_js( __( 'Search failed.', 'viator-collector' ) ); ?>';return;}
if(!res.data.results.length){out.textContent='<?php echo esc_js( __( 'No matches.', 'viator-collector' ) ); ?>';return;}
out.innerHTML='';
res.data.results.forEach(function(item){
var row=document.createElement('div');
row.style.cssText='display:flex;align-items:center;gap:8px;padding:3px 0;';
var b=document.createElement('button');
b.type='button';b.className='button button-small';b.textContent='<?php echo esc_js( __( 'Add', 'viator-collector' ) ); ?>';
b.addEventListener('click',function(){addId(item.id);});
var span=document.createElement('span');
span.textContent=item.name+' (ID '+item.id+')';
row.appendChild(b);row.appendChild(span);out.appendChild(row);
});
})
.catch(function(){out.textContent='<?php echo esc_js( __( 'Request failed.', 'viator-collector' ) ); ?>';});
}
btn.addEventListener('click',search);
q.addEventListener('keydown',function(e){if(e.key==='Enter'){e.preventDefault();search();}});
})();
</script>
</td>
</tr>
<tr><th scope="row"><label for="vc_rate_limit"><?php echo esc_html__( 'Max API requests per minute', 'viator-collector' ); ?></label></th><td><input id="vc_rate_limit" type="number" min="1" max="60" name="vc_settings[rate_limit_per_minute]" value="<?php echo esc_attr( $options['rate_limit_per_minute'] ); ?>"><p class="description"><?php echo esc_html__( 'Requests are throttled to stay under this limit. Viator Basic access allows 25.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><label for="vc_products_per_page"><?php echo esc_html__( 'Products per search page', 'viator-collector' ); ?></label></th><td><input id="vc_products_per_page" type="number" min="1" max="50" name="vc_settings[products_per_page]" value="<?php echo esc_attr( $options['products_per_page'] ); ?>"><p class="description"><?php echo esc_html__( 'Maximum 50 (Viator limit). Lower this only if full detail is ON and ticks time out.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><label for="vc_pages_per_run"><?php echo esc_html__( 'Search pages per tick', 'viator-collector' ); ?></label></th><td><input id="vc_pages_per_run" type="number" min="1" max="100" name="vc_settings[pages_per_run]" value="<?php echo esc_attr( $options['pages_per_run'] ); ?>"><p class="description"><?php echo esc_html__( 'How many search pages every background tick processes before scheduling the next. Lower values avoid PHP timeouts.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><?php echo esc_html__( 'Fetch full detail during crawl', 'viator-collector' ); ?></th><td><label><input type="checkbox" name="vc_settings[fetch_full_detail]" value="1" <?php checked( $options['fetch_full_detail'], 1 ); ?>> <?php echo esc_html__( 'Call /products/{code} for every product while crawling.', 'viator-collector' ); ?></label><p class="description"><?php echo esc_html__( 'Leave OFF (recommended): the crawl stays fast (1 request per 50 products) and full detail (itinerary, inclusions, cancellation policy) is fetched and cached the first time each tour page is viewed. Turn ON only if you must have full detail for every product up front — far slower under the rate limit.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><label for="vc_detail_batch_size"><?php echo esc_html__( 'Detail batch size per tick', 'viator-collector' ); ?></label></th><td><input id="vc_detail_batch_size" type="number" min="1" max="25" name="vc_settings[detail_batch_size]" value="<?php echo esc_attr( $options['detail_batch_size'] ); ?>"><p class="description"><?php echo esc_html__( 'Only used when full detail is ON. Products processed per cron minute. Lower this if ticks time out; raise it (up to ~20) to go faster, staying under your per-minute limit.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><label for="vc_log_every"><?php echo esc_html__( 'Log progress every N products', 'viator-collector' ); ?></label></th><td><input id="vc_log_every" type="number" min="1" max="1000" name="vc_settings[log_every]" value="<?php echo esc_attr( $options['log_every'] ); ?>"><p class="description"><?php echo esc_html__( 'How often a progress line is written to the activity log below.', 'viator-collector' ); ?></p></td></tr>
<tr><th scope="row"><?php echo esc_html__( 'Automatic sync', 'viator-collector' ); ?></th><td><label><input type="checkbox" name="vc_settings[automatic_sync]" value="1" <?php checked( $options['automatic_sync'], 1 ); ?>> <?php echo esc_html__( 'Refresh the catalogue every 24 hours', 'viator-collector' ); ?></label></td></tr>
<tr><th scope="row"><?php echo esc_html__( 'Debug logging', 'viator-collector' ); ?></th><td><label><input type="checkbox" name="vc_settings[debug_logging]" value="1" <?php checked( $options['debug_logging'], 1 ); ?>> <?php echo esc_html__( 'Enable debug logging', 'viator-collector' ); ?></label></td></tr>
</table>
<?php submit_button(); ?>
</form>
<hr>
<h2><?php echo esc_html__( 'Manual actions', 'viator-collector' ); ?></h2>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;margin-right:1em;">
<input type="hidden" name="action" value="vc_test_api">
<?php wp_nonce_field( 'vc_test_api' ); ?>
<?php submit_button( __( 'Test API connection', 'viator-collector' ), 'secondary', 'submit', false ); ?>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;margin-right:1em;">
<input type="hidden" name="action" value="vc_manual_sync">
<?php wp_nonce_field( 'vc_manual_sync' ); ?>
<?php submit_button( __( 'Run sync now', 'viator-collector' ), 'primary', 'submit', false ); ?>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;" onsubmit="return confirm('<?php echo esc_js( __( 'Reset ingestion progress and start a full crawl on the next run?', 'viator-collector' ) ); ?>');">
<input type="hidden" name="action" value="vc_reset_sync">
<?php wp_nonce_field( 'vc_reset_sync' ); ?>
<?php submit_button( __( 'Reset ingestion', 'viator-collector' ), 'delete', 'submit', false ); ?>
</form>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;margin-left:1em;" onsubmit="return confirm('<?php echo esc_js( __( 'This permanently deletes ALL imported tours, destination/tag terms, and sync data. Your settings are kept. Continue?', 'viator-collector' ) ); ?>');">
<input type="hidden" name="action" value="vc_delete_all">
<?php wp_nonce_field( 'vc_delete_all' ); ?>
<?php submit_button( __( 'Delete all imported data', 'viator-collector' ), 'delete', 'submit', false ); ?>
<p class="description"><?php echo esc_html__( 'Removes every tour and term this plugin created. Settings (API key, destination filter) are preserved.', 'viator-collector' ); ?></p>
</form>
<hr>
<h2><?php echo esc_html__( 'Import a single product', 'viator-collector' ); ?></h2>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="vc_import_product">
<?php wp_nonce_field( 'vc_import_product' ); ?>
<label for="vc_product_code"><?php echo esc_html__( 'Viator product code', 'viator-collector' ); ?></label>
<input id="vc_product_code" type="text" name="product_code" required>
<?php submit_button( __( 'Import product', 'viator-collector' ), 'secondary', 'submit', false ); ?>
</form>
<hr>
<h2><?php echo esc_html__( 'Activity log', 'viator-collector' ); ?></h2>
<?php $log = VC_Logger::get_log(); ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="margin-bottom:1em;">
<input type="hidden" name="action" value="vc_clear_log">
<?php wp_nonce_field( 'vc_clear_log' ); ?>
<?php submit_button( __( 'Clear log', 'viator-collector' ), 'secondary', 'submit', false ); ?>
</form>
<?php if ( empty( $log ) ) : ?>
<p><?php echo esc_html__( 'No activity recorded yet.', 'viator-collector' ); ?></p>
<?php else : ?>
<table class="widefat striped" style="max-width:900px;">
<thead>
<tr>
<th style="width:160px;"><?php echo esc_html__( 'Time', 'viator-collector' ); ?></th>
<th style="width:70px;"><?php echo esc_html__( 'Level', 'viator-collector' ); ?></th>
<th><?php echo esc_html__( 'Message', 'viator-collector' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $log as $entry ) : ?>
<?php $is_error = isset( $entry['level'] ) && 'error' === $entry['level']; ?>
<tr>
<td><?php echo esc_html( isset( $entry['t'] ) ? $entry['t'] : '' ); ?></td>
<td><span style="font-weight:600;color:<?php echo $is_error ? '#b32d2e' : '#2271b1'; ?>;"><?php echo $is_error ? esc_html__( 'ERROR', 'viator-collector' ) : esc_html__( 'INFO', 'viator-collector' ); ?></span></td>
<td><?php echo esc_html( isset( $entry['msg'] ) ? $entry['msg'] : '' ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php
}
public static function handle_test_api() {
self::verify_action( 'vc_test_api' );
$result = ( new VC_API() )->get_destinations();
if ( is_wp_error( $result ) ) {
VC_Logger::error( 'API test failed: ' . $result->get_error_message() );
self::redirect_notice( $result->get_error_message() );
}
VC_Logger::info( 'API test connection successful.' );
self::redirect_notice( __( 'API connection successful.', 'viator-collector' ) );
}
public static function handle_manual_sync() {
self::verify_action( 'vc_manual_sync' );
VC_Cron::run();
$totals = get_option( 'vc_ingest_totals', array() );
if ( get_option( 'vc_ingest_in_progress' ) ) {
self::redirect_notice( sprintf(
/* translators: %s: JSON progress counts. */
__( 'Sync started and continues in the background. Progress so far: %s', 'viator-collector' ),
wp_json_encode( $totals )
) );
}
self::redirect_notice( sprintf(
/* translators: %s: JSON result counts. */
__( 'Sync complete. Result: %s', 'viator-collector' ),
wp_json_encode( get_option( 'vc_last_sync_totals', $totals ) )
) );
}
public static function handle_reset_sync() {
self::verify_action( 'vc_reset_sync' );
VC_Cron::reset();
self::redirect_notice( __( 'Ingestion progress reset. The next run will crawl the full catalogue.', 'viator-collector' ) );
}
public static function handle_clear_log() {
self::verify_action( 'vc_clear_log' );
VC_Logger::clear_log();
self::redirect_notice( __( 'Activity log cleared.', 'viator-collector' ) );
}
/**
* Delete every imported tour, all plugin taxonomy terms, and sync/log
* state. Plugin settings (API key, filter, etc.) are kept.
*/
public static function handle_delete_all() {
self::verify_action( 'vc_delete_all' );
global $wpdb;
// Stop and clear any in-progress run first.
VC_Cron::reset();
// Tour posts + their meta + term links.
$ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", 'viator_tour' ) );
if ( ! empty( $ids ) ) {
$in = implode( ',', array_map( 'intval', $ids ) );
$wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($in)" ); // phpcs:ignore WordPress.DB.PreparedSQL
$wpdb->query( "DELETE FROM {$wpdb->term_relationships} WHERE object_id IN ($in)" ); // phpcs:ignore WordPress.DB.PreparedSQL
$wpdb->query( "DELETE FROM {$wpdb->posts} WHERE ID IN ($in)" ); // phpcs:ignore WordPress.DB.PreparedSQL
}
// Plugin taxonomy terms (WP API is safe; counts are small).
foreach ( array( 'viator_destination', 'viator_tag', 'viator_category' ) as $taxonomy ) {
$terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'fields' => 'ids' ) );
if ( ! is_wp_error( $terms ) ) {
foreach ( $terms as $term_id ) {
wp_delete_term( (int) $term_id, $taxonomy );
}
}
}
// Sync state, logs, transients — but keep the saved settings.
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE ( option_name LIKE 'vc\\_%' AND option_name <> 'vc_settings' ) OR option_name LIKE '\\_transient\\_vc\\_%' OR option_name LIKE '\\_transient\\_timeout\\_vc\\_%'" ); // phpcs:ignore WordPress.DB.PreparedSQL
wp_cache_flush();
$count = is_array( $ids ) ? count( $ids ) : 0;
self::redirect_notice( sprintf(
/* translators: %d: number of deleted tours. */
__( 'Deleted %d tours and all imported data. Your settings were kept.', 'viator-collector' ),
$count
) );
}
public static function handle_import_product() {
self::verify_action( 'vc_import_product' );
$product_code = isset( $_POST['product_code'] ) ? sanitize_text_field( wp_unslash( $_POST['product_code'] ) ) : '';
$result = ( new VC_Importer() )->import_product_by_code( $product_code );
if ( is_wp_error( $result ) ) {
VC_Logger::error( 'Manual product import failed (' . $product_code . '): ' . $result->get_error_message() );
self::redirect_notice( $result->get_error_message() );
}
VC_Logger::info( 'Manual product import succeeded: ' . $product_code );
self::redirect_notice( __( 'Product imported successfully.', 'viator-collector' ) );
}
private static function verify_action( $nonce_action ) {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to perform this action.', 'viator-collector' ) );
}
check_admin_referer( $nonce_action );
}
private static function redirect_notice( $message ) {
wp_safe_redirect(
add_query_arg(
array(
'page' => 'viator-collector',
'vc_notice' => rawurlencode( sanitize_text_field( $message ) ),
),
admin_url( 'options-general.php' )
)
);
exit;
}
}

View File

@@ -0,0 +1,257 @@
<?php
defined( 'ABSPATH' ) || exit;
class VC_Taxonomies {
public static function init() {
add_action( 'init', array( __CLASS__, 'register' ) );
foreach ( array( 'viator_destination', 'viator_tag', 'viator_category' ) as $taxonomy ) {
add_action( "{$taxonomy}_add_form_fields", array( __CLASS__, 'add_image_field' ) );
add_action( "{$taxonomy}_edit_form_fields", array( __CLASS__, 'edit_image_field' ), 10, 1 );
add_action( "created_{$taxonomy}", array( __CLASS__, 'save_image_field' ) );
add_action( "edited_{$taxonomy}", array( __CLASS__, 'save_image_field' ) );
}
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'admin_media' ) );
}
/**
* The media library is only needed on our term screens.
*/
public static function admin_media( $hook ) {
if ( 'term.php' !== $hook && 'edit-tags.php' !== $hook ) {
return;
}
$taxonomy = isset( $_GET['taxonomy'] ) ? sanitize_key( $_GET['taxonomy'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
if ( in_array( $taxonomy, array( 'viator_destination', 'viator_tag', 'viator_category' ), true ) ) {
wp_enqueue_media();
}
}
/**
* Media-library picker shared by the add and edit term forms.
*/
private static function image_picker( $value ) {
wp_nonce_field( 'vc_term_image', 'vc_term_image_nonce' );
?>
<input type="hidden" id="vc_image_url" name="vc_image_url" value="<?php echo esc_attr( $value ); ?>">
<button type="button" class="button" id="vc-term-img-pick"><?php echo esc_html__( 'Select image', 'viator-collector' ); ?></button>
<button type="button" class="button" id="vc-term-img-clear"><?php echo esc_html__( 'Remove', 'viator-collector' ); ?></button>
<div id="vc-term-img-preview" style="margin-top:8px;">
<?php if ( $value ) : ?>
<img src="<?php echo esc_url( $value ); ?>" alt="" style="max-width:200px;height:auto;border-radius:6px;">
<?php endif; ?>
</div>
<p class="description">
<?php echo esc_html__( 'Used on the Top Destinations cards and as this term archive\'s banner. If empty, a tour photo from this term is used automatically.', 'viator-collector' ); ?>
</p>
<script>
jQuery(function ($) {
var frame;
$('#vc-term-img-pick').on('click', function (e) {
e.preventDefault();
frame = wp.media({ library: { type: 'image' }, multiple: false });
frame.on('select', function () {
var img = frame.state().get('selection').first().toJSON();
$('#vc_image_url').val(img.url);
$('#vc-term-img-preview').html($('<img>', { src: img.url }).css({ maxWidth: '200px', height: 'auto', borderRadius: '6px' }));
});
frame.open();
});
$('#vc-term-img-clear').on('click', function (e) {
e.preventDefault();
$('#vc_image_url').val('');
$('#vc-term-img-preview').empty();
});
});
</script>
<?php
}
public static function add_image_field() {
?>
<div class="form-field">
<label><?php echo esc_html__( 'Image', 'viator-collector' ); ?></label>
<?php self::image_picker( '' ); ?>
</div>
<?php
}
public static function edit_image_field( $term ) {
$value = (string) get_term_meta( $term->term_id, '_vc_image_url', true );
?>
<tr class="form-field">
<th scope="row"><label><?php echo esc_html__( 'Image', 'viator-collector' ); ?></label></th>
<td><?php self::image_picker( $value ); ?></td>
</tr>
<?php
}
public static function save_image_field( $term_id ) {
if ( ! current_user_can( 'manage_categories' ) ) {
return;
}
if ( ! isset( $_POST['vc_term_image_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['vc_term_image_nonce'] ) ), 'vc_term_image' ) ) {
return;
}
if ( ! isset( $_POST['vc_image_url'] ) ) {
return;
}
$url = esc_url_raw( wp_unslash( $_POST['vc_image_url'] ) );
if ( '' === $url ) {
delete_term_meta( $term_id, '_vc_image_url' );
} else {
update_term_meta( $term_id, '_vc_image_url', $url );
}
delete_transient( 'vc_term_img_' . absint( $term_id ) );
}
public static function register() {
self::register_taxonomy( 'viator_destination', __( 'Viator Destinations', 'viator-collector' ), __( 'Viator Destination', 'viator-collector' ), true, 'tour-destination' );
self::register_taxonomy( 'viator_tag', __( 'Viator Tags', 'viator-collector' ), __( 'Viator Tag', 'viator-collector' ), false, 'tour-tag' );
self::register_taxonomy( 'viator_category', __( 'Viator Categories', 'viator-collector' ), __( 'Viator Category', 'viator-collector' ), true, 'tour-category' );
}
private static function register_taxonomy( $taxonomy, $plural, $singular, $hierarchical, $slug ) {
register_taxonomy(
$taxonomy,
array( 'viator_tour' ),
array(
'labels' => array(
'name' => $plural,
'singular_name' => $singular,
),
'public' => true,
'hierarchical' => $hierarchical,
'rewrite' => array( 'slug' => $slug ),
'show_in_rest' => true,
)
);
}
public static function get_or_create_destination( $item ) {
$name = self::term_name( $item );
if ( '' === $name ) {
return 0;
}
$id = vc_first_non_empty( array( vc_array_get( $item, 'destinationId' ), vc_array_get( $item, 'id' ) ) );
$ref = vc_first_non_empty( array( vc_array_get( $item, 'destinationRef' ), vc_array_get( $item, 'ref' ) ) );
return self::get_or_create_term( 'viator_destination', $name, '_vc_viator_destination_id', $id, array( '_vc_viator_destination_ref' => $ref ) );
}
public static function get_or_create_tag( $item ) {
$name = self::term_name( $item );
$id = vc_first_non_empty( array( vc_array_get( $item, 'tagId' ), vc_array_get( $item, 'id' ) ) );
return self::get_or_create_term( 'viator_tag', $name, '_vc_viator_tag_id', $id );
}
/**
* Resolve a destination term from a numeric Viator destination id, using
* a name looked up from the destinations dictionary.
*/
public static function get_or_create_destination_by_id( $id, $name = '' ) {
$id = (string) $id;
if ( '' === $id ) {
return 0;
}
$name = sanitize_text_field( (string) $name );
if ( '' === $name ) {
/* translators: %s: Viator destination id. */
$name = sprintf( __( 'Destination %s', 'viator-collector' ), $id );
}
return self::get_or_create_term( 'viator_destination', $name, '_vc_viator_destination_id', $id );
}
/**
* Resolve a tag term from a numeric Viator tag id, using a name looked up
* from the tags dictionary.
*/
public static function get_or_create_tag_by_id( $id, $name = '' ) {
$id = (string) $id;
if ( '' === $id ) {
return 0;
}
$name = sanitize_text_field( (string) $name );
if ( '' === $name ) {
/* translators: %s: Viator tag id. */
$name = sprintf( __( 'Tag %s', 'viator-collector' ), $id );
}
return self::get_or_create_term( 'viator_tag', $name, '_vc_viator_tag_id', $id );
}
public static function get_or_create_category( $item ) {
$name = self::term_name( $item );
$id = vc_first_non_empty( array( vc_array_get( $item, 'categoryId' ), vc_array_get( $item, 'id' ) ) );
return self::get_or_create_term( 'viator_category', $name, '_vc_viator_category_id', $id );
}
private static function term_name( $item ) {
if ( is_scalar( $item ) ) {
return sanitize_text_field( (string) $item );
}
if ( ! is_array( $item ) ) {
return '';
}
return sanitize_text_field(
vc_first_non_empty(
array(
vc_array_get( $item, 'name' ),
vc_array_get( $item, 'destinationName' ),
vc_array_get( $item, 'tagName' ),
vc_array_get( $item, 'categoryName' ),
vc_array_get( $item, 'title' ),
)
)
);
}
private static function get_or_create_term( $taxonomy, $name, $meta_key, $viator_id = '', $extra_meta = array() ) {
if ( '' === $name ) {
return 0;
}
if ( '' !== (string) $viator_id ) {
$found = get_terms(
array(
'taxonomy' => $taxonomy,
'hide_empty' => false,
'number' => 1,
'meta_query' => array(
array(
'key' => $meta_key,
'value' => (string) $viator_id,
),
),
)
);
if ( ! is_wp_error( $found ) && ! empty( $found ) ) {
self::update_term_meta( $found[0]->term_id, $meta_key, $viator_id, $extra_meta );
return (int) $found[0]->term_id;
}
}
$term = term_exists( $name, $taxonomy );
if ( 0 === $term || null === $term ) {
$term = wp_insert_term( $name, $taxonomy );
}
if ( is_wp_error( $term ) ) {
VC_Logger::log( 'Term error: ' . $term->get_error_message(), true );
return 0;
}
$term_id = is_array( $term ) ? (int) $term['term_id'] : (int) $term;
self::update_term_meta( $term_id, $meta_key, $viator_id, $extra_meta );
return $term_id;
}
private static function update_term_meta( $term_id, $meta_key, $viator_id, $extra_meta ) {
if ( '' !== (string) $viator_id ) {
update_term_meta( $term_id, $meta_key, sanitize_text_field( (string) $viator_id ) );
}
foreach ( $extra_meta as $key => $value ) {
if ( '' !== (string) $value ) {
update_term_meta( $term_id, $key, sanitize_text_field( (string) $value ) );
}
}
}
}

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();
}