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>
This commit is contained in:
2026-10-31 09:58:54 +03:30
parent 466ea0ac5f
commit 2ded000a27
12 changed files with 846 additions and 10 deletions

View File

@@ -0,0 +1,372 @@
<?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>';
}
}

View File

@@ -168,6 +168,10 @@ class VC_Display {
// Tour page top section + mobile booking bar (after the overrides above).
wp_enqueue_style( 'vc-tour', VC_PLUGIN_URL . 'assets/css/vc-tour.css', array( 'vc-design' ), VC_VERSION );
}
if ( is_tax( 'viator_destination' ) ) {
// City guide: tagline, facts bar, About and Seasons (see VC_City_Guide).
wp_enqueue_style( 'vc-city', VC_PLUGIN_URL . 'assets/css/vc-city.css', array( 'vc-design' ), VC_VERSION );
}
// Front-end interactions: card rails, FAQ accordion, testimonials
// marquee, date field, and auto-submit on quick filter toggles.
@@ -329,6 +333,16 @@ class VC_Display {
);
}
/**
* Whether any filter in $f (from get_filter_state()) narrows the results.
* Sorting alone doesn't count.
*/
public static function filters_active( array $f ) {
return '' !== $f['q'] || ! empty( $f['dest'] ) || ! empty( $f['cat'] ) || ! empty( $f['dur'] )
|| $f['pmin'] > 0 || $f['pmax'] < 1000 || $f['rating'] > 0
|| $f['freecancel'] || $f['bestdeal'] || $f['sellout'];
}
/**
* Apply the filter state to the main tours query (archive + taxonomy pages).
*/

View File

@@ -25,6 +25,7 @@ final class VC_Plugin {
VC_Cron::init();
VC_Display::init();
VC_Sections::init();
VC_City_Guide::init();
}
private function load_files() {
@@ -44,6 +45,7 @@ final class VC_Plugin {
'includes/class-vc-menu.php',
'includes/class-vc-display.php',
'includes/class-vc-sections.php',
'includes/class-vc-city-guide.php',
);
foreach ( $files as $file ) {