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

Binary file not shown.

View File

@@ -0,0 +1,270 @@
/* City guide on a destination page (VC_City_Guide): tagline in the banner,
facts bar, About and Seasons above the tours. */
.vc-app.vc-app .hero__tagline {
order: 2;
max-width: 40ch;
margin: calc(var(--sp-2) * -1) 0 0;
color: var(--c-white);
font-size: clamp(1.05rem, 0.9rem + 0.6vw, 1.375rem);
font-weight: 500;
line-height: 1.35;
opacity: 0.92;
}
/* ---------- facts bar ---------- */
.vc-app.vc-app .cty-top {
margin-block-start: var(--sp-5);
}
.vc-app.vc-app .cty-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
margin: 0;
padding: var(--sp-3) var(--sp-2);
list-style: none;
background: var(--c-white);
border-radius: var(--r-card);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--c-line);
}
.vc-app.vc-app .cfact {
display: flex;
align-items: center;
gap: 0.8rem;
margin: 0;
padding: 0.25rem 1rem;
}
.vc-app.vc-app .cfact + .cfact {
border-inline-start: 1px solid var(--c-line);
}
.vc-app.vc-app .cfact__ic {
display: grid;
flex: none;
place-items: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 50%;
color: #0f7f90;
background: var(--c-teal-20);
}
.vc-app.vc-app .cfact__ic svg {
width: 1.35rem;
height: 1.35rem;
}
.vc-app.vc-app .cfact small {
display: block;
color: var(--c-muted);
font-size: 0.8rem;
font-weight: 500;
}
.vc-app.vc-app .cfact b {
display: block;
color: var(--c-ink);
font-size: 1rem;
font-weight: 700;
}
/* ---------- shared headings ---------- */
.vc-app.vc-app .cty-eyebrow {
margin: 0 0 var(--sp-2);
}
.vc-app.vc-app .cty-h2 {
margin: 0 0 var(--sp-3);
color: var(--c-ink);
font-size: var(--fs-h2);
font-weight: 800;
line-height: 1.15;
}
.vc-app.vc-app .cty-tours-h {
margin-block-end: var(--sp-4);
}
/* ---------- about ---------- */
.vc-app.vc-app .cty-about__grid {
display: grid;
grid-template-columns: 5fr 6fr;
gap: var(--sp-6);
align-items: center;
}
.vc-app.vc-app .cty-about__grid--text {
grid-template-columns: minmax(0, 48rem);
}
.vc-app.vc-app .cty-about__img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: var(--r-xl);
}
.vc-app.vc-app .cty-about__text {
position: relative;
color: rgba(0, 0, 0, 0.72);
line-height: 1.7;
}
.vc-app.vc-app .cty-about__text p {
margin: 0 0 0.8rem;
}
.vc-app.vc-app .cty-about__text.is-clamped {
max-height: 11.5rem;
overflow: hidden;
}
.vc-app.vc-app .cty-about__text.is-clamped::after {
content: "";
position: absolute;
inset: auto 0 0;
height: 4rem;
background: linear-gradient(rgba(255, 255, 255, 0), var(--c-white));
}
.vc-app.vc-app .cty-more {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-block-start: var(--sp-2);
padding: 0;
border: 0;
color: var(--c-ink);
background: none;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.vc-app.vc-app .cty-more[hidden] {
display: none;
}
.vc-app.vc-app .cty-more[aria-expanded="true"] .icon {
transform: rotate(180deg);
}
/* ---------- seasons ---------- */
.vc-app.vc-app .cty-seasons {
padding-block-start: 0;
}
.vc-app.vc-app .seasons {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: var(--gap);
margin: var(--sp-4) 0 0;
padding: 0;
list-style: none;
}
.vc-app.vc-app .season {
--tint: #e9f7f9;
--ink: #0f7f90;
margin: 0;
padding: var(--sp-4);
border-radius: var(--r-card);
background: var(--tint);
}
.vc-app.vc-app .season--summer { --tint: #fff8db; --ink: #a37b00; }
.vc-app.vc-app .season--fall { --tint: #fdeee6; --ink: #c2551b; }
.vc-app.vc-app .season--winter { --tint: #eef1fb; --ink: #4a64c9; }
.vc-app.vc-app .season__ic {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
margin-block-end: var(--sp-3);
border-radius: 50%;
color: var(--ink);
background: var(--c-white);
}
.vc-app.vc-app .season__ic svg {
width: 1.5rem;
height: 1.5rem;
}
.vc-app.vc-app .season h3 {
margin: 0;
color: var(--c-ink);
font-size: 1.25rem;
font-weight: 800;
}
.vc-app.vc-app .season small {
display: block;
margin: 0.15rem 0 0.7rem;
color: var(--ink);
font-size: 0.82rem;
font-weight: 700;
}
.vc-app.vc-app .season p {
margin: 0;
color: rgba(0, 0, 0, 0.7);
font-size: 0.95rem;
line-height: 1.6;
}
/* The tours follow the guide directly: no double section gap. */
.vc-app.vc-app .cty-seasons + #tour-list,
.vc-app.vc-app .cty-about + #tour-list {
padding-block-start: 0;
}
@media (max-width: 48rem) {
.vc-app.vc-app .cty-facts {
grid-template-columns: 1fr 1fr;
row-gap: var(--sp-3);
padding-inline: 0.25rem;
}
.vc-app.vc-app .cfact {
padding-inline: 0.6rem;
}
.vc-app.vc-app .cfact:nth-child(odd) {
border-inline-start: 0;
}
.vc-app.vc-app .cfact__ic {
width: 2.25rem;
height: 2.25rem;
}
.vc-app.vc-app .cfact b {
font-size: 0.875rem;
}
.vc-app.vc-app .cty-about__grid {
grid-template-columns: 1fr;
gap: var(--sp-4);
}
/* Seasons swipe sideways. */
.vc-app.vc-app .seasons {
display: flex;
margin-inline: calc(var(--gutter) * -1);
padding-inline: var(--gutter);
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.vc-app.vc-app .season {
flex: 0 0 78%;
scroll-snap-align: start;
}
}

View File

@@ -86,6 +86,22 @@
});
});
/* ---------- city guide: long About text folds behind "Read more" ---------- */
Array.prototype.forEach.call(app.querySelectorAll("[data-vc-clamp]"), function (text) {
var btn = text.nextElementSibling;
text.classList.add("is-clamped");
if (!btn || text.scrollHeight <= text.clientHeight + 8) {
text.classList.remove("is-clamped");
return;
}
btn.hidden = false;
btn.addEventListener("click", function () {
var open = text.classList.toggle("is-clamped") === false;
btn.setAttribute("aria-expanded", open ? "true" : "false");
btn.querySelector("span").textContent = open ? "Read less" : "Read more";
});
});
/* ---------- single tour: "view all photos" scrolls the gallery ---------- */
var viewAll = app.querySelector(".tg-viewall, .gallery__viewall");
if (viewAll) viewAll.addEventListener("click", function () {

View File

@@ -0,0 +1,89 @@
<?php
/**
* City guide text recovered from the old /tour-destination/{city}/ pages
* (Wayback Machine, 14 Aug 2026). Imported once into empty guides; see
* VC_City_Guide::import_bundled().
*/
defined( 'ABSPATH' ) || exit;
return array (
'new-york-city' =>
array (
'language' => 'English',
'currency' => 'US Dollar (USD)',
'timezone' => 'GMT-5',
'tagline' => 'NYC is the best ever!',
'about_title' => 'New York’s Past: A Quick Look',
'about' => '<p>New York began with the Lenape people on its islands and rivers. Dutch settlers arrived in the 1600s, building a trade hub. Today, it’s a global city famed for finance, art, and vibrant culture. Historic brownstones stand alongside gleaming skyscrapers, blending old and new. From iconic landmarks to diverse neighborhoods, New York offers a dynamic mix of history and energy, making it an exciting destination for visitors to explore and enjoy.</p>
<p>New York City’s hop-on hop-off bus tour, bookable at hoponhopofftickets.com , is a dynamic way to explore with operators like Big Bus Tours. Covering 15 stops, including Times Square, Empire State Building, and Central Park, buses run every 10-15 minutes with a 90-minute loop. Attracting over 60 million annual visitors, it offers multilingual audio guides and wheelchair-accessible buses, though upper decks have restrictions. A downtown loop and night tour showcase Manhattan’s iconic skyline. Pets (except service dogs) are not allowed. Book at hoponhopofftickets.com for an unforgettable NYC adventure.</p>',
'spring' => 'Stroll Central Park for vibrant blooming trees or photograph the iconic Brooklyn Bridge. Enjoy a classic bagel at Ess-a-Bagel or catch a Yankees game at the lively stadium.',
'summer' => 'Relax at Coney Island’s beach or bike the Hudson River’s scenic path. Savor street food at Smorgasburg or enjoy free summer concerts in vibrant Central Park.',
'fall' => 'Wander the High Line for stunning fall colors or explore Greenwich Village’s charming streets. Sip coffee in trendy SoHo or attend the New York Film Festival.',
'winter' => 'Skate at Rockefeller Center’s rink or admire Dyker Heights’ holiday lights. On chilly days, visit the Met Museum or shop at iconic Fifth Avenue stores.',
),
'new-orleans' =>
array (
'language' => 'English',
'currency' => 'US Dollar (USD)',
'timezone' => 'GMT-6',
'tagline' => 'America’s soulful city',
'about_title' => 'New Orleans Past',
'about' => '<p>New Orleans began with Chitimacha and Choctaw peoples along the Mississippi River. In the 1700s, French settlers established a vibrant port town thriving on music and trade. Today, it’s a cultural gem celebrated for jazz, Creole cuisine, and festive parties. Historic French Quarter buildings stand beside lively bars, blending old-world charm with modern energy. From iconic festivals to soulful streets, New Orleans offers a unique mix of history and revelry, making it an exciting destination to explore.</p>
<p>New Orleans’ hop-on hop-off bus tour, bookable at hoponhopofftickets.com, is a lively way to explore the Crescent City with City Sightseeing. Covering 19 stops, including the French Quarter, Garden District, and Jackson Square, buses run every 30-45 minutes with a 90-minute loop. Popular with over 17 million annual visitors, it offers live or audio-guided commentary in English, with wheelchair-accessible buses (lower deck only). A free self-guided walking tour of the French Quarter is included. Pets (except service dogs) are not allowed. Book at hoponhopofftickets.com for a vibrant Big Easy adventure.</p>',
'spring' => 'Walk the French Quarter for vibrant street music or snap photos at Jackson Square. Savor beignets at Café du Monde or catch a Mardi Gras parade with colorful floats.',
'summer' => 'Enjoy a Mississippi riverboat ride or savor gumbo in the Garden District. Experience live music at Essence Festival or cool off with sno-balls at a local stand.',
'fall' => 'Wander City Park for autumn vibes or join a spooky ghost tour. Sip cocktails on lively Bourbon Street or enjoy music and art at Voodoo Fest.',
'winter' => 'Admire holiday lights in the French Quarter or enjoy warm beignets at Café du Monde. On mild days, bike along the Mississippi River or shop at the vibrant French Market.',
),
'miami' =>
array (
'language' => 'English',
'currency' => 'US Dollar (USD)',
'timezone' => 'GMT-5',
'tagline' => 'Where is America’s tropical star?',
'about_title' => 'Miami’s Past',
'about' => '<p>Miami began with the Tequesta people along its ocean and rivers. In the 1800s, it emerged as a trading hub, later booming with tourism and vibrant Latin culture. Today, it’s a global city renowned for pristine beaches, dynamic art, and pulsating music. Historic Art Deco buildings stand beside sleek modern clubs, blending retro charm with contemporary energy. From colorful neighborhoods to iconic shorelines, Miami offers a lively mix of culture and history, making it an exciting destination to explore.</p>
<p>Miami’s hop-on hop-off bus tour, bookable at hoponhopofftickets.com, offers a lively way to explore with Big Bus Tours. Covering 11 stops, including South Beach, Wynwood Walls, and Little Havana, buses run every 30 minutes with a 90-minute loop. Popular with over 8 million annual visitors to Miami, it features multilingual audio guides and wheelchair-accessible buses, though upper decks have restrictions. A night tour highlights Miami’s vibrant skyline. Pets (except service dogs) are not allowed. Book at hoponhopofftickets.com for a colorful city adventure.</p>',
'spring' => 'Stroll South Beach for sun and vibrant people-watching or photograph Wynwood Walls’ colorful street art. Kayak in scenic Biscayne Bay or join Spring Break parties for lively vibes.',
'summer' => 'Swim at Key Biscayne’s beaches or sail around Biscayne Bay. Enjoy ceviche in vibrant Little Havana or dance to live music at the Ultra Music Festival.',
'fall' => 'Wander Ocean Drive for vibrant neon lights or explore Everglades National Park for alligator spotting. Sip mojitos in Coconut Grove or browse the Miami Book Fair for exciting reads.',
'winter' => 'Enjoy Miami Beach’s festive holiday lights or dance at a lively salsa club. On sunny days, bike the scenic Venetian Causeway or shop at Lincoln Road’s vibrant outdoor mall.',
),
'las-vegas' =>
array (
'language' => 'English',
'currency' => 'US Dollar (USD)',
'timezone' => 'GMT-8',
'tagline' => 'America’s fun desert',
'about_title' => 'Las Vegas’s Past: A Quick Look',
'about' => '<p>Las Vegas began with the Paiute people in desert oases. In the 1900s, it grew as a railway hub, later booming with casinos and vibrant shows. Today, it’s the world’s entertainment capital, famed for dazzling glitz, high-stakes gambling, and grand dreams. Retro neon signs stand beside sleek modern resorts, blending nostalgia with extravagance. From iconic Strip landmarks to electrifying nightlife, Las Vegas offers a thrilling mix of history and spectacle, making it an exciting destination to explore.</p>
<p>Las Vegas’ hop-on hop-off bus tour, bookable at hoponhopofftickets.com, is a vibrant way to explore Sin City with Big Bus Tours. The Red Route covers 9 stops, including the Welcome to Fabulous Las Vegas Sign, Bellagio Fountains, and Treasure Island, with buses every 30-60 minutes and a 2.5-hour loop. Attracting over 41 million annual visitors, it offers pre-recorded English and Spanish commentary, wheelchair-accessible lower decks, and a 2-hour night tour showcasing the Strip’s neon lights. Pets (except service dogs) are not allowed, and upper decks restrict mobility-impaired guests for safety. Book at hoponhopofftickets.com.</p>',
'spring' => 'Stroll the Strip for dazzling neon lights or photograph the iconic “Welcome to Las Vegas” sign. Hike Red Rock Canyon for stunning desert views or join a lively pool party at the Cosmopolitan.',
'summer' => 'Relax at Mandalay Bay’s beach pool or enjoy the Blue Man Group show. Savor tacos at Fremont Street’s food stalls or explore the Neon Museum’s glowing displays at night.',
'fall' => 'Wander Valley of Fire’s striking red rocks or watch Bellagio’s mesmerizing fountain show. Sip cocktails at a chic rooftop bar or enjoy the Life is Beautiful music and art festival.',
'winter' => 'Admire holiday lights at Ethel M Cactus Garden or enjoy a buffet at Caesars Palace. On mild days, golf at a nearby course or shop at the Grand Canal Shoppes.',
),
'montreal' =>
array (
'language' => 'French',
'currency' => 'Canadian Dollar (CAD)',
'timezone' => 'GMT-5',
'tagline' => 'Enjoy Canada’s artsy star!',
'about_title' => 'Montreal’s Past',
'about' => '<p>Montreal began with the Kanien’kehá:ka thriving along the St. Lawrence River. In the 1600s, French settlers established a town that became a bustling trade hub. Today, as Quebec’s cultural heart, it blends French heritage with global influences. Historic stone buildings stand alongside vibrant modern art, with music filling the air. From lively festivals to charming cobblestone streets, Montreal’s unique fusion of old and new creates a dynamic, inviting atmosphere perfect for exploration and discovery.</p>
<p>Montreal’s hop-on hop-off bus tour, bookable at hoponhopofftickets.com, is a convenient way to explore Quebec’s vibrant metropolis with Gray Line Montreal. The double-decker buses cover 10 stops, including Notre-Dame Basilica, Old Port, Chinatown, and Mount Royal Park, with a 2-hour loop and departures every 15-30 minutes seasonally. Popular with over 7 million annual visitors to the Old Port alone, the tour offers bilingual (English/French) live commentary, though some reviews note inconsistent guide quality. Wheelchair access is limited, and strollers may face restrictions. Book at hoponhopofftickets.com for a flexible city adventure.</p>',
'spring' => 'Stroll Mount Royal for lush views and skyline shots. Savor a bagel at St-Viateur or wander Old Montreal’s historic streets. Bike the Lachine Canal for a scenic spring adventure.',
'summer' => 'Relax at Parc Jean-Drapeau for festivals like Osheaga or picnic by the St. Lawrence River. Enjoy street food at La Ronde or catch live music at the Montreal Jazz Festival.',
'fall' => 'Wander Parc La Fontaine for vibrant fall foliage or stroll the Plateau’s colorful streets. Sip coffee at a Mile End café or enjoy films at the Montreal International Documentary Festival.',
'winter' => 'Skate at Old Port’s rink or sip hot chocolate at Igloofest’s music fest. On snowy days, explore the Montreal Museum of Fine Arts or shop in the Underground City. ',
),
'vancouver' =>
array (
'language' => 'English',
'currency' => 'Canadian Dollar (CAD)',
'timezone' => 'GMT-8',
'tagline' => 'Vancouver’s got the magic.',
'about_title' => 'Vancouver’s Story: A Quick Dive into Its Cool Past',
'about' => '<p>Vancouver blends chill and vibrant energy, rooted in Coast Salish traditions of thriving on fish and forests. The 1800s gold rush and railway transformed Gastown into a bustling hub. Now, as “Hollywood North,” it buzzes with film crews, diverse global cuisine, and eco-conscious style. Neon-lit nights and Indigenous art create a dynamic cultural mosaic. From Stanley Park’s greenery to urban energy, Vancouver invites exploration with its unique mix of nature, culture, and modern flair, perfect for diving in.</p>',
),
);

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 ) {

View File

@@ -7,11 +7,16 @@
* submits via GET. VC_Display::filter_archive_query() reads the same state.
*
* Args: $vc_title, $vc_subtitle, $vc_query, $vc_form_action, $vc_after_hero
* (HTML printed between the hero and the results, e.g. the region city grid).
* (HTML printed between the hero and the results, e.g. the region city grid),
* $vc_tagline (line under the title), $vc_list_heading (heading above the
* results) and $vc_jump (true: filtering and paging land on the results).
*/
defined( 'ABSPATH' ) || exit;
$vc_after_hero = isset( $vc_after_hero ) ? (string) $vc_after_hero : '';
$vc_tagline = isset( $vc_tagline ) ? (string) $vc_tagline : '';
$vc_list_heading = isset( $vc_list_heading ) ? (string) $vc_list_heading : '';
$vc_anchor = empty( $vc_jump ) ? '' : '#tour-list';
$f = VC_Display::get_filter_state();
@@ -53,9 +58,7 @@ $vc_dur_labels = array(
);
// Whether any filter is active (drives the "Clear filters" control).
$vc_active = ( '' !== $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'] );
$vc_active = VC_Display::filters_active( $f );
// Current filters as a base URL, so pagination links keep the query string.
$vc_base_args = array();
@@ -70,12 +73,12 @@ if ( $f['bestdeal'] ) { $vc_base_args['bestdeal'] = 1; }
if ( $f['sellout'] ) { $vc_base_args['sellout'] = 1; }
if ( 'newest' !== $f['sort'] ) { $vc_base_args['sort'] = $f['sort']; }
$vc_page_url = function ( $n ) use ( $vc_action, $vc_base_args ) {
$vc_page_url = function ( $n ) use ( $vc_action, $vc_base_args, $vc_anchor ) {
$args = $vc_base_args;
if ( $n > 1 ) {
$args['paged'] = $n;
}
return empty( $args ) ? $vc_action : add_query_arg( $args, $vc_action );
return ( empty( $args ) ? $vc_action : add_query_arg( $args, $vc_action ) ) . $vc_anchor;
};
$vc_hero_img = vc_hero_image_source();
@@ -94,8 +97,11 @@ $vc_hero_img = vc_hero_image_source();
echo vc_breadcrumb_html( vc_breadcrumb_trail(), $vc_crumb_current ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<h1 class="hero__title"><?php echo esc_html( $vc_title ); ?></h1>
<?php if ( '' !== $vc_tagline ) : ?>
<p class="hero__tagline"><?php echo esc_html( $vc_tagline ); ?></p>
<?php endif; ?>
<form class="filters" id="vc-filter-form" method="get" action="<?php echo esc_url( $vc_action ); ?>" novalidate>
<form class="filters" id="vc-filter-form" method="get" action="<?php echo esc_url( $vc_action . $vc_anchor ); ?>" novalidate>
<div class="filters__row">
<div class="fsearch">
<?php echo vc_icon( 'search', 'fsearch__icon' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
@@ -191,6 +197,10 @@ $vc_hero_img = vc_hero_image_source();
<section class="section" id="tour-list">
<div class="container">
<?php if ( '' !== $vc_list_heading ) : ?>
<p class="pill cty-eyebrow"><?php echo esc_html__( 'Tours', 'viator-collector' ); ?></p>
<h2 class="cty-h2 cty-tours-h"><?php echo esc_html( $vc_list_heading ); ?></h2>
<?php endif; ?>
<div class="results">
<p class="results__count" role="status">
<?php if ( $vc_total ) : ?>

View File

@@ -9,6 +9,17 @@ get_header();
$vc_term = get_queried_object();
$vc_name = ( $vc_term && ! is_wp_error( $vc_term ) ) ? $vc_term->name : __( 'Tours', 'viator-collector' );
$vc_desc = term_description();
// City guide (destinations only): tagline in the banner; facts, About and
// Seasons above the tours on page 1 of an unfiltered listing.
$vc_guide = '';
$vc_tagline = '';
if ( is_tax( 'viator_destination' ) && $vc_term && ! is_wp_error( $vc_term ) ) {
$vc_tagline = VC_City_Guide::tagline( $vc_term );
if ( VC_City_Guide::is_visible_here() ) {
$vc_guide = VC_City_Guide::render( $vc_term );
}
}
?>
<main class="vc-app vc-listing vc-listing--term">
<?php
@@ -24,6 +35,11 @@ $vc_desc = term_description();
'url' => get_post_type_archive_link( 'viator_tour' ),
),
),
'vc_tagline' => $vc_tagline,
'vc_after_hero' => $vc_guide,
/* translators: %s: city name. */
'vc_list_heading' => $vc_guide ? sprintf( __( 'Things to do in %s', 'viator-collector' ), $vc_name ) : '',
'vc_jump' => '' !== $vc_guide,
)
);
?>

View File

@@ -9,6 +9,7 @@ define( 'ABSPATH', __DIR__ . '/' );
require_once dirname( __DIR__ ) . '/includes/class-vc-structure.php';
require_once dirname( __DIR__ ) . '/includes/class-vc-tags.php';
require_once dirname( __DIR__ ) . '/includes/class-vc-city-guide.php';
function vc_assert_same( $expected, $actual, $msg = '' ) {
if ( $expected !== $actual ) {

View File

@@ -0,0 +1,46 @@
<?php
/**
* VC_City_Guide unit tests: bundled guide data and season months.
*/
function vc_test_bundled_guides() {
return include dirname( __DIR__ ) . '/data/city-guides.php';
}
function test_bundled_guides_use_known_fields() {
$fields = VC_City_Guide::fields();
foreach ( vc_test_bundled_guides() as $slug => $guide ) {
vc_assert_same( array(), array_values( array_diff( array_keys( $guide ), array_keys( $fields ) ) ), "unknown fields for $slug" );
}
}
function test_bundled_guides_have_core_text() {
foreach ( vc_test_bundled_guides() as $slug => $guide ) {
foreach ( array( 'tagline', 'language', 'currency', 'timezone', 'about_title', 'about' ) as $key ) {
vc_assert_same( true, ! empty( $guide[ $key ] ), "$slug.$key empty" );
}
vc_assert_same( false, (bool) preg_match( '/elementor|\{|\}/', implode( ' ', $guide ) ), "$slug has page junk" );
}
}
function test_bundled_guides_cover_old_pages() {
vc_assert_same(
array( 'las-vegas', 'miami', 'montreal', 'new-orleans', 'new-york-city', 'vancouver' ),
( function ( $k ) { sort( $k ); return $k; } )( array_keys( vc_test_bundled_guides() ) )
);
}
function test_empty_guide_detection() {
$blank = array_fill_keys( array_keys( VC_City_Guide::fields() ), '' );
vc_assert_same( true, VC_City_Guide::is_empty( $blank ) );
vc_assert_same( true, VC_City_Guide::is_empty( array_merge( $blank, array( 'southern' => true, 'about_image' => 'https://x/y.jpg' ) ) ) );
vc_assert_same( false, VC_City_Guide::is_empty( array_merge( $blank, array( 'winter' => 'Skate.' ) ) ) );
}
function test_season_months_flip_south() {
$north = VC_City_Guide::season_months( false );
$south = VC_City_Guide::season_months( true );
vc_assert_same( 'March – May', $north['spring'] );
vc_assert_same( 'September – November', $south['spring'] );
vc_assert_same( 'June – August', $south['winter'] );
}

View File

@@ -4,13 +4,13 @@ defined('ABSPATH') || exit;
/**
* Plugin Name: Viator Collector
* Description: Import Viator tours using the Viator Partner API and display them with native WordPress post types, templates, and shortcodes.
* Version: 1.9.6
* Version: 1.9.7
* Author: Amir Bagheri
* License: GPL-2.0-or-later
* Text Domain: viator-collector
*/
define('VC_VERSION', '1.9.6');
define('VC_VERSION', '1.9.7');
define('VC_PLUGIN_FILE', __FILE__);
define('VC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('VC_PLUGIN_URL', plugin_dir_url(__FILE__));