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
218 lines
6.5 KiB
PHP
218 lines
6.5 KiB
PHP
<?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;
|
|
}
|
|
}
|