Files
hoponhopofftickets/viator-collector/includes/class-vc-city-guide.php
Amir Bagheri 2ded000a27 feat: city guide on city pages (tagline, facts, About, seasons)
Editable per city under Viator tours > Destinations. Shown above the
tours on page 1 of an unfiltered listing only; filtering and paging jump
to the results. Text recovered from the six old /tour-destination/ pages
is imported once into empty guides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:58:54 +03:30

373 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* City guide: the editable text shown on a city page above its tours — a
* tagline in the banner, a facts bar, an "About" section and seasonal cards.
*
* Stored per destination term in one meta array (_vc_guide). Edited on the
* term screen (Viator tours → Destinations → edit a city). Shown on page 1 of
* the city's listing only, and never while filters are applied, so browsing
* and filtering land straight on the tours.
*/
defined( 'ABSPATH' ) || exit;
class VC_City_Guide {
const META = '_vc_guide';
/** Slugs from data/city-guides.php already imported (or skipped because the guide had text). */
const IMPORTED_OPTION = 'vc_city_guides_imported';
public static function init() {
add_action( 'viator_destination_edit_form_fields', array( __CLASS__, 'edit_fields' ), 20 );
add_action( 'edited_viator_destination', array( __CLASS__, 'save' ) );
add_action( 'admin_init', array( __CLASS__, 'import_bundled' ) );
}
/**
* Field key => [label, type, help]. Pure (no WordPress calls) so tests can
* check the bundled data against it.
*/
public static function fields() {
return array(
'tagline' => array( 'Tagline', 'text', 'One short line under the city name in the banner, e.g. "Where is America’s tropical star?"' ),
'language' => array( 'Language', 'text', 'Facts bar. E.g. "English, Spanish".' ),
'currency' => array( 'Currency', 'text', 'Facts bar. E.g. "US Dollar (USD)".' ),
'timezone' => array( 'Time zone', 'text', 'Facts bar. E.g. "GMT-5".' ),
'best_time' => array( 'Best time to visit', 'text', 'Facts bar. E.g. "March – May".' ),
'about_title' => array( 'About heading', 'text', 'Heading of the About section. Empty uses "Discover {city}".' ),
'about' => array( 'About text', 'html', 'History and introduction. Long text folds behind a "Read more" button.' ),
'about_image' => array( 'About photo', 'image', 'Photo beside the About text. Empty uses one of the city\'s tour photos.' ),
'spring' => array( 'Spring', 'textarea', 'Things to do in spring (2–3 sentences).' ),
'summer' => array( 'Summer', 'textarea', 'Things to do in summer.' ),
'fall' => array( 'Fall', 'textarea', 'Things to do in fall.' ),
'winter' => array( 'Winter', 'textarea', 'Things to do in winter.' ),
'southern' => array( 'Southern hemisphere', 'checkbox', 'Tick for cities south of the equator (e.g. Sydney, Cape Town) so the season months are flipped.' ),
);
}
public static function get( $term_id ) {
$saved = get_term_meta( (int) $term_id, self::META, true );
$saved = is_array( $saved ) ? $saved : array();
$guide = array();
foreach ( self::fields() as $key => $field ) {
$guide[ $key ] = isset( $saved[ $key ] ) ? $saved[ $key ] : ( 'checkbox' === $field[1] ? false : '' );
}
return $guide;
}
public static function sanitize( array $raw ) {
$clean = array();
foreach ( self::fields() as $key => $field ) {
$value = isset( $raw[ $key ] ) ? $raw[ $key ] : '';
switch ( $field[1] ) {
case 'html':
$clean[ $key ] = trim( wp_kses_post( (string) $value ) );
break;
case 'textarea':
$clean[ $key ] = trim( sanitize_textarea_field( (string) $value ) );
break;
case 'image':
$clean[ $key ] = esc_url_raw( (string) $value );
break;
case 'checkbox':
$clean[ $key ] = ! empty( $value );
break;
default:
$clean[ $key ] = trim( sanitize_text_field( (string) $value ) );
}
}
return $clean;
}
/** True when no text field is filled (the hemisphere flag alone doesn't count). */
public static function is_empty( array $guide ) {
foreach ( self::fields() as $key => $field ) {
if ( 'checkbox' !== $field[1] && 'about_image' !== $key && ! empty( $guide[ $key ] ) ) {
return false;
}
}
return true;
}
/**
* Months for each season. Pure.
*/
public static function season_months( $southern ) {
$north = array(
'spring' => 'March – May',
'summer' => 'June – August',
'fall' => 'September – November',
'winter' => 'December – February',
);
if ( ! $southern ) {
return $north;
}
return array(
'spring' => $north['fall'],
'summer' => $north['winter'],
'fall' => $north['spring'],
'winter' => $north['summer'],
);
}
/* ------------------------------------------------------------------ admin */
public static function edit_fields( $term ) {
if ( get_term_meta( $term->term_id, '_vc_region', true ) ) {
return; // Regions use their own page; the guide is for cities.
}
$guide = self::get( $term->term_id );
wp_nonce_field( 'vc_guide_save', 'vc_guide_nonce' );
?>
<tr class="form-field"><th colspan="2" style="padding-bottom:0">
<h2 style="margin:1.5em 0 .25em"><?php echo esc_html__( 'City guide', 'viator-collector' ); ?></h2>
<p class="description" style="font-weight:400"><?php echo esc_html__( 'Shown on the city page above its tours. Anything left empty is hidden.', 'viator-collector' ); ?></p>
</th></tr>
<?php
foreach ( self::fields() as $key => $field ) {
list( $label, $type, $help ) = $field;
$name = 'vc_guide[' . $key . ']';
$id = 'vc-guide-' . $key;
$value = $guide[ $key ];
?>
<tr class="form-field">
<th scope="row"><label for="<?php echo esc_attr( $id ); ?>"><?php echo esc_html( $label ); ?></label></th>
<td>
<?php
if ( 'html' === $type ) {
wp_editor(
(string) $value,
'vcguideabout',
array(
'textarea_name' => $name,
'textarea_rows' => 8,
'media_buttons' => false,
'teeny' => true,
)
);
} elseif ( 'textarea' === $type ) {
echo '<textarea id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '" rows="3">' . esc_textarea( $value ) . '</textarea>';
} elseif ( 'checkbox' === $type ) {
echo '<label><input type="checkbox" id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '" value="1" ' . checked( (bool) $value, true, false ) . ' style="width:auto"> ' . esc_html__( 'Yes', 'viator-collector' ) . '</label>';
} elseif ( 'image' === $type ) {
self::image_field( $id, $name, (string) $value );
} else {
echo '<input type="text" id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '">';
}
?>
<p class="description"><?php echo esc_html( $help ); ?></p>
</td>
</tr>
<?php
}
}
private static function image_field( $id, $name, $value ) {
?>
<input type="hidden" id="<?php echo esc_attr( $id ); ?>" name="<?php echo esc_attr( $name ); ?>" value="<?php echo esc_attr( $value ); ?>">
<button type="button" class="button" id="<?php echo esc_attr( $id ); ?>-pick"><?php echo esc_html__( 'Select image', 'viator-collector' ); ?></button>
<button type="button" class="button" id="<?php echo esc_attr( $id ); ?>-clear"><?php echo esc_html__( 'Remove', 'viator-collector' ); ?></button>
<div id="<?php echo esc_attr( $id ); ?>-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>
<script>
jQuery(function ($) {
var id = <?php echo wp_json_encode( $id ); ?>, frame;
$('#' + id + '-pick').on('click', function (e) {
e.preventDefault();
frame = frame || wp.media({ library: { type: 'image' }, multiple: false });
frame.off('select').on('select', function () {
var img = frame.state().get('selection').first().toJSON();
$('#' + id).val(img.url);
$('#' + id + '-preview').html($('<img>', { src: img.url }).css({ maxWidth: '200px', height: 'auto', borderRadius: '6px' }));
});
frame.open();
});
$('#' + id + '-clear').on('click', function (e) {
e.preventDefault();
$('#' + id).val('');
$('#' + id + '-preview').empty();
});
});
</script>
<?php
}
public static function save( $term_id ) {
if ( ! current_user_can( 'manage_categories' ) ) {
return;
}
if ( ! isset( $_POST['vc_guide_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['vc_guide_nonce'] ) ), 'vc_guide_save' ) ) {
return;
}
$raw = isset( $_POST['vc_guide'] ) && is_array( $_POST['vc_guide'] ) ? wp_unslash( $_POST['vc_guide'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per field below.
$guide = self::sanitize( $raw );
if ( self::is_empty( $guide ) && ! $guide['southern'] && '' === $guide['about_image'] ) {
delete_term_meta( $term_id, self::META );
} else {
update_term_meta( $term_id, self::META, $guide );
}
}
/**
* Fill empty guides from the text recovered from the old site. Each slug is
* handled once; cities that don't exist yet are retried at most hourly.
*/
public static function import_bundled() {
if ( ! current_user_can( 'manage_categories' ) || get_transient( 'vc_city_guides_check' ) ) {
return;
}
$data = include VC_PLUGIN_DIR . 'data/city-guides.php';
$done = (array) get_option( self::IMPORTED_OPTION, array() );
$todo = array_diff( array_keys( $data ), $done );
foreach ( $todo as $slug ) {
$term = get_term_by( 'slug', $slug, 'viator_destination' );
if ( ! $term ) {
continue;
}
if ( self::is_empty( self::get( $term->term_id ) ) ) {
update_term_meta( $term->term_id, self::META, self::sanitize( $data[ $slug ] ) );
}
$done[] = $slug;
}
update_option( self::IMPORTED_OPTION, array_values( array_unique( $done ) ), false );
if ( array_diff( array_keys( $data ), $done ) ) {
set_transient( 'vc_city_guides_check', 1, HOUR_IN_SECONDS );
}
}
/* --------------------------------------------------------------- front end */
/** The guide is shown on page 1 of an unfiltered city listing only. */
public static function is_visible_here() {
$paged = max( (int) get_query_var( 'paged' ), isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 ); // phpcs:ignore WordPress.Security.NonceVerification
return $paged <= 1 && ! VC_Display::filters_active( VC_Display::get_filter_state() );
}
/**
* Facts bar, About and Seasons for $term, or '' when the guide is empty.
*/
public static function render( $term ) {
$guide = self::get( $term->term_id );
if ( self::is_empty( $guide ) ) {
return '';
}
$city = $term->name;
$out = '';
$facts = array(
'language' => array( __( 'Language', 'viator-collector' ), 'lang' ),
'currency' => array( __( 'Currency', 'viator-collector' ), 'cur' ),
'timezone' => array( __( 'Time zone', 'viator-collector' ), 'time' ),
'best_time' => array( __( 'Best time to visit', 'viator-collector' ), 'sun' ),
);
$items = '';
foreach ( $facts as $key => $fact ) {
if ( '' !== $guide[ $key ] ) {
$items .= '<li class="cfact"><span class="cfact__ic">' . self::icon( $fact[1] ) . '</span><span><small>' . esc_html( $fact[0] ) . '</small><b>' . esc_html( $guide[ $key ] ) . '</b></span></li>';
}
}
if ( $items ) {
// Wrapped: direct children of .vc-app are forced full width.
$out .= '<div class="cty-top"><div class="container"><ul class="cty-facts">' . $items . '</ul></div></div>';
}
if ( '' !== $guide['about'] ) {
$img = $guide['about_image'] ? $guide['about_image'] : self::fallback_image( $term );
/* translators: %s: city name. */
$title = '' !== $guide['about_title'] ? $guide['about_title'] : sprintf( __( 'Discover %s', 'viator-collector' ), $city );
$out .= '<section class="section cty-about"><div class="container"><div class="cty-about__grid' . ( $img ? '' : ' cty-about__grid--text' ) . '">';
if ( $img ) {
$out .= '<img class="cty-about__img" src="' . esc_url( $img ) . '" alt="' . esc_attr( $city ) . '" loading="lazy" decoding="async">';
}
/* translators: %s: city name. */
$out .= '<div><p class="pill cty-eyebrow">' . esc_html( sprintf( __( 'About %s', 'viator-collector' ), $city ) ) . '</p>'
. '<h2 class="cty-h2">' . esc_html( $title ) . '</h2>'
. '<div class="cty-about__text" data-vc-clamp>' . wpautop( $guide['about'] ) . '</div>'
. '<button class="cty-more" type="button" hidden aria-expanded="false"><span>' . esc_html__( 'Read more', 'viator-collector' ) . '</span>' . vc_icon( 'chevron' ) . '</button>'
. '</div></div></div></section>';
}
$months = self::season_months( $guide['southern'] );
$seasons = array(
'spring' => array( __( 'Spring', 'viator-collector' ), 'flower' ),
'summer' => array( __( 'Summer', 'viator-collector' ), 'sun' ),
'fall' => array( __( 'Fall', 'viator-collector' ), 'leaf' ),
'winter' => array( __( 'Winter', 'viator-collector' ), 'snow' ),
);
$cards = '';
foreach ( $seasons as $key => $season ) {
if ( '' !== $guide[ $key ] ) {
$cards .= '<li class="season season--' . esc_attr( $key ) . '"><span class="season__ic">' . self::icon( $season[1] ) . '</span>'
. '<h3>' . esc_html( $season[0] ) . '</h3><small>' . esc_html( $months[ $key ] ) . '</small>'
. '<p>' . esc_html( $guide[ $key ] ) . '</p></li>';
}
}
if ( $cards ) {
/* translators: %s: city name. */
$out .= '<section class="section cty-seasons"><div class="container"><p class="pill cty-eyebrow">' . esc_html__( 'Seasonal activities', 'viator-collector' ) . '</p>'
. '<h2 class="cty-h2">' . esc_html( sprintf( __( 'What to do in %s, all year round', 'viator-collector' ), $city ) ) . '</h2>'
. '<ul class="seasons">' . $cards . '</ul></div></section>';
}
return $out;
}
public static function tagline( $term ) {
return self::get( $term->term_id )['tagline'];
}
/**
* A tour photo of the city that isn't the banner photo, cached for a day.
*/
private static function fallback_image( $term ) {
$key = 'vc_guide_img_' . $term->term_id;
$cached = get_transient( $key );
if ( false !== $cached ) {
return $cached;
}
$hero = vc_term_image_url( $term->term_id, $term->taxonomy );
$query = new WP_Query(
array(
'post_type' => 'viator_tour',
'post_status' => 'publish',
'posts_per_page' => 10,
'fields' => 'ids',
'no_found_rows' => true,
'update_post_term_cache' => false,
'tax_query' => array(
array(
'taxonomy' => $term->taxonomy,
'field' => 'term_id',
'terms' => $term->term_id,
),
),
)
);
$url = '';
foreach ( $query->posts as $post_id ) {
$candidate = (string) get_post_meta( (int) $post_id, '_vc_primary_image_url', true );
if ( '' !== $candidate && $candidate !== $hero ) {
$url = $candidate;
break;
}
}
set_transient( $key, $url, $url ? DAY_IN_SECONDS : HOUR_IN_SECONDS );
return $url;
}
private static function icon( $name ) {
$paths = array(
'lang' => '<path d="M4 5h9M8.5 3v2m3 0c-.8 4-3.6 7-7 8.5m2.5-5c1 2 2.6 3.6 4.5 4.5M13 21l4-9 4 9m-6.8-3h5.6"/>',
'cur' => '<rect x="2.5" y="6" width="19" height="12" rx="2"/><circle cx="12" cy="12" r="2.5"/><path d="M6 9.5v5M18 9.5v5"/>',
'time' => '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
'sun' => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
'flower' => '<circle cx="12" cy="12" r="2.5"/><path d="M12 9.5C10 6 10 4 12 3c2 1 2 3 0 6.5Zm0 5c2 3.5 2 5.5 0 6.5-2-1-2-3 0-6.5Zm2.5-2.5c3.5-2 5.5-2 6.5 0-1 2-3 2-6.5 0Zm-5 0c-3.5 2-5.5 2-6.5 0 1-2 3-2 6.5 0Z"/>',
'leaf' => '<path d="M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 7-7"/>',
'snow' => '<path d="M12 2v20M3.3 7l17.4 10M3.3 17 20.7 7M9 4l3 2 3-2M9 20l3-2 3 2"/>',
);
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' . $paths[ $name ] . '</svg>';
}
}