Compare commits
18 Commits
e4d2afc2fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 930b7e0534 | |||
| f5c4c89f1a | |||
| c73189f2db | |||
| f6153376f9 | |||
| 8b70ae9104 | |||
| 9c707b7075 | |||
| 02b6ce0ff4 | |||
| 614ea98825 | |||
| cfa23b6384 | |||
|
|
eeb759f4bf | ||
|
|
3f25d5e828 | ||
| 6a7f43311b | |||
| d5686ed14b | |||
| b0e302be15 | |||
| 01f414e10b | |||
| 911d2b4d2e | |||
| 35368fb5f9 | |||
| 67c88c795b |
4
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
.playwright-mcp
|
.playwright-mcp_home.png
|
||||||
|
_tours.png
|
||||||
|
*.tmp.png
|
||||||
|
|||||||
@@ -421,6 +421,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hoho .ring-badge--top .ring-badge__text {
|
.hoho .ring-badge--top .ring-badge__text {
|
||||||
|
fill: #808080;
|
||||||
font-size: 0.625rem;
|
font-size: 0.625rem;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
@@ -546,15 +547,26 @@
|
|||||||
box-shadow: 0 1.25rem 2.5rem rgba(0, 0, 0, 0.18);
|
box-shadow: 0 1.25rem 2.5rem rgba(0, 0, 0, 0.18);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateY(-0.375rem);
|
transform: translateY(-0.4rem) scale(0.97);
|
||||||
transition: opacity 0.2s var(--ease), transform 0.2s var(--ease), visibility 0.2s;
|
transform-origin: top center;
|
||||||
|
/* Exit: fade/slide out, then flip visibility only once the fade is done, so
|
||||||
|
the close animates instead of snapping. */
|
||||||
|
transition:
|
||||||
|
opacity 0.16s var(--ease),
|
||||||
|
transform 0.16s var(--ease),
|
||||||
|
visibility 0s linear 0.16s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hoho .nav__item--has-menu:hover .nav__submenu,
|
.hoho .nav__item--has-menu:hover .nav__submenu,
|
||||||
.hoho .nav__toggle[aria-expanded="true"]+.nav__submenu {
|
.hoho .nav__toggle[aria-expanded="true"]+.nav__submenu {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translateY(0);
|
transform: translateY(0) scale(1);
|
||||||
|
/* Enter: a slightly longer, soft ease-out so it settles in gently. */
|
||||||
|
transition:
|
||||||
|
opacity 0.24s var(--ease),
|
||||||
|
transform 0.28s cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
|
visibility 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hoho .nav__submenu a {
|
.hoho .nav__submenu a {
|
||||||
@@ -1868,14 +1880,24 @@
|
|||||||
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.22);
|
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.22);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateY(-0.5rem);
|
transform: translateY(-0.6rem) scale(0.98);
|
||||||
transition: opacity 0.25s var(--ease), transform 0.25s var(--ease), visibility 0.25s;
|
transform-origin: top right;
|
||||||
|
/* Exit: fade/slide up, then hide once the fade completes. */
|
||||||
|
transition:
|
||||||
|
opacity 0.18s var(--ease),
|
||||||
|
transform 0.18s var(--ease),
|
||||||
|
visibility 0s linear 0.18s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hoho .nav.is-open {
|
.hoho .nav.is-open {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translateY(0);
|
transform: translateY(0) scale(1);
|
||||||
|
/* Enter: soft ease-out settle. */
|
||||||
|
transition:
|
||||||
|
opacity 0.26s var(--ease),
|
||||||
|
transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
|
visibility 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hoho .nav__list {
|
.hoho .nav__list {
|
||||||
|
|||||||
@@ -63,6 +63,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- header search: expand, then submit ---------- */
|
||||||
|
var search = document.querySelector(".header__search");
|
||||||
|
if (search) {
|
||||||
|
var searchInput = search.querySelector(".header__search-input");
|
||||||
|
var searchBtn = search.querySelector(".header__search-btn");
|
||||||
|
if (searchInput && searchBtn) {
|
||||||
|
searchBtn.addEventListener("click", function (e) {
|
||||||
|
if (!search.classList.contains("is-open")) {
|
||||||
|
e.preventDefault();
|
||||||
|
search.classList.add("is-open");
|
||||||
|
searchInput.focus();
|
||||||
|
} else if (!searchInput.value.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
search.classList.remove("is-open"); /* empty → collapse instead of submit */
|
||||||
|
}
|
||||||
|
/* open + has text → let the form submit to the tours archive (?vc_q=) */
|
||||||
|
});
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
if (!search.contains(e.target) && !searchInput.value.trim()) {
|
||||||
|
search.classList.remove("is-open");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- footer: back to top ---------- */
|
/* ---------- footer: back to top ---------- */
|
||||||
var toTop = document.querySelector(".ring-badge--top");
|
var toTop = document.querySelector(".ring-badge--top");
|
||||||
if (toTop) {
|
if (toTop) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden">
|
||||||
<defs>
|
<defs>
|
||||||
<path id="ringPathScroll" fill="none" d="M50,50 m-39,0 a39,39 0 1,1 78,0 a39,39 0 1,1 -78,0" />
|
<path id="ringPathScroll" fill="none" d="M50,50 m-39,0 a39,39 0 1,1 78,0 a39,39 0 1,1 -78,0" />
|
||||||
</defs>
|
</defs>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -17,19 +17,55 @@ add_action( 'wp_enqueue_scripts', function () {
|
|||||||
// Design typeface (same as the plugin's pages).
|
// Design typeface (same as the plugin's pages).
|
||||||
wp_enqueue_style( 'hoho-fonts', 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;700;800&display=swap', array(), null );
|
wp_enqueue_style( 'hoho-fonts', 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;700;800&display=swap', array(), null );
|
||||||
|
|
||||||
// Header/footer design, scoped to .hoho so it can't fight Astra.
|
// Header/footer design, scoped to .hoho so it can't fight Astra. Depend on
|
||||||
wp_enqueue_style( 'hoho-chrome', get_stylesheet_directory_uri() . '/assets/chrome.css', array( 'astra-parent' ), '1.0.0' );
|
// the Astra stylesheet (when present) so our rules load AFTER it and win ties.
|
||||||
|
$deps = array( 'astra-parent' );
|
||||||
|
if ( wp_style_is( 'astra-theme-css', 'registered' ) ) {
|
||||||
|
$deps[] = 'astra-theme-css';
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'hoho-chrome', get_stylesheet_directory_uri() . '/assets/chrome.css', $deps, '1.0.8' );
|
||||||
|
|
||||||
// Header interactions: burger, submenu, sticky, back-to-top.
|
// Header interactions: burger, submenu, sticky, back-to-top, search toggle.
|
||||||
wp_enqueue_script( 'hoho-chrome', get_stylesheet_directory_uri() . '/assets/chrome.js', array(), '1.0.0', true );
|
wp_enqueue_script( 'hoho-chrome', get_stylesheet_directory_uri() . '/assets/chrome.js', array(), '1.0.8', true );
|
||||||
|
|
||||||
// On pages without a full-bleed hero to overlay, the header renders solid and
|
|
||||||
// sticky (added by chrome.js as .is-solid) so it stays readable and doesn't
|
|
||||||
// cover content. Hero pages keep the transparent overlay + is-stuck on scroll.
|
|
||||||
wp_add_inline_style(
|
wp_add_inline_style(
|
||||||
'hoho-chrome',
|
'hoho-chrome',
|
||||||
'.hoho .site-header.is-solid{position:sticky;inset-block-start:0;padding-block:var(--sp-2);'
|
implode(
|
||||||
. 'background:rgba(0,0,0,.85);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);}'
|
'',
|
||||||
|
array(
|
||||||
|
// Solid, sticky header on pages without a hero to overlay (added by
|
||||||
|
// chrome.js as .is-solid) so it stays readable and doesn't cover content.
|
||||||
|
'.hoho .site-header.is-solid{position:sticky;inset-block-start:0;padding-block:var(--sp-2);background:rgba(0,0,0,.85);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);}',
|
||||||
|
// Force the design font over Astra inside the header/footer.
|
||||||
|
'.hoho,.hoho h1,.hoho h2,.hoho h3,.hoho p,.hoho a,.hoho span,.hoho li,.hoho button,.hoho input{font-family:"Plus Jakarta Sans",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}',
|
||||||
|
// Normalize: stop Astra decorating header/footer links & lists.
|
||||||
|
'.hoho a,.hoho.hoho a{text-decoration:none!important;box-shadow:none!important;}',
|
||||||
|
// …but not ring-badges: they set their own colour on the <a>, and this
|
||||||
|
// inherit rule out-specifies .ring-badge--top, turning the footer
|
||||||
|
// "back to top" text white (inherited) on the light area above it.
|
||||||
|
'.hoho a:not(.btn):not(.ring-badge){color:inherit;}',
|
||||||
|
'.hoho :is(ul,ol){list-style:none!important;margin:0!important;padding:0!important;}',
|
||||||
|
'.hoho li{margin:0!important;}',
|
||||||
|
// Let the footer\'s decorative bits (back-to-top ring, support tab)
|
||||||
|
// spill vertically, but keep the horizontal axis clipped so nothing
|
||||||
|
// causes a mobile sideways scroll. overflow-x:clip (unlike hidden)
|
||||||
|
// does not force overflow-y to auto, so the y-axis stays visible.
|
||||||
|
'.hoho,.hoho .footer{overflow-x:clip;overflow-y:visible;}',
|
||||||
|
// Icon buttons: Astra gives <button> a filled background + its own
|
||||||
|
// colour, which turned the header search icon into an empty dark
|
||||||
|
// circle. Reset them (real .btn buttons keep their styling).
|
||||||
|
'.hoho .icon-btn{background:transparent!important;border:0!important;color:#fff!important;padding:.4rem;}',
|
||||||
|
'.hoho .site-header .icon{color:#fff;}',
|
||||||
|
'.hoho button:not(.btn):not(.burger){background:transparent;border:0;color:inherit;}',
|
||||||
|
// Expanding header search field.
|
||||||
|
'.hoho .header__search{display:flex;align-items:center;gap:.25rem;}',
|
||||||
|
// !important beats Astra's input[type=search] styling, which forces a
|
||||||
|
// white field background (and, with a white placeholder, hid the text).
|
||||||
|
'.hoho .header__search-input{width:0;min-width:0;padding:0;border:0!important;outline:0;opacity:0;height:var(--control-h);border-radius:var(--r-pill)!important;background:rgba(255,255,255,.15)!important;color:#fff!important;box-shadow:none!important;transition:width .25s var(--ease),opacity .2s var(--ease),padding .25s var(--ease);}',
|
||||||
|
'.hoho .header__search.is-open .header__search-input{width:min(52vw,15rem);padding:0 1rem;opacity:1;}',
|
||||||
|
'.hoho .header__search-input::placeholder{color:rgba(255,255,255,.7)!important;opacity:1;}',
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}, 20 );
|
}, 20 );
|
||||||
|
|
||||||
@@ -79,9 +115,11 @@ add_action( 'wp', function () {
|
|||||||
}
|
}
|
||||||
add_action( 'astra_header', 'hoho_render_header' );
|
add_action( 'astra_header', 'hoho_render_header' );
|
||||||
|
|
||||||
if ( function_exists( 'astra_footer_markup' ) ) {
|
// Astra renders its footer via the Footer Builder (a class method on the
|
||||||
remove_action( 'astra_footer', 'astra_footer_markup' );
|
// `astra_footer` hook), so removing the legacy astra_footer_markup() alone
|
||||||
}
|
// left a second, default footer. Clear every callback on the hook, then add
|
||||||
|
// ours — a clean, full replacement regardless of classic vs builder footer.
|
||||||
|
remove_all_actions( 'astra_footer' );
|
||||||
add_action( 'astra_footer', 'hoho_render_footer' );
|
add_action( 'astra_footer', 'hoho_render_footer' );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
@@ -120,7 +158,10 @@ function hoho_logo() {
|
|||||||
* Small helper: inline-icon markup referencing the sprite.
|
* Small helper: inline-icon markup referencing the sprite.
|
||||||
*/
|
*/
|
||||||
function hoho_icon( $name, $extra = '' ) {
|
function hoho_icon( $name, $extra = '' ) {
|
||||||
$name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . ltrim( (string) $name, 'i-' ) );
|
// Strip an existing "i-" prefix (once), then re-add it. ltrim() with a
|
||||||
|
// charlist would eat any leading i/-, turning "instagram" into "nstagram".
|
||||||
|
$slug = preg_replace( '/^i-/', '', (string) $name );
|
||||||
|
$name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . $slug );
|
||||||
$class = 'icon' . ( $extra ? ' ' . $extra : '' );
|
$class = 'icon' . ( $extra ? ' ' . $extra : '' );
|
||||||
return '<svg class="' . esc_attr( $class ) . '" aria-hidden="true"><use href="#' . esc_attr( $name ) . '"></use></svg>';
|
return '<svg class="' . esc_attr( $class ) . '" aria-hidden="true"><use href="#' . esc_attr( $name ) . '"></use></svg>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ $hoho_footer_col = function ( $location ) {
|
|||||||
<footer class="footer" id="footer">
|
<footer class="footer" id="footer">
|
||||||
<a class="ring-badge ring-badge--top" href="#top" aria-label="<?php echo esc_attr__( 'Back to top', 'hoponhopoff' ); ?>">
|
<a class="ring-badge ring-badge--top" href="#top" aria-label="<?php echo esc_attr__( 'Back to top', 'hoponhopoff' ); ?>">
|
||||||
<svg class="ring-badge__ring" viewBox="0 0 100 100" aria-hidden="true">
|
<svg class="ring-badge__ring" viewBox="0 0 100 100" aria-hidden="true">
|
||||||
<text class="ring-badge__text"><textPath href="#ringPathScroll" startOffset="0">BACK TO TOP · BACK TO TOP · </textPath></text>
|
<text class="ring-badge__text" text-anchor="middle">
|
||||||
|
<textPath href="#ringPathScroll" startOffset="25%"><?php echo esc_html__( 'BACK TO TOP', 'hoponhopoff' ); ?></textPath>
|
||||||
|
<textPath href="#ringPathScroll" startOffset="75%"><?php echo esc_html__( 'BACK TO TOP', 'hoponhopoff' ); ?></textPath>
|
||||||
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="ring-badge__dot"><svg class="icon" aria-hidden="true"><use href="#i-arrow-up"></use></svg></span>
|
<span class="ring-badge__dot"><svg class="icon" aria-hidden="true"><use href="#i-arrow-up"></use></svg></span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ $hoho_archive = $hoho_archive ? $hoho_archive : home_url( '/' );
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header__actions">
|
<div class="header__actions">
|
||||||
<button class="icon-btn" type="button" aria-label="<?php echo esc_attr__( 'Search', 'hoponhopoff' ); ?>">
|
<form class="header__search" role="search" method="get" action="<?php echo esc_url( $hoho_archive ); ?>">
|
||||||
<?php echo hoho_icon( 'search', 'icon--24' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
<input class="header__search-input" type="search" name="vc_q" placeholder="<?php echo esc_attr__( 'Search tours…', 'hoponhopoff' ); ?>" aria-label="<?php echo esc_attr__( 'Search tours', 'hoponhopoff' ); ?>">
|
||||||
</button>
|
<button class="icon-btn header__search-btn" type="submit" aria-label="<?php echo esc_attr__( 'Search', 'hoponhopoff' ); ?>">
|
||||||
<a class="icon-btn" href="<?php echo esc_url( wp_login_url() ); ?>" aria-label="<?php echo esc_attr__( 'Account', 'hoponhopoff' ); ?>">
|
<?php echo hoho_icon( 'search', 'icon--24' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||||
<?php echo hoho_icon( 'user', 'icon--24' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
</button>
|
||||||
</a>
|
</form>
|
||||||
<a class="btn btn--accent header__cta" href="<?php echo esc_url( $hoho_archive ); ?>">
|
<a class="btn btn--accent header__cta" href="<?php echo esc_url( $hoho_archive ); ?>">
|
||||||
<span><?php echo esc_html__( 'Book now', 'hoponhopoff' ); ?></span>
|
<span><?php echo esc_html__( 'Book now', 'hoponhopoff' ); ?></span>
|
||||||
<?php echo hoho_icon( 'arrow-right' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
<?php echo hoho_icon( 'arrow-right' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||||
|
|||||||
@@ -54,5 +54,12 @@ function hoho_nav_fallback() {
|
|||||||
echo '<ul class="nav__list">';
|
echo '<ul class="nav__list">';
|
||||||
echo '<li><a class="nav__link" href="' . esc_url( home_url( '/' ) ) . '">' . esc_html__( 'Home', 'hoponhopoff' ) . '</a></li>';
|
echo '<li><a class="nav__link" href="' . esc_url( home_url( '/' ) ) . '">' . esc_html__( 'Home', 'hoponhopoff' ) . '</a></li>';
|
||||||
echo '<li><a class="nav__link" href="' . esc_url( $archive ) . '">' . esc_html__( 'Tours', 'hoponhopoff' ) . '</a></li>';
|
echo '<li><a class="nav__link" href="' . esc_url( $archive ) . '">' . esc_html__( 'Tours', 'hoponhopoff' ) . '</a></li>';
|
||||||
|
// Regions + cities from the Viator Collector structure, when active.
|
||||||
|
if ( class_exists( 'VC_Menu' ) ) {
|
||||||
|
$items = VC_Menu::items();
|
||||||
|
if ( $items ) {
|
||||||
|
echo walk_nav_menu_tree( $items, 2, (object) array( 'walker' => new Hoho_Nav_Walker() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
|
}
|
||||||
|
}
|
||||||
echo '</ul>';
|
echo '</ul>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Theme URI: https://git.kaamyab.dev/vargha/TourWebsite
|
|||||||
Description: Astra child theme that replaces the Astra header and footer with the "Hop On Hop Off" design. Pairs with the Viator Collector plugin.
|
Description: Astra child theme that replaces the Astra header and footer with the "Hop On Hop Off" design. Pairs with the Viator Collector plugin.
|
||||||
Author: Amir Bagheri
|
Author: Amir Bagheri
|
||||||
Template: astra
|
Template: astra
|
||||||
Version: 1.0.0
|
Version: 1.0.9
|
||||||
Text Domain: hoponhopoff
|
Text Domain: hoponhopoff
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
70
build-zip.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Distribution zipper for the plugin / theme.
|
||||||
|
*
|
||||||
|
* php build-zip.php <source-dir> <output.zip>
|
||||||
|
*
|
||||||
|
* Produces a clean, distributable archive: excludes docs, build tooling,
|
||||||
|
* markdown, VCS and editor/AI metadata — nothing that documents how the code
|
||||||
|
* was produced ships to the client.
|
||||||
|
*
|
||||||
|
* Exclusion rule (keep in sync with the "no docs/AI files in zips" preference):
|
||||||
|
* - any path segment: .git, .claude, .github, docs, build, node_modules, tests
|
||||||
|
* - any *.md file
|
||||||
|
* - dotfiles at any level except .htaccess
|
||||||
|
*/
|
||||||
|
|
||||||
|
$src = isset( $argv[1] ) ? rtrim( str_replace( '\\', '/', $argv[1] ), '/' ) : '';
|
||||||
|
$out = isset( $argv[2] ) ? $argv[2] : '';
|
||||||
|
|
||||||
|
if ( '' === $src || '' === $out || ! is_dir( $src ) ) {
|
||||||
|
fwrite( STDERR, "Usage: php build-zip.php <source-dir> <output.zip>\n" );
|
||||||
|
exit( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$excluded_dirs = array( '.git', '.claude', '.github', 'docs', 'build', 'node_modules', 'tests' );
|
||||||
|
|
||||||
|
function is_excluded( $rel, array $excluded_dirs ) {
|
||||||
|
$parts = explode( '/', $rel );
|
||||||
|
$base = end( $parts );
|
||||||
|
|
||||||
|
foreach ( $parts as $seg ) {
|
||||||
|
if ( in_array( $seg, $excluded_dirs, true ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Markdown / doc files.
|
||||||
|
if ( preg_match( '/\.md$/i', $base ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Dotfiles (e.g. .gitignore, .DS_Store) except .htaccess.
|
||||||
|
if ( '' !== $base && '.' === $base[0] && '.htaccess' !== $base ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink( $out );
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ( true !== $zip->open( $out, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
|
||||||
|
fwrite( STDERR, "Cannot create $out\n" );
|
||||||
|
exit( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = basename( $src );
|
||||||
|
$it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $src, FilesystemIterator::SKIP_DOTS ) );
|
||||||
|
$added = 0;
|
||||||
|
$skip = 0;
|
||||||
|
foreach ( $it as $file ) {
|
||||||
|
$path = str_replace( '\\', '/', $file->getPathname() );
|
||||||
|
// Path relative to the source's parent, so the archive contains <root>/…
|
||||||
|
$rel_from_src = ltrim( substr( $path, strlen( $src ) ), '/' );
|
||||||
|
if ( is_excluded( $rel_from_src, $excluded_dirs ) ) {
|
||||||
|
$skip++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$zip->addFile( $path, $root . '/' . $rel_from_src );
|
||||||
|
$added++;
|
||||||
|
}
|
||||||
|
$zip->close();
|
||||||
|
fwrite( STDOUT, "Wrote $out — $added files (skipped $skip)\n" );
|
||||||
@@ -58,6 +58,15 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
"Plus Jakarta Sans",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
Helvetica,
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
font-family:
|
font-family:
|
||||||
"Plus Jakarta Sans",
|
"Plus Jakarta Sans",
|
||||||
system-ui,
|
system-ui,
|
||||||
@@ -624,7 +633,6 @@ dd {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
min-height: clamp(34rem, 88svh, 56rem);
|
|
||||||
margin: var(--page-inset);
|
margin: var(--page-inset);
|
||||||
padding-block: calc(var(--header-h) + var(--sp-6))
|
padding-block: calc(var(--header-h) + var(--sp-6))
|
||||||
clamp(3rem, 1.5rem + 6vw, 6rem);
|
clamp(3rem, 1.5rem + 6vw, 6rem);
|
||||||
@@ -836,9 +844,13 @@ dd {
|
|||||||
scroll-snap-type: x mandatory;
|
scroll-snap-type: x mandatory;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
/* negative margin + padding keeps the card glow from being clipped by the scroller */
|
/* overflow-x:auto forces overflow-y to auto, so the hover glow (a ~3.75rem
|
||||||
margin-inline: calc(var(--gap) * -1);
|
blur) would be clipped by the scroller. Don't switch to overflow:visible —
|
||||||
padding: var(--sp-4) var(--gap);
|
that kills the horizontal scroll. Instead give the rail enough padding for
|
||||||
|
the glow to paint INSIDE the scroll box, and pull it back with an equal
|
||||||
|
negative margin so the visible spacing is unchanged. Scrolling stays intact. */
|
||||||
|
margin: calc(var(--sp-5) * -1) calc(var(--gap) * -1);
|
||||||
|
padding: 4.5rem var(--gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail::-webkit-scrollbar {
|
.rail::-webkit-scrollbar {
|
||||||
@@ -1696,7 +1708,10 @@ dd {
|
|||||||
transition: grid-template-rows 0.3s var(--ease);
|
transition: grid-template-rows 0.3s var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.acc__q[aria-expanded="true"] + .acc__a {
|
/* The button sits inside an <h3>, so .acc__a is the h3's sibling — not the
|
||||||
|
button's. Match on the .acc wrapper's expanded state instead of an adjacent
|
||||||
|
sibling, which never matched through the heading. */
|
||||||
|
.acc:has(.acc__q[aria-expanded="true"]) .acc__a {
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2044,10 +2059,6 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 40rem) {
|
@media (max-width: 40rem) {
|
||||||
.hero {
|
|
||||||
min-height: clamp(30rem, 84svh, 44rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.booking {
|
.booking {
|
||||||
padding: var(--sp-2);
|
padding: var(--sp-2);
|
||||||
}
|
}
|
||||||
@@ -2717,6 +2728,13 @@ dd {
|
|||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
object-position: center 26%;
|
object-position: center 26%;
|
||||||
|
/* Viator only ships ~720px images, which look soft blown up to a full-width
|
||||||
|
banner. Rather than fight it, lean in: a gentle blur turns the photo into an
|
||||||
|
intentional backdrop (the crisp image lives in the gallery lightbox). The
|
||||||
|
scale hides the blur's feathered edges, which overflow:clip on .hero-tour
|
||||||
|
then trims to the rounded frame. */
|
||||||
|
filter: blur(10px) saturate(1.12);
|
||||||
|
transform: scale(1.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-tour__sky {
|
.hero-tour__sky {
|
||||||
@@ -2732,9 +2750,9 @@ dd {
|
|||||||
),
|
),
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(27, 147, 166, 0.82) 0%,
|
rgba(27, 147, 166, 0.55) 0%,
|
||||||
rgba(44, 175, 194, 0.62) 45%,
|
rgba(44, 175, 194, 0.42) 45%,
|
||||||
rgba(111, 208, 221, 0.78) 100%
|
rgba(111, 208, 221, 0.72) 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2838,7 +2856,7 @@ dd {
|
|||||||
|
|
||||||
.hero-tour__figure--sydney {
|
.hero-tour__figure--sydney {
|
||||||
left: 1.05em;
|
left: 1.05em;
|
||||||
top: 0.35em;
|
top: 0;
|
||||||
height: 2.3em;
|
height: 2.3em;
|
||||||
scale: 1;
|
scale: 1;
|
||||||
}
|
}
|
||||||
@@ -3013,6 +3031,119 @@ dd {
|
|||||||
border-radius: var(--r-card);
|
border-radius: var(--r-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gallery img {
|
||||||
|
cursor: zoom-in;
|
||||||
|
transition: transform 0.25s var(--ease), filter 0.25s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery img:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Gallery lightbox (built by vc-design.js)
|
||||||
|
========================================================= */
|
||||||
|
.vc-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: clamp(1rem, 0.5rem + 3vw, 3.5rem);
|
||||||
|
background: rgba(0, 0, 0, 0.86);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.25s var(--ease),
|
||||||
|
visibility 0s linear 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition: opacity 0.25s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox__img {
|
||||||
|
max-width: min(100%, 78rem);
|
||||||
|
max-height: 84vh;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.5);
|
||||||
|
transform: scale(0.96);
|
||||||
|
transition: transform 0.25s var(--ease), opacity 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox.is-open .vc-lightbox__img {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox__btn {
|
||||||
|
position: absolute;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
color: var(--c-white);
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s var(--ease), transform 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox__btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox__close {
|
||||||
|
top: clamp(1rem, 3vw, 2rem);
|
||||||
|
right: clamp(1rem, 3vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox__prev,
|
||||||
|
.vc-lightbox__next {
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox__prev {
|
||||||
|
left: clamp(0.5rem, 2vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox__next {
|
||||||
|
right: clamp(0.5rem, 2vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox__prev:hover,
|
||||||
|
.vc-lightbox__next:hover {
|
||||||
|
transform: translateY(-50%) scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-lightbox__count {
|
||||||
|
position: absolute;
|
||||||
|
bottom: clamp(1rem, 3vw, 2rem);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* single image → hide the arrows */
|
||||||
|
.vc-lightbox--single .vc-lightbox__prev,
|
||||||
|
.vc-lightbox--single .vc-lightbox__next,
|
||||||
|
.vc-lightbox--single .vc-lightbox__count {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- included / not included --- */
|
/* --- included / not included --- */
|
||||||
.lists {
|
.lists {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -3437,11 +3568,6 @@ dd {
|
|||||||
border-color: var(--c-black);
|
border-color: var(--c-black);
|
||||||
}
|
}
|
||||||
|
|
||||||
white);
|
|
||||||
border-color: var(--c-black);
|
|
||||||
background: var(--c-black);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- reviews --- */
|
/* --- reviews --- */
|
||||||
.reviews {
|
.reviews {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
BIN
removed-images/bus-iso.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
removed-images/bus-open-top.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
removed-images/ellipse-17-2.png
Normal file
|
After Width: | Height: | Size: 344 B |
BIN
removed-images/ellipse-17.png
Normal file
|
After Width: | Height: | Size: 344 B |
BIN
removed-images/hero.png
Normal file
|
After Width: | Height: | Size: 630 KiB |
BIN
removed-images/hero2.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
removed-images/hero3.jpg
Normal file
|
After Width: | Height: | Size: 825 KiB |
BIN
removed-images/tower.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
viator-collector.zip
Normal file
@@ -16,23 +16,31 @@ listing/single/section assets load only on pages that use them.
|
|||||||
| `[viator_top_tours]` | Rail of tour cards | `viator_tour` posts |
|
| `[viator_top_tours]` | Rail of tour cards | `viator_tour` posts |
|
||||||
| `[viator_top_attractions]` | Rail of tour cards | `viator_tour` posts |
|
| `[viator_top_attractions]` | Rail of tour cards | `viator_tour` posts |
|
||||||
| `[viator_browse_activities]` | Rail of tour cards | `viator_tour` posts |
|
| `[viator_browse_activities]` | Rail of tour cards | `viator_tour` posts |
|
||||||
|
| `[viator_bus_tours]` | "Open Top Bus Tours" promo + 4-up card grid | `viator_tour` posts |
|
||||||
|
| `[viator_about]` | Static "About / Memorable" block + stat counters | Shortcode attributes |
|
||||||
|
| `[viator_articles]` | Rail of latest blog-post cards | `post` posts |
|
||||||
| `[viator_faq]` | FAQ accordion | Shortcode `items` / `vc_faq_items` filter |
|
| `[viator_faq]` | FAQ accordion | Shortcode `items` / `vc_faq_items` filter |
|
||||||
| `[viator_testimonials]` | Testimonials marquee | `vc_testimonials` filter |
|
| `[viator_testimonials]` | Testimonials marquee | `vc_testimonials` filter |
|
||||||
|
|
||||||
`[viator_hero_slider]` is kept as a **back-compat alias** for `[viator_hero]`.
|
`[viator_hero_slider]` is kept as a **back-compat alias** for `[viator_hero]`.
|
||||||
|
|
||||||
A typical home page:
|
Home page matching the design mockup (`new front/index.html`), top to bottom:
|
||||||
|
|
||||||
```
|
```
|
||||||
[viator_hero]
|
[viator_hero image_id="125304"]
|
||||||
[viator_top_destinations]
|
[viator_top_destinations]
|
||||||
|
[viator_bus_tours image_id="125310"]
|
||||||
[viator_top_tours]
|
[viator_top_tours]
|
||||||
[viator_top_attractions]
|
[viator_about image_id="125311"]
|
||||||
[viator_browse_activities]
|
|
||||||
[viator_testimonials]
|
[viator_testimonials]
|
||||||
|
[viator_articles]
|
||||||
[viator_faq]
|
[viator_faq]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Heavy decorative images (hero photo, open-top bus, isometric bus) are **not
|
||||||
|
bundled** — upload them in **Media** and pass the attachment `image_id` (exactly
|
||||||
|
like `[viator_hero image_id="…"]`). Icons and tiny decorations are inline SVG.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `[viator_hero]`
|
## `[viator_hero]`
|
||||||
@@ -99,6 +107,71 @@ are hidden automatically.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `[viator_bus_tours]`
|
||||||
|
|
||||||
|
The "Open Top Bus Tours…" promo block (route line, headline, "See all", a chip,
|
||||||
|
a bus illustration and note) followed by a 4-up grid of tour cards. Selects tours
|
||||||
|
with the **same attributes as the tour rails** (`ids` / `codes` / `destination` /
|
||||||
|
`tag` / `limit` / `orderby`), plus:
|
||||||
|
|
||||||
|
| Attribute | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | `Open Top<br>Bus Tours…` | Promo headline (`<br>` allowed). |
|
||||||
|
| `note` | *(default copy)* | Paragraph under the headline. |
|
||||||
|
| `chip` | `Very Fun!` | Small accent chip. |
|
||||||
|
| `image_id` | — | **Media attachment ID** of the open-top bus illustration. |
|
||||||
|
| `image` | — | …or a direct image URL (use `image_id` when possible). |
|
||||||
|
|
||||||
|
```
|
||||||
|
[viator_bus_tours image_id="125310" tag="hop-on-hop-off" limit="4"]
|
||||||
|
[viator_bus_tours ids="482,517,690,701"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `[viator_about]`
|
||||||
|
|
||||||
|
Static "About" marketing block: headline with an accent chip, a paragraph, four
|
||||||
|
stat counters and a decorative isometric bus. All copy is editable.
|
||||||
|
|
||||||
|
| Attribute | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | `Hop On Hop Off` | Headline. |
|
||||||
|
| `highlight` | `Memorable` | Accent chip appended to the headline. |
|
||||||
|
| `text` | *(default copy)* | Paragraph. |
|
||||||
|
| `button_text` | `About us` | Button label. |
|
||||||
|
| `button_url` | *(tours archive)* | Button link. |
|
||||||
|
| `image_id` | — | **Media attachment ID** of the isometric bus illustration. |
|
||||||
|
| `image` | — | …or a direct image URL. |
|
||||||
|
| `stats` | *(4 defaults)* | `25+|Years Experience;;500+|Tours Conducted` — `|` separates number from label, `;;` separates stats. A trailing `+` is styled. |
|
||||||
|
|
||||||
|
```
|
||||||
|
[viator_about image_id="125311" stats="25+|Years Experience;;500+|Tours Conducted;;430+|Tours this Year;;15+|Different Tours"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `[viator_articles]`
|
||||||
|
|
||||||
|
A rail of the latest blog posts styled as the design's "Latest Articles" cards
|
||||||
|
(featured image, relative time, title, excerpt → links to the post).
|
||||||
|
|
||||||
|
| Attribute | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | `Latest<br>Articles` | Heading (`<br>` allowed). |
|
||||||
|
| `lead` | *(default copy)* | Sub-heading. |
|
||||||
|
| `limit` | `8` | Number of posts. |
|
||||||
|
| `category` | — | Restrict to a category slug. |
|
||||||
|
| `view_all` | *(posts page)* | "See all" link; defaults to the blog page. |
|
||||||
|
|
||||||
|
Cards use each post's **featured image**; set one so the card isn't blank.
|
||||||
|
|
||||||
|
```
|
||||||
|
[viator_articles limit="8" category="travel-tips"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `[viator_faq]`
|
## `[viator_faq]`
|
||||||
|
|
||||||
FAQ accordion.
|
FAQ accordion.
|
||||||
|
|||||||
BIN
viator-collector/assets.zip
Normal file
@@ -1,4 +1,4 @@
|
|||||||
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden">
|
||||||
<defs>
|
<defs>
|
||||||
<path id="ringPathScroll" fill="none" d="M50,50 m-39,0 a39,39 0 1,1 78,0 a39,39 0 1,1 -78,0" />
|
<path id="ringPathScroll" fill="none" d="M50,50 m-39,0 a39,39 0 1,1 78,0 a39,39 0 1,1 -78,0" />
|
||||||
</defs>
|
</defs>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 440 KiB |
|
Before Width: | Height: | Size: 321 B |
|
Before Width: | Height: | Size: 321 B |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 595 KiB |
@@ -92,4 +92,57 @@
|
|||||||
var g = app.querySelector(".gallery, .tour-gallery");
|
var g = app.querySelector(".gallery, .tour-gallery");
|
||||||
if (g) g.scrollIntoView({ behavior: "smooth" });
|
if (g) g.scrollIntoView({ behavior: "smooth" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------- gallery lightbox (zoom / view photos) ---------- */
|
||||||
|
(function () {
|
||||||
|
var imgs = Array.prototype.slice.call(app.querySelectorAll(".gallery img"));
|
||||||
|
if (!imgs.length) return;
|
||||||
|
|
||||||
|
var host = imgs[0].closest(".vc-app") || document.body;
|
||||||
|
var box = document.createElement("div");
|
||||||
|
box.className = "vc-lightbox" + (imgs.length < 2 ? " vc-lightbox--single" : "");
|
||||||
|
box.setAttribute("role", "dialog");
|
||||||
|
box.setAttribute("aria-modal", "true");
|
||||||
|
box.innerHTML =
|
||||||
|
'<button class="vc-lightbox__btn vc-lightbox__prev" type="button" aria-label="Previous"><svg class="icon icon--24" aria-hidden="true"><use href="#i-arrow-left"></use></svg></button>' +
|
||||||
|
'<img class="vc-lightbox__img" alt="">' +
|
||||||
|
'<button class="vc-lightbox__btn vc-lightbox__next" type="button" aria-label="Next"><svg class="icon icon--24" aria-hidden="true"><use href="#i-arrow-right"></use></svg></button>' +
|
||||||
|
'<button class="vc-lightbox__btn vc-lightbox__close" type="button" aria-label="Close"><svg class="icon icon--24" aria-hidden="true"><use href="#i-x"></use></svg></button>' +
|
||||||
|
'<span class="vc-lightbox__count"></span>';
|
||||||
|
host.appendChild(box);
|
||||||
|
|
||||||
|
var bigImg = box.querySelector(".vc-lightbox__img");
|
||||||
|
var count = box.querySelector(".vc-lightbox__count");
|
||||||
|
var idx = 0;
|
||||||
|
|
||||||
|
function show(i) {
|
||||||
|
idx = (i + imgs.length) % imgs.length;
|
||||||
|
bigImg.src = imgs[idx].currentSrc || imgs[idx].src;
|
||||||
|
bigImg.alt = imgs[idx].alt || "";
|
||||||
|
count.textContent = (idx + 1) + " / " + imgs.length;
|
||||||
|
}
|
||||||
|
function open(i) {
|
||||||
|
show(i);
|
||||||
|
box.classList.add("is-open");
|
||||||
|
document.documentElement.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
box.classList.remove("is-open");
|
||||||
|
document.documentElement.style.overflow = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
imgs.forEach(function (img, i) {
|
||||||
|
img.addEventListener("click", function () { open(i); });
|
||||||
|
});
|
||||||
|
box.querySelector(".vc-lightbox__prev").addEventListener("click", function () { show(idx - 1); });
|
||||||
|
box.querySelector(".vc-lightbox__next").addEventListener("click", function () { show(idx + 1); });
|
||||||
|
box.querySelector(".vc-lightbox__close").addEventListener("click", close);
|
||||||
|
box.addEventListener("click", function (e) { if (e.target === box) close(); });
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (!box.classList.contains("is-open")) return;
|
||||||
|
if (e.key === "Escape") close();
|
||||||
|
else if (e.key === "ArrowLeft") show(idx - 1);
|
||||||
|
else if (e.key === "ArrowRight") show(idx + 1);
|
||||||
|
});
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
|
|||||||
181
viator-collector/assets/js/vc-structure-admin.js
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Settings → Viator Structure editor. Regions and cities are plain DOM
|
||||||
|
* blocks; cities can only be added from the Viator destination search, and
|
||||||
|
* indexed field names are assigned on submit.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var cfg = window.vcStructure || {};
|
||||||
|
var i18n = cfg.i18n || {};
|
||||||
|
var form = document.getElementById('vc-structure-form');
|
||||||
|
var list = document.getElementById('vc-regions');
|
||||||
|
if (!form || !list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(s) {
|
||||||
|
return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromTemplate(id) {
|
||||||
|
var tpl = document.getElementById(id);
|
||||||
|
return tpl.content.firstElementChild.cloneNode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usedIds() {
|
||||||
|
var ids = {};
|
||||||
|
list.querySelectorAll('.vc-city [data-f="id"]').forEach(function (input) {
|
||||||
|
ids[input.value] = true;
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(el, dir) {
|
||||||
|
if (dir < 0 && el.previousElementSibling) {
|
||||||
|
el.parentNode.insertBefore(el, el.previousElementSibling);
|
||||||
|
} else if (dir > 0 && el.nextElementSibling) {
|
||||||
|
el.parentNode.insertBefore(el.nextElementSibling, el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCity(region, item) {
|
||||||
|
var row = fromTemplate('vc-tpl-city');
|
||||||
|
row.querySelector('[data-f="id"]').value = item.id;
|
||||||
|
row.querySelector('[data-f="name"]').value = item.name;
|
||||||
|
row.querySelector('[data-f="slug"]').value = item.slug || slugify(item.name);
|
||||||
|
row.querySelector('.vc-city__name').textContent = item.name;
|
||||||
|
row.querySelector('.vc-city__id').textContent = item.id;
|
||||||
|
region.querySelector('.vc-cities tbody').appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(region) {
|
||||||
|
var q = region.querySelector('.vc-city-search input[type="search"]');
|
||||||
|
var out = region.querySelector('.vc-results');
|
||||||
|
out.textContent = i18n.searching || '…';
|
||||||
|
|
||||||
|
var data = new FormData();
|
||||||
|
data.append('action', 'vc_search_destinations');
|
||||||
|
data.append('_ajax_nonce', cfg.nonce);
|
||||||
|
data.append('q', q.value);
|
||||||
|
|
||||||
|
fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: data })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res || !res.success) {
|
||||||
|
out.textContent = (res && res.data && res.data.message) || i18n.failed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.data.results.length) {
|
||||||
|
out.textContent = i18n.noMatches;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var used = usedIds();
|
||||||
|
out.innerHTML = '';
|
||||||
|
res.data.results.forEach(function (item) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'button button-small';
|
||||||
|
if (used[item.id]) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = i18n.used;
|
||||||
|
} else {
|
||||||
|
btn.textContent = i18n.add;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
addCity(region, item);
|
||||||
|
out.innerHTML = '';
|
||||||
|
q.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var label = document.createElement('span');
|
||||||
|
label.textContent = item.name + ' (ID ' + item.id + ')';
|
||||||
|
row.appendChild(btn);
|
||||||
|
row.appendChild(label);
|
||||||
|
out.appendChild(row);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { out.textContent = i18n.failed; });
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('[data-act]');
|
||||||
|
if (!btn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var region = btn.closest('.vc-region');
|
||||||
|
var city = btn.closest('.vc-city');
|
||||||
|
var target = city || region;
|
||||||
|
switch (btn.getAttribute('data-act')) {
|
||||||
|
case 'up': move(target, -1); break;
|
||||||
|
case 'down': move(target, 1); break;
|
||||||
|
case 'remove-city': city.remove(); break;
|
||||||
|
case 'remove-region':
|
||||||
|
if (window.confirm(i18n.confirm)) {
|
||||||
|
region.remove();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'search': search(region); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
list.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter' && e.target.matches('.vc-city-search input[type="search"]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
search(e.target.closest('.vc-region'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suggest a region slug from its name until the slug is edited by hand.
|
||||||
|
list.addEventListener('input', function (e) {
|
||||||
|
var input = e.target;
|
||||||
|
if (input.matches('.vc-region__head [data-f="slug"]')) {
|
||||||
|
input.dataset.touched = '1';
|
||||||
|
}
|
||||||
|
if (input.matches('.vc-region__head [data-f="name"]')) {
|
||||||
|
var slug = input.closest('.vc-region__head').querySelector('[data-f="slug"]');
|
||||||
|
if (!slug.dataset.touched && (slug.value === '' || slug.dataset.auto === '1')) {
|
||||||
|
slug.value = slugify(input.value);
|
||||||
|
slug.dataset.auto = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('vc-add-region').addEventListener('click', function () {
|
||||||
|
var region = fromTemplate('vc-tpl-region');
|
||||||
|
list.appendChild(region);
|
||||||
|
region.querySelector('[data-f="name"]').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function hidden(name) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = name;
|
||||||
|
input.value = '1';
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
var regions = list.querySelectorAll('.vc-region');
|
||||||
|
if (!regions.length) {
|
||||||
|
if (!window.confirm(i18n.confirmEmpty)) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hidden('vc_confirm_empty');
|
||||||
|
}
|
||||||
|
// Proves the editor ran: the server refuses a save without it, so a
|
||||||
|
// failed script can never submit an empty structure by accident.
|
||||||
|
hidden('vc_structure_present');
|
||||||
|
regions.forEach(function (region, i) {
|
||||||
|
region.querySelectorAll('.vc-region__head [data-f]').forEach(function (input) {
|
||||||
|
input.name = 'vc_structure[' + i + '][' + input.dataset.f + ']';
|
||||||
|
});
|
||||||
|
region.querySelectorAll('.vc-city').forEach(function (city, j) {
|
||||||
|
city.querySelectorAll('[data-f]').forEach(function (input) {
|
||||||
|
input.name = 'vc_structure[' + i + '][cities][' + j + '][' + input.dataset.f + ']';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -17,7 +17,11 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
const SCOPE = process.env.VC_SCOPE || '.vc-app';
|
// Doubled class (`.vc-app.vc-app`) so every ported rule carries an extra class
|
||||||
|
// of specificity — enough to win against Astra's content selectors (e.g.
|
||||||
|
// `.ast-… .entry-content a`) without touching the markup, which still only
|
||||||
|
// needs a single `class="vc-app"`. Override with VC_SCOPE if ever needed.
|
||||||
|
const SCOPE = process.env.VC_SCOPE || '.vc-app.vc-app';
|
||||||
|
|
||||||
function prefixSelector(sel) {
|
function prefixSelector(sel) {
|
||||||
sel = sel.trim();
|
sel = sel.trim();
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Region / City / Product URLs Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Serve regions at `/{region}/`, cities at `/{city}/` and products at `/{city}/{product}/`. The client manages the region → city structure from a validated settings screen.
|
||||||
|
|
||||||
|
**Architecture:** A pure-PHP `VC_Structure` class holds the structure, validation, canonical-city choice and rewrite-rule generation, and is unit-tested without WordPress. Thin WordPress layers sit on top of it:
|
||||||
|
- `VC_Structure_Admin`: settings screen, save, sync to `viator_destination` terms
|
||||||
|
- `VC_Routing`: rewrite rules, link filters, 301s
|
||||||
|
- `VC_Menu`: region items in the header menu
|
||||||
|
|
||||||
|
These layers are verified by `php -l` and a manual QA pass on a WordPress install.
|
||||||
|
|
||||||
|
**Tech Stack:** WordPress, PHP 8 (local CLI is PHP 8.4), plugin `viator-collector`, child theme `astra-child-hoponhopoff`. No Composer/PHPUnit: tests use a tiny runner at `viator-collector/tests/run.php`.
|
||||||
|
|
||||||
|
**Spec:** `viator-collector/docs/superpowers/specs/2026-10-29-region-city-urls-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Preset regions, in order: UK `uk`, Europe `europe`, Asia `asia`, Middle East and Africa `middle-east-and-africa`, Americas `americas`, Australasia `australasia`.
|
||||||
|
- Valid slug: `^[a-z0-9]+(?:-[a-z0-9]+)*$`.
|
||||||
|
- Reserved slugs: `wp-admin`, `wp-content`, `wp-includes`, `wp-json`, `feed`, `page`, `search`, `tag`, `category`, `author`, `tours`, `tour-destination`, `tour-tag`, `tour-category`, `blog`, `about`, `contact`, `faq`, `embed`, `attachment`.
|
||||||
|
- Term meta: region flag `_vc_region` = `1`; city link `_vc_viator_destination_id` (already exists).
|
||||||
|
- Post meta: `_vc_primary_destination` (Viator destination ref string).
|
||||||
|
- Options: `vc_structure` (structure array), `vc_flush_rewrite` (flag).
|
||||||
|
- All redirects are 301 and keep the query string.
|
||||||
|
- Match plugin style: static classes, `VC_` prefix, tabs, `vc_get_option`, `VC_Logger`, text domain `viator-collector`.
|
||||||
|
- Work on branch `feature/region-city-urls`, not `main`.
|
||||||
|
- `tests/` and `docs/` never ship in the zip.
|
||||||
|
|
||||||
|
**Structure array shape** (used everywhere):
|
||||||
|
```php
|
||||||
|
array(
|
||||||
|
array(
|
||||||
|
'name' => 'Europe',
|
||||||
|
'slug' => 'europe',
|
||||||
|
'term_id' => 12, // 0 until synced
|
||||||
|
'cities' => array(
|
||||||
|
array( 'id' => '737', 'name' => 'Amsterdam', 'slug' => 'amsterdam' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. A city slug equal to an **unconfigured** existing destination term's slug (e.g. two Viator "Victoria"s) must be a validation error, not a silent `wp_update_term` failure. Covered by `test_validate_taken_slug` (Task 1).
|
||||||
|
2. A product whose Viator primary destination is not configured but which belongs to two configured cities must use the first city in settings order. Covered by `test_canonical_city_fallback_order` (Task 1).
|
||||||
|
3. A product in no configured city keeps `/tours/{slug}/`: `canonical_city()` returns `''`. Covered by `test_canonical_city_none` (Task 1).
|
||||||
|
4. An admin-typed slug like `New York` or `amsterdam!` must be rejected, not silently changed. Covered by `test_validate_bad_slug` (Task 1).
|
||||||
|
5. Filter and sort query strings (`?dest=…&sort=…&paged=2`) must survive every 301. Covered by manual check M6 (Task 7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `VC_Structure` core and test runner
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `viator-collector/includes/class-vc-structure.php`
|
||||||
|
- Create: `viator-collector/tests/run.php`, `viator-collector/tests/test-structure.php`
|
||||||
|
- Modify: `build-zip.php` (add `'tests'` to `$excluded_dirs`)
|
||||||
|
|
||||||
|
**Interfaces (Produces):** class `VC_Structure`, all `public static`:
|
||||||
|
- `const OPTION = 'vc_structure';`
|
||||||
|
- `default_regions(): array`: the six presets, `term_id` 0, no cities.
|
||||||
|
- `get(): array`: `get_option( self::OPTION, self::default_regions() )`, cached in a static per request. Untested; WordPress only.
|
||||||
|
- `normalize( array $raw ): array`
|
||||||
|
- Shape POST data into the structure shape: trim names, `trim(strtolower())` slugs, cast `term_id` to int and `id` to a digit string, drop city rows with an empty `id`, reindex.
|
||||||
|
- An empty region slug becomes `slugify( name )`. An empty city slug becomes `slugify( city name )`.
|
||||||
|
- `slugify( string $s ): string`: lowercase, runs of non `[a-z0-9]` become `-`, trim `-`.
|
||||||
|
- `reserved_slugs(): array`: the list in Global Constraints.
|
||||||
|
- `validate( array $structure, array $destination_map, array $taken ): array`
|
||||||
|
- `$destination_map` is Viator `id => name`. `$taken` is `slug => human label` for slugs owned by something outside the structure.
|
||||||
|
- Returns a list of error strings, empty when valid. Messages are exact:
|
||||||
|
- `Region %d has no name.` (1-based)
|
||||||
|
- `City ID %s is not a Viator destination.`
|
||||||
|
- `%s is listed more than once.` (city name)
|
||||||
|
- `Slug "%s" is invalid: use only a-z, 0-9 and -.`
|
||||||
|
- `Slug "%s" is reserved.`
|
||||||
|
- `Slug "%s" is used more than once.`
|
||||||
|
- `Slug "%s" is already used by %s.`
|
||||||
|
- `cities( array $structure ): array`: ordered `viator_id => city + array( 'region' => region slug )`.
|
||||||
|
- `canonical_city( array $structure, array $dest_ids, string $primary_id ): string`
|
||||||
|
- Returns the city slug for `$primary_id` if it is configured.
|
||||||
|
- Otherwise returns the slug of the first configured city (in `cities()` order) that appears in `$dest_ids`.
|
||||||
|
- Otherwise returns `''`.
|
||||||
|
- `primary_destination_id( array $destinations ): string`: `ref` (or `destinationId`) of the entry with truthy `primary`, else `''`.
|
||||||
|
- `rewrite_rules( array $structure ): array`, regex => query, in this order:
|
||||||
|
- each region: `'^europe/?$' => 'index.php?viator_destination=europe'`
|
||||||
|
- each city: `'^amsterdam/?$' => 'index.php?viator_destination=amsterdam'` and `'^amsterdam/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]'`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the runner.** `tests/run.php` defines `ABSPATH`, requires `includes/class-vc-structure.php` and every `tests/test-*.php`, then calls each function whose name starts with `test_`. It provides `vc_assert_same( $expected, $actual, string $msg )`, which throws on mismatch, and `vc_assert_contains( string $needle, array $errors )`. It prints `N passed, M failed` and exits 1 on any failure.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing tests** in `tests/test-structure.php`. Use the fixture `$s` = Europe(`amsterdam` id 737, `paris` id 479) + Americas(`boston` id 678) and `$map = array('737'=>'Amsterdam','479'=>'Paris','678'=>'Boston','22'=>'Victoria')`.
|
||||||
|
```php
|
||||||
|
test_default_regions // 6 regions; slugs exactly the Global Constraints list, in order
|
||||||
|
test_normalize_fills_slugs // region name 'Middle East and Africa', slug '' -> 'middle-east-and-africa'; city row with id '' dropped
|
||||||
|
test_validate_ok // validate($s,$map,[]) === []
|
||||||
|
test_validate_unknown_city // city id '999' -> contains 'City ID 999 is not a Viator destination.'
|
||||||
|
test_validate_duplicate_city // 737 in Europe and Americas -> contains 'Amsterdam is listed more than once.'
|
||||||
|
test_validate_duplicate_slug // region slug 'paris' + city 'paris' -> contains 'Slug "paris" is used more than once.'
|
||||||
|
test_validate_bad_slug // 'New York' and 'amsterdam!' -> both 'Slug "…" is invalid: use only a-z, 0-9 and -.'
|
||||||
|
test_validate_reserved // city slug 'contact' -> contains 'Slug "contact" is reserved.'
|
||||||
|
test_validate_taken_slug // $taken=['victoria'=>'destination "Victoria"'], city slug 'victoria' -> contains 'Slug "victoria" is already used by destination "Victoria".'
|
||||||
|
test_validate_empty_region // region name '' -> contains 'Region 1 has no name.'
|
||||||
|
test_canonical_city_primary // canonical_city($s,['479','737'],'479') === 'paris'
|
||||||
|
test_canonical_city_fallback_order // canonical_city($s,['678','479','737'],'5555') === 'amsterdam'
|
||||||
|
test_canonical_city_none // canonical_city($s,['5555'],'') === ''
|
||||||
|
test_primary_destination_id // [['ref'=>'1'],['ref'=>'2','primary'=>true]] -> '2'; [] -> ''
|
||||||
|
test_rewrite_rules // exact array: europe, americas, amsterdam, amsterdam product, paris, paris product, boston, boston product (region rules first, then cities)
|
||||||
|
test_rewrite_rules_empty_region // region with no cities still yields '^europe/?$'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run and confirm the failure.** Run `php tests/run.php` from `viator-collector/`. Expected: a fatal error saying class `VC_Structure` was not found.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `VC_Structure`** with the interface above. Only `get()` may call WordPress functions.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run and confirm the pass.** Run `php tests/run.php`. Expected: `16 passed, 0 failed`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Exclude tests from the zip.** Add `'tests'` to `$excluded_dirs` in `build-zip.php`. Then run `php build-zip.php viator-collector "$TMP/vc.zip"` and confirm no `tests/` entries are in the zip.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit.** Commit message: `feat(structure): add region/city structure model with validation`.
|
||||||
|
|
||||||
|
### Task 2: Importer stores the primary destination; crawl follows the structure
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `viator-collector/includes/class-vc-importer.php` (the `$meta` array in `save_tour_meta`, ~line 370)
|
||||||
|
- Modify: `viator-collector/includes/class-vc-cron.php` (`start_run`, ~lines 159-166)
|
||||||
|
- Modify: `viator-collector/includes/class-vc-settings.php` (description of the crawl destinations field)
|
||||||
|
- Modify: `viator-collector/includes/class-vc-plugin.php` (load `class-vc-structure.php` before the importer)
|
||||||
|
|
||||||
|
**Interfaces (Consumes):** `VC_Structure::primary_destination_id()`, `VC_Structure::cities()`, `VC_Structure::get()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Store the primary destination.** Add `'_vc_primary_destination' => VC_Structure::primary_destination_id( (array) vc_array_get( $product, 'destinations', array() ) )` to the `$meta` array.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the crawl queue from the structure.** In `start_run`:
|
||||||
|
- If `array_keys( VC_Structure::cities( VC_Structure::get() ) )` is non-empty, the queue is exactly that list.
|
||||||
|
- Otherwise keep the existing allowlist/full-catalogue logic.
|
||||||
|
- Log `Sync run started from structure — %d cities queued.` in the structure case.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Relabel the old allowlist.** Append `Used only when no cities are set on the Viator Structure screen.` to the crawl destinations field's description.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lint.** Run `php -l` on each changed file. Expected: `No syntax errors detected` for each. Then run `php tests/run.php`, which should still pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.** Commit message: `feat(import): store primary destination; crawl configured cities`.
|
||||||
|
|
||||||
|
### Task 3: Structure settings screen, validated save, and term sync
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `viator-collector/includes/class-vc-structure-admin.php`
|
||||||
|
- Create: `viator-collector/assets/js/vc-structure-admin.js`
|
||||||
|
- Modify: `viator-collector/includes/class-vc-plugin.php` (load the file and call `VC_Structure_Admin::init()`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all of `VC_Structure`; existing AJAX action `vc_search_destinations` (nonce `vc_search_destinations`, POST `q`, returns `{results:[{id,name}]}`); `VC_Taxonomies::get_or_create_destination_by_id( $id, $name )`; `VC_API::get_destination_map()`.
|
||||||
|
- Produces:
|
||||||
|
- `VC_Structure_Admin::init()`
|
||||||
|
- `VC_Structure_Admin::taken_slugs( array $structure ): array`, slug => label
|
||||||
|
- `VC_Structure_Admin::sync_terms( array $old, array $new ): array`, returns `$new` with region `term_id`s filled
|
||||||
|
- Sets option `vc_flush_rewrite` = 1 after a successful save
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the screen.** Register a submenu under Settings with `add_options_page`: title `Viator Structure`, slug `viator-collector-structure`, capability `manage_options`.
|
||||||
|
- The page renders one block per region: name input, slug input, a hidden `term_id`, and up/down/remove buttons.
|
||||||
|
- Each region block holds its city rows. A city row shows the name read-only, an editable slug, and a hidden `id` and `name`, with up/down/remove buttons.
|
||||||
|
- Each region has a city search box that calls `vc_search_destinations`. Results whose id is already used anywhere in the form are shown disabled.
|
||||||
|
- An "Add region" button sits at the end.
|
||||||
|
- Field names: `vc_structure[i][name|slug|term_id]` and `vc_structure[i][cities][j][id|name|slug]`. The JS renumbers indexes before submit.
|
||||||
|
- The form posts to `admin-post.php`, action `vc_save_structure`, nonce `vc_save_structure`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement `taken_slugs( $structure )`.** It returns these as `slug => label`:
|
||||||
|
- top-level Pages (`post_parent` 0), labelled `page "Title"`
|
||||||
|
- posts of every public post type in any status except trash and auto-draft, labelled `post "Title"`
|
||||||
|
- `viator_destination` terms that are neither a structure region (`term_id`) nor a structure city (matched through `_vc_viator_destination_id`), labelled `destination "Name"`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the save handler.**
|
||||||
|
1. Check the capability and nonce.
|
||||||
|
2. Run `normalize( wp_unslash( $_POST['vc_structure'] ?? array() ) )`.
|
||||||
|
3. Load the destination map. If that returns a `WP_Error`, treat it as the single error `Could not load Viator destinations; nothing was saved.`
|
||||||
|
4. Run `validate( $s, $map, self::taken_slugs( $s ) )`.
|
||||||
|
5. On errors, store `array( 'errors' => …, 'structure' => $s )` in transient `vc_structure_draft_{user_id}` (10 minutes) and redirect back. The screen re-renders that draft with a red notice listing every error. Nothing is saved.
|
||||||
|
6. On success: `$s = self::sync_terms( VC_Structure::get(), $s )`, then `update_option( VC_Structure::OPTION, $s )`, then `update_option( 'vc_flush_rewrite', 1 )`, then redirect with the notice `Structure saved.`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `sync_terms( $old, $new )`.**
|
||||||
|
- **Region:** update the term by `term_id` if it exists (name, slug, parent 0). Otherwise `wp_insert_term( name, 'viator_destination', array( 'slug' => slug ) )`. In both cases set `_vc_region` = 1 and store the `term_id` back.
|
||||||
|
- **City:** `$tid = VC_Taxonomies::get_or_create_destination_by_id( id, name )`, then `wp_update_term( $tid, 'viator_destination', array( 'slug' => slug, 'parent' => region term_id ) )`.
|
||||||
|
- **Removed regions** (old `term_id`s that are no longer present): `wp_delete_term`.
|
||||||
|
- **Removed cities** (old ids that are no longer present): set `parent` to 0.
|
||||||
|
- Log every `WP_Error` with `VC_Logger::error`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the page-slug clash notice.** On `admin_notices`, on the page edit screen, if the current page's `post_name` is a structure region or city slug, show the warning `This page's URL is also used by the Viator region/city "%s"; the Viator page will take priority.`
|
||||||
|
|
||||||
|
- [ ] **Step 6: Lint.** Run `php -l includes/class-vc-structure-admin.php` and `node --check assets/js/vc-structure-admin.js`. Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit.** Commit message: `feat(structure): admin screen with validated save and term sync`.
|
||||||
|
|
||||||
|
### Task 4: Routing — rewrite rules, links, redirects
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `viator-collector/includes/class-vc-routing.php`
|
||||||
|
- Modify: `viator-collector/includes/class-vc-plugin.php` (load the file and call `VC_Routing::init()`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `VC_Structure::get()`, `::rewrite_rules()`, `::cities()`, `::canonical_city()`; option `vc_flush_rewrite`.
|
||||||
|
- Produces: `VC_Routing::init()`, `VC_Routing::is_configured_term( WP_Term $term ): bool`, `VC_Routing::product_city( int $post_id ): string`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the rules.** On `init` at priority 20, call `add_rewrite_rule( $regex, $query, 'top' )` for each `rewrite_rules( get() )` entry. Then, if `get_option( 'vc_flush_rewrite' )` is set, delete it and call `flush_rewrite_rules( false )`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement `is_configured_term`.** It is true when the term is in `viator_destination` and either has `_vc_region` = 1 or its `_vc_viator_destination_id` is a key of `cities()`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `product_city`.** Build `$dest_ids` from the `ref` / `destinationId` values in the post's `_vc_destinations` JSON meta. Return `canonical_city( get(), $dest_ids, (string) _vc_primary_destination )`. Cache the result per `post_id` in a static.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the link filters.**
|
||||||
|
- `term_link`: for a configured term, return `home_url( '/' . $term->slug . '/' )`.
|
||||||
|
- `post_type_link`: for a `viator_tour` with a non-empty `post_name` and a non-empty `product_city`, return `home_url( "/{city}/{post_name}/" )`.
|
||||||
|
- Everything else is returned unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the 301s.** On `template_redirect`, for `is_singular( 'viator_tour' )` (published, not a preview) and for configured `is_tax( 'viator_destination' )`:
|
||||||
|
- Compute the canonical URL (`get_permalink()` / `get_term_link()`).
|
||||||
|
- Compare `untrailingslashit` paths of the canonical URL and `REQUEST_URI` with the query string removed.
|
||||||
|
- If they differ, `wp_safe_redirect( canonical + '?' + original query string if any, 301 )` and exit.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add the term canonical tag.** On `wp_head`, for a configured term archive, print `<link rel="canonical" href="…">`. Skip this when `WPSEO_VERSION` or `RANK_MATH_VERSION` is defined. Singular products already get core's canonical from `get_permalink()`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Lint and commit.** Run `php -l includes/class-vc-routing.php`. Expected: no errors. Commit message: `feat(routing): region/city/product URLs with 301s from old paths`.
|
||||||
|
|
||||||
|
### Task 5: Region landing page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `viator-collector/templates/region.php`
|
||||||
|
- Modify: `viator-collector/includes/class-vc-display.php` (`taxonomy_template`, ~line 534)
|
||||||
|
- Modify: `viator-collector/templates/listing.php` (new optional arg `$vc_after_hero`)
|
||||||
|
|
||||||
|
**Interfaces (Consumes):** `VC_Routing::is_configured_term()`; existing `vc_get_template()`, `card-destination.php` (arg `term`), `listing.php`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Route region terms.** In `taxonomy_template`, when the queried term has `_vc_region` = 1, return `get_stylesheet_directory() . '/viator-collector/region.php'` if it exists, else `VC_PLUGIN_DIR . 'templates/region.php'`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `$vc_after_hero` to `listing.php`.** It is an optional HTML string, default `''`, echoed immediately after the listing hero section closes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `templates/region.php`.**
|
||||||
|
- Copy the structure of `taxonomy-viator.php`.
|
||||||
|
- Build `$vc_after_hero` as `<section class="vc-section vc-section--destinations">`. It holds the heading `Cities in %s` and a `vc-destination-grid` of `card-destination.php` cards.
|
||||||
|
- The cards are the child terms from `get_terms( array( 'taxonomy' => 'viator_destination', 'parent' => term_id, 'hide_empty' => false ) )`, ordered as in the structure (the order of `cities()`).
|
||||||
|
- The listing's main query already includes child terms, so it shows every tour in the region.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lint and commit.** Run `php -l` on the three files. Commit message: `feat(display): region landing page with city grid`.
|
||||||
|
|
||||||
|
### Task 6: Regions in the header menu
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `viator-collector/includes/class-vc-menu.php`
|
||||||
|
- Modify: `viator-collector/includes/class-vc-plugin.php` (load the file and call `VC_Menu::init()`)
|
||||||
|
- Modify: `astra-child-hoponhopoff/inc/nav-walker.php` (`hoho_nav_fallback`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `VC_Structure::get()`; region and city terms (`term_id`, links via `get_term_link`).
|
||||||
|
- Produces: `VC_Menu::items(): array` of menu-item objects.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement `VC_Menu::items()`.** Build `stdClass` items with these fields: `ID`, `db_id`, `menu_item_parent` (string), `title`, `url`, `classes` (array), `type` `'custom'`, `object` `'custom'`, `object_id`, `target` `''`, `xfn` `''`, `attr_title` `''`, `description` `''`, `current` false.
|
||||||
|
- Use negative unique `ID`/`db_id`: `-1000 - n`.
|
||||||
|
- Each region gets a top-level item.
|
||||||
|
- A region with cities gets these children, in order: `All of %s` (the region URL, because the theme walker turns a parent with children into a toggle button with no link), then one item per city.
|
||||||
|
- Regions whose term no longer exists are skipped.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Append to the header menu.** Add the filter `wp_nav_menu_objects` (`$items`, `$args`). When `$args->theme_location === 'hoho_primary'`, return `array_merge( $items, self::items() )`. Set `menu_order` sequentially after the existing items.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the theme fallback.** In `hoho_nav_fallback`, after the Tours link and when `class_exists( 'VC_Menu' )`, echo `walk_nav_menu_tree( VC_Menu::items(), 2, (object) array( 'walker' => new Hoho_Nav_Walker() ) )` inside the same `<ul>`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lint and commit.** Run `php -l` on both PHP files. Commit message: `feat(menu): list regions and cities in the header menu`.
|
||||||
|
|
||||||
|
### Task 7: Version bump, build, and manual QA
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `viator-collector/viator-collector.php` (Version header and `VC_VERSION` → `1.9.0`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump the version, run the tests and build the zips.**
|
||||||
|
- Bump the version to 1.9.0.
|
||||||
|
- Run `php tests/run.php`. Expected: all tests pass.
|
||||||
|
- From the repo root, run `php build-zip.php viator-collector viator-collector.zip`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manual QA on a WordPress install** with the client's live data copied over. Record pass or fail for each check:
|
||||||
|
- M1. Save the preset regions and add `Boston` and `New York City` under Americas. The menu shows the six regions, and Americas drops down to `All of Americas`, Boston and New York City.
|
||||||
|
- M2. `/americas/` shows the city grid and all of their tours. `/boston/` lists Boston tours.
|
||||||
|
- M3. `/tour-destination/boston/` gives a 301 to `/boston/`. Do the same check for all 15 of the client's URLs once those cities are added.
|
||||||
|
- M4. A Boston tour's card links to `/boston/{slug}/`. `/tours/{slug}/` and `/new-york-city/{slug}/` both give a 301 to it.
|
||||||
|
- M5. Saving each of these shows an error, and the previous structure is unchanged:
|
||||||
|
- a city twice
|
||||||
|
- slug `contact`
|
||||||
|
- slug `New York`
|
||||||
|
- a city slug equal to an existing page's slug
|
||||||
|
- M6. `/boston/?sort=price_asc&paged=2` keeps its query. `/tour-destination/boston/?sort=price_asc` gives a 301 to `/boston/?sort=price_asc`.
|
||||||
|
- M7. Run a manual sync. The log reads `cities queued` with the configured count.
|
||||||
|
- M8. `/contact/` and other pages still load.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit.** Commit message: `chore: bump viator-collector to 1.9.0`.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Region / City / Product URL Structure — Design
|
||||||
|
|
||||||
|
Date: 2026-10-29
|
||||||
|
Status: draft, awaiting approval
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
The client wants the site organised by place, like hoponhopoffplus.com:
|
||||||
|
visitors pick a **region**, then a **city**, then a **product**. URLs should
|
||||||
|
read the same way:
|
||||||
|
|
||||||
|
| Page | URL | Example |
|
||||||
|
|---------|---------------------------------------|----------------------------------------------------------------|
|
||||||
|
| Region | `/{region-slug}/` | `/europe/` |
|
||||||
|
| City | `/{city-slug}/` | `/amsterdam/` |
|
||||||
|
| Product | `/{city-slug}/{product-slug}/` | `/amsterdam/city-sightseeing-hop-on-hop-off-amsterdam-bus-tour-24-hours/` |
|
||||||
|
|
||||||
|
The header menu lists the regions: **UK, Europe, Asia, Middle East and
|
||||||
|
Africa, Americas, Australasia**.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- The client builds the region → city structure in the plugin settings.
|
||||||
|
- Products keep importing automatically from Viator. A city page shows
|
||||||
|
**every** Viator product in that city, not only hop-on hop-off tours.
|
||||||
|
- The settings screen makes bad data impossible to save (see Validation).
|
||||||
|
- Existing live URLs keep working through 301 redirects. The client sent
|
||||||
|
these, which are live today and likely the first Americas cities:
|
||||||
|
washington-dc, boston, chicago, new-york-city, new-orleans, miami,
|
||||||
|
las-vegas, los-angeles, san-diego, san-francisco, seattle, victoria,
|
||||||
|
vancouver, montreal, toronto (all under `/tour-destination/{slug}/`).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- The new static pages from `new front/` (about, contact, faq, blog,
|
||||||
|
blog-post). That is a separate, later project.
|
||||||
|
- A hand-built sample product page. Products come from the Viator plugin.
|
||||||
|
- Redesigning the listing, card or single-tour templates.
|
||||||
|
- Region in the city URL (`/europe/amsterdam/`). The client's examples
|
||||||
|
rule this out.
|
||||||
|
|
||||||
|
## Anti-goals (would count as failure even if it "works")
|
||||||
|
|
||||||
|
- A city or region URL that hijacks an existing WordPress page, post or
|
||||||
|
system path (e.g. a city slugged `about` or `wp-admin`).
|
||||||
|
- The same product reachable at two indexable URLs (duplicate content).
|
||||||
|
- Any of the client's existing `/tour-destination/...` links returning 404.
|
||||||
|
- Admin able to type a city that does not exist in Viator.
|
||||||
|
|
||||||
|
## Decisions (agreed in conversation)
|
||||||
|
|
||||||
|
- Structure lives in plugin settings; client owns it.
|
||||||
|
- Cities are picked only from Viator's destination list (reuse the existing
|
||||||
|
`vc_search_destinations` AJAX lookup in `class-vc-settings.php`).
|
||||||
|
- A product that belongs to several configured cities **appears on every
|
||||||
|
one of those city pages**.
|
||||||
|
- Old `/tour-destination/{slug}/` URLs 301 to `/{slug}/`.
|
||||||
|
|
||||||
|
## Decisions made by Claude (user delegated URL choices)
|
||||||
|
|
||||||
|
1. **One canonical product URL.** City in the URL = the product's Viator
|
||||||
|
*primary* destination if that destination is a configured city;
|
||||||
|
otherwise the first configured city (in settings order) the product
|
||||||
|
belongs to. Requesting the product under any other city
|
||||||
|
(`/paris/same-tour/`) 301s to the canonical URL. `/tours/{slug}/`
|
||||||
|
also 301s to the canonical URL.
|
||||||
|
2. **Products in no configured city** (left over from older crawls) keep
|
||||||
|
`/tours/{slug}/` and do not appear on any region/city page.
|
||||||
|
3. **Data model: reuse the `viator_destination` taxonomy (hierarchical).**
|
||||||
|
- Region = top-level term with no Viator ID, marked by term meta
|
||||||
|
`_vc_region = 1`.
|
||||||
|
- City = term linked to a Viator destination ID (existing
|
||||||
|
`_vc_viator_destination_id` meta), with its parent set to its region.
|
||||||
|
- Existing city terms are reused by Viator ID, so current slugs such as
|
||||||
|
`washington-dc` are kept as-is.
|
||||||
|
- A region page queries its child terms with `include_children`, so it
|
||||||
|
lists every product in its cities without extra assignment.
|
||||||
|
- The structure itself is stored as one option (`vc_structure`): an
|
||||||
|
ordered list of regions, each with an ordered list of
|
||||||
|
`{viator_id, name, slug}` cities. Saving the settings syncs it into
|
||||||
|
terms (create/re-parent/rename), never deleting products.
|
||||||
|
4. **The six regions come preset** (slugs: `uk`, `europe`, `asia`,
|
||||||
|
`middle-east-and-africa`, `americas`, `australasia`). The client can
|
||||||
|
rename, reorder, add or remove regions.
|
||||||
|
5. **City slug** defaults to `sanitize_title( Viator name )`, or the
|
||||||
|
existing term's slug if one exists. The admin may edit it; edits go
|
||||||
|
through the same validation.
|
||||||
|
6. **Routing: explicit rewrite rules per configured slug, not catch-alls.**
|
||||||
|
For each region and city, add rules for `^{slug}/?$`; for each city also
|
||||||
|
`^{slug}/([^/]+)/?$` → product. Rules are rebuilt and flushed when the
|
||||||
|
structure is saved. Unconfigured paths fall through to normal WordPress
|
||||||
|
routing, so pages like `/contact/` are unaffected. Listing pagination
|
||||||
|
already uses `?paged=`, so no `/page/N/` rule is needed (the builder
|
||||||
|
should confirm this).
|
||||||
|
7. **Links.** `term_link` filter returns `/{slug}/` for region and city
|
||||||
|
terms; `post_type_link` filter returns the canonical product URL.
|
||||||
|
Unconfigured destination terms keep `/tour-destination/{slug}/`.
|
||||||
|
8. **Crawl.** When the structure has at least one city, the crawl queue is
|
||||||
|
exactly the configured cities' Viator IDs. The old
|
||||||
|
`crawl_destinations` allowlist is used only when the structure is empty,
|
||||||
|
and its field is labelled as such.
|
||||||
|
9. **Primary destination.** The importer stores the Viator primary
|
||||||
|
destination ref (the entry in `destinations` with `primary: true`) in
|
||||||
|
post meta `_vc_primary_destination`. Products imported before this change
|
||||||
|
get it on the next sync; until then the fallback rule in (1) applies.
|
||||||
|
10. **Menu.** Regions are added automatically to the "HoHo — Header Menu"
|
||||||
|
location as top-level items, each with a dropdown of its cities (the
|
||||||
|
existing nav walker already renders dropdowns). Items the client added
|
||||||
|
by hand in the menu stay, and the regions are appended after them.
|
||||||
|
11. **Region landing page.** Hero (region name and term image), a grid of
|
||||||
|
its city cards (reuse `card-destination.php`), then the standard
|
||||||
|
listing of all tours in the region. **City landing page** = the
|
||||||
|
existing taxonomy listing template, unchanged apart from the URLs.
|
||||||
|
12. **SEO.** Canonical tags follow the canonical URLs above. All redirects
|
||||||
|
are 301.
|
||||||
|
|
||||||
|
## Validation (settings save)
|
||||||
|
|
||||||
|
The whole save is rejected, with the previous structure kept, and the admin
|
||||||
|
sees a list of every problem, if any of these is true:
|
||||||
|
|
||||||
|
- A city's Viator ID is not in Viator's destination list.
|
||||||
|
- The same city (Viator ID) appears twice, in one region or across regions.
|
||||||
|
- Two items (regions or cities) share a slug.
|
||||||
|
- A region has an empty name.
|
||||||
|
- A slug is empty or contains anything other than `a-z`, `0-9` and `-`.
|
||||||
|
- A slug collides with an existing page, post, or other public post-type
|
||||||
|
slug or archive (`tours`), or with a reserved word: WordPress reserved
|
||||||
|
query vars, `wp-admin`, `wp-content`, `wp-includes`, `feed`, `page`,
|
||||||
|
`search`, `tag`, `category`, `author`, `tour-destination`, `tour-tag`,
|
||||||
|
`tour-category`, `blog`, `about`, `contact`, `faq`.
|
||||||
|
|
||||||
|
The editor UI also prevents most of these before submit: cities are added
|
||||||
|
only from search results, and already-used cities are greyed out in the
|
||||||
|
results.
|
||||||
|
|
||||||
|
Also, when a Page is created or renamed to a slug a region or city already
|
||||||
|
uses, show an admin notice warning about the clash. (WordPress itself does
|
||||||
|
not block this.)
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- WordPress, Astra child theme `astra-child-hoponhopoff`, plugin
|
||||||
|
`viator-collector`. Match the existing plugin style (static classes,
|
||||||
|
`VC_` prefix, `vc_get_option`, `VC_Logger`).
|
||||||
|
- Viator Basic access: the catalogue is only enumerable per destination,
|
||||||
|
which suits per-city crawling.
|
||||||
|
- No changes to how products are fetched or priced.
|
||||||
|
|
||||||
|
## Left to the builder
|
||||||
|
|
||||||
|
- Settings editor UI details (drag-to-reorder or up/down buttons, etc.)
|
||||||
|
as long as the validation and pick-from-Viator rules hold.
|
||||||
|
- Whether the structure editor is a section of the existing settings page
|
||||||
|
or its own sub-page.
|
||||||
|
- Exact redirect hook (`template_redirect` is the expected place).
|
||||||
|
- What happens to a city removed from the structure: its term stays (it is
|
||||||
|
un-parented and loses its `/{slug}/` route, so it falls back to
|
||||||
|
`/tour-destination/{slug}/`). Products are not deleted.
|
||||||
@@ -156,10 +156,14 @@ class VC_Cron {
|
|||||||
|
|
||||||
$map = get_transient( 'vc_destination_map' );
|
$map = get_transient( 'vc_destination_map' );
|
||||||
|
|
||||||
// A destination allowlist (comma-separated IDs) narrows the crawl to a
|
// Cities configured on the Viator Structure screen define the crawl.
|
||||||
// focused set; empty means the whole catalogue.
|
// Without any, a destination allowlist (comma-separated IDs) narrows
|
||||||
$allow = array_filter( array_map( 'trim', explode( ',', (string) vc_get_option( 'crawl_destinations', '' ) ) ) );
|
// the crawl to a focused set; empty means the whole catalogue.
|
||||||
if ( ! empty( $allow ) ) {
|
$cities = array_map( 'strval', array_keys( VC_Structure::cities( VC_Structure::get() ) ) );
|
||||||
|
$allow = array_filter( array_map( 'trim', explode( ',', (string) vc_get_option( 'crawl_destinations', '' ) ) ) );
|
||||||
|
if ( ! empty( $cities ) ) {
|
||||||
|
$queue = $cities;
|
||||||
|
} elseif ( ! empty( $allow ) ) {
|
||||||
$queue = array_values( array_unique( $allow ) );
|
$queue = array_values( array_unique( $allow ) );
|
||||||
} else {
|
} else {
|
||||||
$queue = is_array( $map ) ? array_keys( $map ) : array();
|
$queue = is_array( $map ) ? array_keys( $map ) : array();
|
||||||
@@ -191,7 +195,11 @@ class VC_Cron {
|
|||||||
);
|
);
|
||||||
update_option( 'vc_ingest_in_progress', 1, false );
|
update_option( 'vc_ingest_in_progress', 1, false );
|
||||||
update_option( 'vc_last_sync_started_at', current_time( 'mysql' ), false );
|
update_option( 'vc_last_sync_started_at', current_time( 'mysql' ), false );
|
||||||
VC_Logger::info( sprintf( 'Sync run started — %d destinations queued.', count( $queue ) ) );
|
if ( ! empty( $cities ) ) {
|
||||||
|
VC_Logger::info( sprintf( 'Sync run started from structure — %d cities queued.', count( $queue ) ) );
|
||||||
|
} else {
|
||||||
|
VC_Logger::info( sprintf( 'Sync run started — %d destinations queued.', count( $queue ) ) );
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class VC_Display {
|
|||||||
'viator_top_destinations',
|
'viator_top_destinations',
|
||||||
'viator_top_attractions',
|
'viator_top_attractions',
|
||||||
'viator_browse_activities',
|
'viator_browse_activities',
|
||||||
|
'viator_bus_tours',
|
||||||
|
'viator_about',
|
||||||
|
'viator_articles',
|
||||||
'viator_faq',
|
'viator_faq',
|
||||||
'viator_testimonials',
|
'viator_testimonials',
|
||||||
'viator_tour_card',
|
'viator_tour_card',
|
||||||
@@ -24,7 +27,12 @@ class VC_Display {
|
|||||||
);
|
);
|
||||||
|
|
||||||
public static function init() {
|
public static function init() {
|
||||||
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
|
// Priority 100 so the theme (Astra + child) has already enqueued ALL of
|
||||||
|
// its stylesheets — including the parent style.css and the child's chrome
|
||||||
|
// CSS, which are enqueued by the theme at priority 20 and would otherwise
|
||||||
|
// print AFTER us (plugins run before themes at equal priority) and win
|
||||||
|
// equal-specificity ties. Loading last makes our design win those ties.
|
||||||
|
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ), 100 );
|
||||||
add_action( 'template_redirect', array( __CLASS__, 'maybe_enrich_single' ) );
|
add_action( 'template_redirect', array( __CLASS__, 'maybe_enrich_single' ) );
|
||||||
add_action( 'pre_get_posts', array( __CLASS__, 'filter_archive_query' ) );
|
add_action( 'pre_get_posts', array( __CLASS__, 'filter_archive_query' ) );
|
||||||
add_filter( 'single_template', array( __CLASS__, 'single_template' ) );
|
add_filter( 'single_template', array( __CLASS__, 'single_template' ) );
|
||||||
@@ -62,9 +70,18 @@ class VC_Display {
|
|||||||
|| is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) {
|
|| is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// A Page hosting the full-listing shortcode also wants full width.
|
// Any Page hosting a Viator shortcode (hero, sections, listing, …) also
|
||||||
|
// wants the full-width, no-sidebar canvas.
|
||||||
$post = ( is_singular() ) ? get_post() : null;
|
$post = ( is_singular() ) ? get_post() : null;
|
||||||
return $post && has_shortcode( $post->post_content, 'viator_listing' );
|
if ( ! $post ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach ( self::SHORTCODES as $shortcode ) {
|
||||||
|
if ( has_shortcode( $post->post_content, $shortcode ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,9 +151,19 @@ class VC_Display {
|
|||||||
// Design typeface.
|
// 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 );
|
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
|
// One scoped stylesheet (.vc-app) covers listing, single, and homepage
|
||||||
// sections — ported from the "Hop On Hop Off" design.
|
// sections — ported from the "Hop On Hop Off" design. Declare every theme
|
||||||
wp_enqueue_style( 'vc-design', VC_PLUGIN_URL . 'assets/css/vc-design.css', array( 'vc-fonts' ), VC_VERSION );
|
// stylesheet that could fight ours as a dependency (when present) so our
|
||||||
|
// rules always print AFTER them and win equal-specificity ties. We enqueue
|
||||||
|
// at priority 100, so by now the theme (priority 20) has registered them.
|
||||||
|
$deps = array( 'vc-fonts' );
|
||||||
|
foreach ( array( 'astra-theme-css', 'astra-parent', 'hoho-chrome' ) as $handle ) {
|
||||||
|
if ( wp_style_is( $handle, 'registered' ) ) {
|
||||||
|
$deps[] = $handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wp_enqueue_style( 'vc-design', VC_PLUGIN_URL . 'assets/css/vc-design.css', $deps, VC_VERSION );
|
||||||
|
wp_add_inline_style( 'vc-design', self::astra_overrides() );
|
||||||
|
|
||||||
// Front-end interactions: card rails, FAQ accordion, testimonials
|
// Front-end interactions: card rails, FAQ accordion, testimonials
|
||||||
// marquee, date field, and auto-submit on quick filter toggles.
|
// marquee, date field, and auto-submit on quick filter toggles.
|
||||||
@@ -146,6 +173,111 @@ class VC_Display {
|
|||||||
add_action( 'wp_footer', 'vc_print_icon_sprite' );
|
add_action( 'wp_footer', 'vc_print_icon_sprite' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS that neutralises Astra's content spacing/width on Viator pages and
|
||||||
|
* makes the design typography win over the theme. Injected after vc-design.css.
|
||||||
|
*/
|
||||||
|
private static function astra_overrides() {
|
||||||
|
return implode(
|
||||||
|
'',
|
||||||
|
array(
|
||||||
|
// Full-bleed: strip Astra's page-builder article padding/width so
|
||||||
|
// the hero and sections reach the viewport edges. !important beats
|
||||||
|
// Astra's own high-specificity rules (e.g. the 2em article padding
|
||||||
|
// that leaves a gap above the hero).
|
||||||
|
'.vc-fullwidth .site-main>article,'
|
||||||
|
. '.vc-fullwidth .ast-page-builder-template .site-main>article,'
|
||||||
|
. '.vc-fullwidth.ast-page-builder-template .site-main>article'
|
||||||
|
. '{padding:0!important;margin:0!important;border:0!important;max-width:none!important;}',
|
||||||
|
'.vc-fullwidth #primary,.vc-fullwidth #content{margin:0!important;padding:0!important;}',
|
||||||
|
// display:block undoes Astra's `.site-content .ast-container{display:flex}`
|
||||||
|
// (≥922px), which otherwise sizes our content to its contents instead
|
||||||
|
// of filling the full-width container.
|
||||||
|
'.vc-fullwidth .site-content .ast-container,.vc-fullwidth .ast-container'
|
||||||
|
. '{max-width:none!important;padding-left:0!important;padding-right:0!important;display:block!important;}',
|
||||||
|
'.vc-fullwidth .entry-content{margin-block:0!important;}',
|
||||||
|
'.vc-fullwidth .site-content{padding:0!important;}',
|
||||||
|
// Guard against a stray horizontal scrollbar from any full-bleed child.
|
||||||
|
'body.vc-fullwidth{overflow-x:hidden;}',
|
||||||
|
// A gradient stands in when no hero image is set, so the banner is
|
||||||
|
// never blank.
|
||||||
|
'.vc-app.vc-app .hero__media,.vc-app.vc-app .hero-tour{background:linear-gradient(120deg,#101317,#2cafc2);}',
|
||||||
|
// Normalize: stop Astra from decorating our components. Its
|
||||||
|
// .entry-content rules (underlined links, list bullets/indent)
|
||||||
|
// out-specify our scoped base, so reset them here (!important beats
|
||||||
|
// e.g. `.ast-single-post .entry-content a`). Every selector matches
|
||||||
|
// the doubled `.vc-app.vc-app` scope of the main stylesheet so these
|
||||||
|
// patches keep winning against it, not just against the theme.
|
||||||
|
'.vc-app.vc-app a{text-decoration:none;box-shadow:none;}',
|
||||||
|
// No !important on margin/padding: the doubled .vc-app.vc-app scope
|
||||||
|
// already out-specifies Astra's list rules, and !important here was
|
||||||
|
// steamrolling design lists that need their own spacing — e.g. the
|
||||||
|
// rail (<ul class="rail">) whose vertical padding gives the hover glow
|
||||||
|
// room. list-style stays !important (purely cosmetic; prose lists
|
||||||
|
// re-enable bullets via a higher-specificity rule).
|
||||||
|
'.vc-app.vc-app :is(ul,ol){list-style:none!important;margin:0;padding:0;}',
|
||||||
|
'.vc-app.vc-app li{margin:0;}',
|
||||||
|
// …but keep the tour "Overview" prose (the_content) readable.
|
||||||
|
'.vc-app.vc-app .tour__text a{text-decoration:underline;}',
|
||||||
|
'.vc-app.vc-app .tour__text ul{list-style:disc!important;margin:1em 0!important;padding-inline-start:1.5em!important;}',
|
||||||
|
'.vc-app.vc-app .tour__text ol{list-style:decimal!important;margin:1em 0!important;padding-inline-start:1.5em!important;}',
|
||||||
|
'.vc-app.vc-app .tour__text li{margin:.25em 0!important;}',
|
||||||
|
// Links must inherit the design colour (Astra colours links with its
|
||||||
|
// theme colour, which turned white card titles/destination names into
|
||||||
|
// coloured text). Buttons keep their own colour.
|
||||||
|
// …but not ring-badges: they set their own colour on the <a>, and this
|
||||||
|
// inherit rule out-specifies .ring-badge--top/--scroll, which turned
|
||||||
|
// the badge text white (inherited) on light backgrounds.
|
||||||
|
'.vc-app.vc-app a:not(.btn):not(.ring-badge){color:inherit;}',
|
||||||
|
'.vc-app.vc-app .tour__text a{color:var(--c-teal);}',
|
||||||
|
// Real tour titles are far longer than the mockup\'s city names — clamp
|
||||||
|
// them and stop price/duration values wrapping mid-number.
|
||||||
|
'.vc-app.vc-app .dcard__title{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}',
|
||||||
|
'.vc-app.vc-app .titem__desc{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}',
|
||||||
|
// Real tour descriptions run long; the on-hover card blurb clamps to
|
||||||
|
// three lines with an ellipsis instead of spilling over the card.
|
||||||
|
'.vc-app.vc-app .dcard__desc p{display:-webkit-box;-webkit-line-clamp:3;line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}',
|
||||||
|
'.vc-app.vc-app .dcard__meta .meta__v{white-space:nowrap;}',
|
||||||
|
// Astra's page-builder layout centres/limits every entry-content child
|
||||||
|
// (max-width:none; margin:auto), which flattened the hero inset. Our
|
||||||
|
// sections are full-bleed, so re-assert 0 side-margins; !important
|
||||||
|
// beats Astra's 4-class selector. The hero keeps its small inset — the
|
||||||
|
// only section with an intentional side margin.
|
||||||
|
'.vc-app.vc-app>*{max-width:none!important;margin-inline:0!important;}',
|
||||||
|
// Heroes keep their small inset (rounded card look) — .hero on the
|
||||||
|
// home/archive pages and .hero-tour on the single-tour page. Without
|
||||||
|
// this they went full-bleed after the margin-inline:0 reset above.
|
||||||
|
'.vc-app.vc-app>.hero,.vc-app.vc-app>.hero-tour{margin:var(--page-inset)!important;}',
|
||||||
|
// Let vertical decorations (rotating badges, card glow, promo bus, FAQ
|
||||||
|
// watermark) escape, but keep the horizontal axis clipped so a
|
||||||
|
// full-bleed section or decorative element can't cause a mobile
|
||||||
|
// sideways scroll. overflow-x:clip (unlike hidden) does NOT force
|
||||||
|
// overflow-y to auto, so the y-axis stays truly visible.
|
||||||
|
'.vc-app.vc-app{overflow-x:clip;overflow-y:visible;}',
|
||||||
|
// Astra wraps the content in article/.entry-content/.ast-container,
|
||||||
|
// some of which clip overflow — that cut the card hover glow at the
|
||||||
|
// rail edges. Open them up on our pages only (page scroll stays
|
||||||
|
// contained by body.vc-fullwidth{overflow-x:hidden}).
|
||||||
|
'.vc-fullwidth .site-main,.vc-fullwidth #primary,.vc-fullwidth #content,'
|
||||||
|
. '.vc-fullwidth .site-content,.vc-fullwidth .ast-container,'
|
||||||
|
. '.vc-fullwidth .entry-content,.vc-fullwidth .site-main>article'
|
||||||
|
. '{overflow:visible!important;}',
|
||||||
|
// FAQ question buttons must stay flat — Astra gives <button> a shadow.
|
||||||
|
'.vc-app.vc-app .acc__q{box-shadow:none!important;}',
|
||||||
|
// Astra colours every heading on the element itself (h1–h6 and
|
||||||
|
// .entry-content :where(h1..h6)), beating our container-inherited
|
||||||
|
// colours. Make headings inherit again — no !important, so design
|
||||||
|
// classes (.hero__title, the white .tcard titles) keep their own.
|
||||||
|
'.vc-app.vc-app :is(h1,h2,h3,h4,h5,h6){color:inherit;}',
|
||||||
|
// Typography: force the design font over Astra inside our scopes.
|
||||||
|
'.vc-app.vc-app,.vc-app.vc-app h1,.vc-app.vc-app h2,.vc-app.vc-app h3,.vc-app.vc-app h4,.vc-app.vc-app h5,.vc-app.vc-app h6,'
|
||||||
|
. '.vc-app.vc-app p,.vc-app.vc-app a,.vc-app.vc-app span,.vc-app.vc-app li,.vc-app.vc-app button,.vc-app.vc-app input,'
|
||||||
|
. '.vc-app.vc-app select,.vc-app.vc-app textarea,.vc-app.vc-app blockquote'
|
||||||
|
. '{font-family:"Plus Jakarta Sans",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read + sanitize the current filter state from the query string. Shared by
|
* Read + sanitize the current filter state from the query string. Shared by
|
||||||
* the query builder and the archive template so both stay in sync.
|
* the query builder and the archive template so both stay in sync.
|
||||||
@@ -214,7 +346,7 @@ class VC_Display {
|
|||||||
*/
|
*/
|
||||||
public static function build_query_vars( array $base_tax = array() ) {
|
public static function build_query_vars( array $base_tax = array() ) {
|
||||||
$f = self::get_filter_state();
|
$f = self::get_filter_state();
|
||||||
$vars = array( 'posts_per_page' => 12 );
|
$vars = array( 'posts_per_page' => 12, 'ignore_sticky_posts' => true );
|
||||||
|
|
||||||
if ( '' !== $f['q'] ) {
|
if ( '' !== $f['q'] ) {
|
||||||
$vars['s'] = $f['q'];
|
$vars['s'] = $f['q'];
|
||||||
@@ -271,46 +403,39 @@ class VC_Display {
|
|||||||
if ( $f['sellout'] ) {
|
if ( $f['sellout'] ) {
|
||||||
$meta_query[] = array( 'key' => '_vc_flags', 'value' => 'LIKELY_TO_SELL_OUT', 'compare' => 'LIKE' );
|
$meta_query[] = array( 'key' => '_vc_flags', 'value' => 'LIKELY_TO_SELL_OUT', 'compare' => 'LIKE' );
|
||||||
}
|
}
|
||||||
// Featured tours always sort first, whatever the chosen sort. OR/NOT
|
// Sort. Performance: the default (newest) orders by post date only — no
|
||||||
// EXISTS keeps non-featured tours in the results too.
|
// postmeta JOIN and no featured/review tiebreak — so the unfiltered
|
||||||
$meta_query['vc_featured'] = array(
|
// archive stays fast even with a very large catalogue (tens of thousands
|
||||||
'relation' => 'OR',
|
// of tours). Price/rating sorts add a single meta_key JOIN, on demand.
|
||||||
array( 'key' => '_vc_featured', 'value' => '1', 'compare' => '=' ),
|
|
||||||
array( 'key' => '_vc_featured', 'compare' => 'NOT EXISTS' ),
|
|
||||||
);
|
|
||||||
|
|
||||||
$orderby = array( 'vc_featured' => 'DESC' );
|
|
||||||
|
|
||||||
switch ( $f['sort'] ) {
|
switch ( $f['sort'] ) {
|
||||||
case 'price_asc':
|
case 'price_asc':
|
||||||
$meta_query['vc_price'] = array( 'key' => '_vc_price_from', 'type' => 'DECIMAL', 'compare' => 'EXISTS' );
|
$vars['meta_key'] = '_vc_price_from';
|
||||||
$orderby['vc_price'] = 'ASC';
|
$vars['orderby'] = 'meta_value_num';
|
||||||
|
$vars['order'] = 'ASC';
|
||||||
break;
|
break;
|
||||||
case 'price_desc':
|
case 'price_desc':
|
||||||
$meta_query['vc_price'] = array( 'key' => '_vc_price_from', 'type' => 'DECIMAL', 'compare' => 'EXISTS' );
|
$vars['meta_key'] = '_vc_price_from';
|
||||||
$orderby['vc_price'] = 'DESC';
|
$vars['orderby'] = 'meta_value_num';
|
||||||
|
$vars['order'] = 'DESC';
|
||||||
break;
|
break;
|
||||||
case 'rating':
|
case 'rating':
|
||||||
$meta_query['vc_rating_sort'] = array( 'key' => '_vc_rating', 'type' => 'DECIMAL', 'compare' => 'EXISTS' );
|
$vars['meta_key'] = '_vc_rating';
|
||||||
$orderby['vc_rating_sort'] = 'DESC';
|
$vars['orderby'] = 'meta_value_num';
|
||||||
|
$vars['order'] = 'DESC';
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
$vars['orderby'] = 'date';
|
||||||
|
$vars['order'] = 'DESC';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the selected sort as the primary order, then prefer the more
|
// Only attach a meta_query when a filter is actually active, so the plain
|
||||||
// established tour when two products otherwise rank the same.
|
// archive query carries no postmeta joins at all.
|
||||||
$meta_query['vc_reviews_sort'] = array(
|
if ( ! empty( $meta_query ) ) {
|
||||||
'key' => '_vc_review_count',
|
if ( count( $meta_query ) > 1 ) {
|
||||||
'type' => 'NUMERIC',
|
$meta_query['relation'] = 'AND';
|
||||||
'compare' => 'EXISTS',
|
}
|
||||||
);
|
$vars['meta_query'] = $meta_query;
|
||||||
$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;
|
return $vars;
|
||||||
}
|
}
|
||||||
@@ -407,6 +532,10 @@ class VC_Display {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function taxonomy_template( $template ) {
|
public static function taxonomy_template( $template ) {
|
||||||
|
if ( is_tax( 'viator_destination' ) && get_term_meta( get_queried_object_id(), '_vc_region', true ) ) {
|
||||||
|
$path = get_stylesheet_directory() . '/viator-collector/region.php';
|
||||||
|
return file_exists( $path ) ? $path : VC_PLUGIN_DIR . 'templates/region.php';
|
||||||
|
}
|
||||||
if ( is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) {
|
if ( is_tax( array( 'viator_destination', 'viator_tag', 'viator_category' ) ) ) {
|
||||||
$path = get_stylesheet_directory() . '/viator-collector/taxonomy-viator.php';
|
$path = get_stylesheet_directory() . '/viator-collector/taxonomy-viator.php';
|
||||||
if ( file_exists( $path ) ) {
|
if ( file_exists( $path ) ) {
|
||||||
|
|||||||
@@ -368,6 +368,7 @@ class VC_Importer {
|
|||||||
'_vc_cancellation_policy' => wp_json_encode( vc_first_non_empty( array( vc_array_get( $product, 'cancellationPolicy' ), vc_array_get( $product, 'cancellation' ) ), 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_supplier' => sanitize_text_field( $supplier ),
|
||||||
'_vc_destinations' => wp_json_encode( vc_array_get( $product, 'destinations', array() ) ),
|
'_vc_destinations' => wp_json_encode( vc_array_get( $product, 'destinations', array() ) ),
|
||||||
|
'_vc_primary_destination' => VC_Structure::primary_destination_id( (array) vc_array_get( $product, 'destinations', array() ) ),
|
||||||
'_vc_tags' => wp_json_encode( vc_array_get( $product, 'tags', array() ) ),
|
'_vc_tags' => wp_json_encode( vc_array_get( $product, 'tags', array() ) ),
|
||||||
'_vc_rating' => sanitize_text_field( (string) $rating ),
|
'_vc_rating' => sanitize_text_field( (string) $rating ),
|
||||||
'_vc_review_count' => absint( $review_count ),
|
'_vc_review_count' => absint( $review_count ),
|
||||||
|
|||||||
88
viator-collector/includes/class-vc-menu.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the Viator Structure regions (each with a dropdown of its cities)
|
||||||
|
* to the theme's header menu, so the menu follows the structure without
|
||||||
|
* hand-editing Appearance → Menus.
|
||||||
|
*/
|
||||||
|
class VC_Menu {
|
||||||
|
const LOCATION = 'hoho_primary';
|
||||||
|
|
||||||
|
public static function init() {
|
||||||
|
add_filter( 'wp_nav_menu_objects', array( __CLASS__, 'append' ), 10, 2 );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function append( $items, $args ) {
|
||||||
|
if ( empty( $args->theme_location ) || self::LOCATION !== $args->theme_location ) {
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
$order = count( $items );
|
||||||
|
$extra = self::items();
|
||||||
|
foreach ( $extra as $item ) {
|
||||||
|
$item->menu_order = ++$order;
|
||||||
|
}
|
||||||
|
return array_merge( $items, $extra );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu-item objects for the walker. The theme walker turns a parent with
|
||||||
|
* children into a toggle button (no link), so a region's dropdown starts
|
||||||
|
* with an "All of {region}" link to the region page itself.
|
||||||
|
*
|
||||||
|
* @return object[]
|
||||||
|
*/
|
||||||
|
public static function items() {
|
||||||
|
$items = array();
|
||||||
|
$n = 0;
|
||||||
|
foreach ( VC_Structure::get() as $region ) {
|
||||||
|
$term = $region['term_id'] ? get_term( (int) $region['term_id'], 'viator_destination' ) : null;
|
||||||
|
if ( ! $term instanceof WP_Term ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$region_url = get_term_link( $term );
|
||||||
|
if ( is_wp_error( $region_url ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parent = self::item( ++$n, 0, $term->name, $region_url );
|
||||||
|
$items[] = $parent;
|
||||||
|
if ( ! $region['cities'] ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
/* translators: %s: region name. */
|
||||||
|
$items[] = self::item( ++$n, $parent->db_id, sprintf( __( 'All of %s', 'viator-collector' ), $term->name ), $region_url );
|
||||||
|
foreach ( $region['cities'] as $city ) {
|
||||||
|
$items[] = self::item( ++$n, $parent->db_id, $city['name'], home_url( '/' . $city['slug'] . '/' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function item( $n, $parent_id, $title, $url ) {
|
||||||
|
$id = -1000 - $n;
|
||||||
|
$item = new stdClass();
|
||||||
|
|
||||||
|
$item->ID = $id;
|
||||||
|
$item->db_id = $id;
|
||||||
|
$item->menu_item_parent = (string) $parent_id;
|
||||||
|
$item->object_id = (string) $id;
|
||||||
|
$item->object = 'custom';
|
||||||
|
$item->type = 'custom';
|
||||||
|
$item->type_label = '';
|
||||||
|
$item->title = $title;
|
||||||
|
$item->url = $url;
|
||||||
|
$item->target = '';
|
||||||
|
$item->attr_title = '';
|
||||||
|
$item->description = '';
|
||||||
|
$item->classes = array( 'menu-item', 'vc-structure-item' );
|
||||||
|
$item->xfn = '';
|
||||||
|
$item->status = 'publish';
|
||||||
|
$item->post_parent = 0;
|
||||||
|
$item->menu_order = $n;
|
||||||
|
$item->current = false;
|
||||||
|
$item->current_item_ancestor = false;
|
||||||
|
$item->current_item_parent = false;
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ final class VC_Plugin {
|
|||||||
VC_CPT::init();
|
VC_CPT::init();
|
||||||
VC_Taxonomies::init();
|
VC_Taxonomies::init();
|
||||||
VC_Settings::init();
|
VC_Settings::init();
|
||||||
|
VC_Structure_Admin::init();
|
||||||
|
VC_Routing::init();
|
||||||
|
VC_Menu::init();
|
||||||
VC_Cron::init();
|
VC_Cron::init();
|
||||||
VC_Display::init();
|
VC_Display::init();
|
||||||
VC_Sections::init();
|
VC_Sections::init();
|
||||||
@@ -30,10 +33,14 @@ final class VC_Plugin {
|
|||||||
'includes/class-vc-logger.php',
|
'includes/class-vc-logger.php',
|
||||||
'includes/class-vc-cpt.php',
|
'includes/class-vc-cpt.php',
|
||||||
'includes/class-vc-taxonomies.php',
|
'includes/class-vc-taxonomies.php',
|
||||||
|
'includes/class-vc-structure.php',
|
||||||
'includes/class-vc-api.php',
|
'includes/class-vc-api.php',
|
||||||
'includes/class-vc-importer.php',
|
'includes/class-vc-importer.php',
|
||||||
'includes/class-vc-cron.php',
|
'includes/class-vc-cron.php',
|
||||||
'includes/class-vc-settings.php',
|
'includes/class-vc-settings.php',
|
||||||
|
'includes/class-vc-structure-admin.php',
|
||||||
|
'includes/class-vc-routing.php',
|
||||||
|
'includes/class-vc-menu.php',
|
||||||
'includes/class-vc-display.php',
|
'includes/class-vc-display.php',
|
||||||
'includes/class-vc-sections.php',
|
'includes/class-vc-sections.php',
|
||||||
);
|
);
|
||||||
@@ -53,6 +60,9 @@ final class VC_Plugin {
|
|||||||
VC_Taxonomies::register();
|
VC_Taxonomies::register();
|
||||||
VC_Cron::maybe_schedule();
|
VC_Cron::maybe_schedule();
|
||||||
flush_rewrite_rules();
|
flush_rewrite_rules();
|
||||||
|
// Structure routes are added on init, which has not run for this
|
||||||
|
// request; flush again on the next one so they are included.
|
||||||
|
update_option( 'vc_flush_rewrite', 1 );
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function deactivate() {
|
public static function deactivate() {
|
||||||
|
|||||||
147
viator-collector/includes/class-vc-routing.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place-based URLs from the Viator Structure: /{region}/, /{city}/ and
|
||||||
|
* /{city}/{tour}/. Old /tour-destination/… and /tours/… addresses 301 to
|
||||||
|
* the new ones, and a tour opened under the wrong city 301s to its one
|
||||||
|
* canonical city.
|
||||||
|
*/
|
||||||
|
class VC_Routing {
|
||||||
|
private static $product_city = array();
|
||||||
|
|
||||||
|
public static function init() {
|
||||||
|
// After the CPT/taxonomies register (default priority 10).
|
||||||
|
add_action( 'init', array( __CLASS__, 'add_rules' ), 20 );
|
||||||
|
add_filter( 'term_link', array( __CLASS__, 'term_link' ), 10, 3 );
|
||||||
|
add_filter( 'post_type_link', array( __CLASS__, 'post_type_link' ), 10, 2 );
|
||||||
|
// Ahead of core's redirect_canonical (priority 10).
|
||||||
|
add_action( 'template_redirect', array( __CLASS__, 'redirect' ), 5 );
|
||||||
|
add_action( 'wp_head', array( __CLASS__, 'term_canonical' ), 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add_rules() {
|
||||||
|
foreach ( VC_Structure::rewrite_rules( VC_Structure::get() ) as $regex => $query ) {
|
||||||
|
add_rewrite_rule( $regex, $query, 'top' );
|
||||||
|
}
|
||||||
|
if ( get_option( 'vc_flush_rewrite' ) ) {
|
||||||
|
delete_option( 'vc_flush_rewrite' );
|
||||||
|
flush_rewrite_rules( false );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function is_configured_term( $term ) {
|
||||||
|
if ( ! $term instanceof WP_Term || 'viator_destination' !== $term->taxonomy ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ( get_term_meta( $term->term_id, '_vc_region', true ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$viator_id = (string) get_term_meta( $term->term_id, '_vc_viator_destination_id', true );
|
||||||
|
if ( '' === $viator_id ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$cities = VC_Structure::cities( VC_Structure::get() );
|
||||||
|
return isset( $cities[ $viator_id ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug of the city a tour's URL lives under, or '' (it stays at /tours/).
|
||||||
|
*/
|
||||||
|
public static function product_city( $post_id ) {
|
||||||
|
$post_id = (int) $post_id;
|
||||||
|
if ( isset( self::$product_city[ $post_id ] ) ) {
|
||||||
|
return self::$product_city[ $post_id ];
|
||||||
|
}
|
||||||
|
$dest_ids = array();
|
||||||
|
$destinations = json_decode( (string) get_post_meta( $post_id, '_vc_destinations', true ), true );
|
||||||
|
foreach ( is_array( $destinations ) ? $destinations : array() as $destination ) {
|
||||||
|
$ref = is_array( $destination ) ? ( $destination['ref'] ?? ( $destination['destinationId'] ?? '' ) ) : $destination;
|
||||||
|
if ( '' !== (string) $ref ) {
|
||||||
|
$dest_ids[] = (string) $ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$primary = (string) get_post_meta( $post_id, '_vc_primary_destination', true );
|
||||||
|
|
||||||
|
self::$product_city[ $post_id ] = VC_Structure::canonical_city( VC_Structure::get(), $dest_ids, $primary );
|
||||||
|
return self::$product_city[ $post_id ];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function term_link( $url, $term, $taxonomy ) {
|
||||||
|
if ( 'viator_destination' === $taxonomy && self::is_configured_term( $term ) ) {
|
||||||
|
return home_url( '/' . $term->slug . '/' );
|
||||||
|
}
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function post_type_link( $url, $post ) {
|
||||||
|
if ( 'viator_tour' !== $post->post_type || '' === $post->post_name ) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
$city = self::product_city( $post->ID );
|
||||||
|
if ( '' === $city ) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
return home_url( '/' . $city . '/' . $post->post_name . '/' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function redirect() {
|
||||||
|
if ( is_preview() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$canonical = '';
|
||||||
|
if ( is_singular( 'viator_tour' ) ) {
|
||||||
|
$post = get_queried_object();
|
||||||
|
if ( $post && 'publish' === $post->post_status ) {
|
||||||
|
$canonical = get_permalink( $post );
|
||||||
|
}
|
||||||
|
} elseif ( is_tax( 'viator_destination' ) ) {
|
||||||
|
$term = get_queried_object();
|
||||||
|
if ( self::is_configured_term( $term ) ) {
|
||||||
|
$canonical = get_term_link( $term );
|
||||||
|
// Keep the page: /tour-destination/x/page/2/ and /x/?paged=2
|
||||||
|
// both land on /x/page/2/ (the form core's redirect_canonical
|
||||||
|
// would produce anyway).
|
||||||
|
$paged = (int) get_query_var( 'paged' );
|
||||||
|
if ( $paged > 1 && ! is_wp_error( $canonical ) ) {
|
||||||
|
$canonical = trailingslashit( $canonical ) . 'page/' . $paged . '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! $canonical || is_wp_error( $canonical ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||||
|
$parts = explode( '?', $request, 2 );
|
||||||
|
$current = untrailingslashit( rawurldecode( $parts[0] ) );
|
||||||
|
$target = untrailingslashit( rawurldecode( (string) wp_parse_url( $canonical, PHP_URL_PATH ) ) );
|
||||||
|
if ( $current === $target ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( isset( $parts[1] ) && '' !== $parts[1] ) {
|
||||||
|
$canonical .= '?' . $parts[1];
|
||||||
|
$canonical = remove_query_arg( 'paged', $canonical );
|
||||||
|
}
|
||||||
|
wp_safe_redirect( $canonical, 301 );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core prints rel=canonical for single tours only; add it for region and
|
||||||
|
* city archives unless an SEO plugin already handles canonicals.
|
||||||
|
*/
|
||||||
|
public static function term_canonical() {
|
||||||
|
if ( defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || ! is_tax( 'viator_destination' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$term = get_queried_object();
|
||||||
|
if ( ! self::is_configured_term( $term ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$link = get_term_link( $term );
|
||||||
|
if ( ! is_wp_error( $link ) ) {
|
||||||
|
echo '<link rel="canonical" href="' . esc_url( $link ) . '">' . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ class VC_Sections {
|
|||||||
const TOP_ATTRS = 'viator_top_attractions';
|
const TOP_ATTRS = 'viator_top_attractions';
|
||||||
const ACTIVITIES = 'viator_browse_activities';
|
const ACTIVITIES = 'viator_browse_activities';
|
||||||
const TOP_TOURS = 'viator_top_tours';
|
const TOP_TOURS = 'viator_top_tours';
|
||||||
|
const BUS_TOURS = 'viator_bus_tours';
|
||||||
|
const ABOUT = 'viator_about';
|
||||||
|
const ARTICLES = 'viator_articles';
|
||||||
const FAQ = 'viator_faq';
|
const FAQ = 'viator_faq';
|
||||||
const REVIEWS = 'viator_testimonials';
|
const REVIEWS = 'viator_testimonials';
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@ class VC_Sections {
|
|||||||
add_shortcode( self::TOP_ATTRS, array( __CLASS__, 'top_attractions' ) );
|
add_shortcode( self::TOP_ATTRS, array( __CLASS__, 'top_attractions' ) );
|
||||||
add_shortcode( self::ACTIVITIES, array( __CLASS__, 'browse_activities' ) );
|
add_shortcode( self::ACTIVITIES, array( __CLASS__, 'browse_activities' ) );
|
||||||
add_shortcode( self::TOP_TOURS, array( __CLASS__, 'top_tours' ) );
|
add_shortcode( self::TOP_TOURS, array( __CLASS__, 'top_tours' ) );
|
||||||
|
add_shortcode( self::BUS_TOURS, array( __CLASS__, 'bus_tours' ) );
|
||||||
|
add_shortcode( self::ABOUT, array( __CLASS__, 'about' ) );
|
||||||
|
add_shortcode( self::ARTICLES, array( __CLASS__, 'articles' ) );
|
||||||
add_shortcode( self::FAQ, array( __CLASS__, 'faq' ) );
|
add_shortcode( self::FAQ, array( __CLASS__, 'faq' ) );
|
||||||
add_shortcode( self::REVIEWS, array( __CLASS__, 'testimonials' ) );
|
add_shortcode( self::REVIEWS, array( __CLASS__, 'testimonials' ) );
|
||||||
}
|
}
|
||||||
@@ -41,17 +47,24 @@ class VC_Sections {
|
|||||||
public static function hero( $atts ) {
|
public static function hero( $atts ) {
|
||||||
$atts = shortcode_atts(
|
$atts = shortcode_atts(
|
||||||
array(
|
array(
|
||||||
'title' => __( 'Set out on the journey.', 'viator-collector' ),
|
'title' => __( 'Set out on the journey.', 'viator-collector' ),
|
||||||
'eyebrow' => __( 'Tour & Travel', 'viator-collector' ),
|
'eyebrow' => __( 'Tour & Travel', 'viator-collector' ),
|
||||||
'image' => '',
|
'image' => '',
|
||||||
|
'image_id' => '', // WordPress media attachment ID (responsive).
|
||||||
),
|
),
|
||||||
$atts,
|
$atts,
|
||||||
self::HERO
|
self::HERO
|
||||||
);
|
);
|
||||||
|
|
||||||
$img = $atts['image'] ? $atts['image'] : vc_hero_image_url();
|
// Prefer a media-library image (responsive srcset): shortcode image_id,
|
||||||
if ( ! $img ) {
|
// then a shortcode URL, then the plugin's Hero image setting. No bundled
|
||||||
$img = vc_asset_url( 'img/hero.png' );
|
// fallback — an empty source shows the CSS gradient.
|
||||||
|
if ( '' !== $atts['image_id'] ) {
|
||||||
|
$img = absint( $atts['image_id'] );
|
||||||
|
} elseif ( '' !== $atts['image'] ) {
|
||||||
|
$img = $atts['image'];
|
||||||
|
} else {
|
||||||
|
$img = vc_hero_image_source();
|
||||||
}
|
}
|
||||||
$archive = get_post_type_archive_link( 'viator_tour' );
|
$archive = get_post_type_archive_link( 'viator_tour' );
|
||||||
$archive = $archive ? $archive : home_url( '/' );
|
$archive = $archive ? $archive : home_url( '/' );
|
||||||
@@ -64,7 +77,7 @@ class VC_Sections {
|
|||||||
<div class="vc-app vc-sections">
|
<div class="vc-app vc-sections">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="hero__media">
|
<div class="hero__media">
|
||||||
<img class="hero__img" src="<?php echo esc_url( $img ); ?>" alt="" fetchpriority="high">
|
<?php echo vc_hero_img_html( $img, 'hero__img', true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||||
<div class="hero__scrim" aria-hidden="true"></div>
|
<div class="hero__scrim" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container hero__content">
|
<div class="container hero__content">
|
||||||
@@ -123,7 +136,7 @@ class VC_Sections {
|
|||||||
* Open a titled rail section and return its unique rail id. Caller fills the
|
* Open a titled rail section and return its unique rail id. Caller fills the
|
||||||
* <ul> with cards and then calls close_rail().
|
* <ul> with cards and then calls close_rail().
|
||||||
*/
|
*/
|
||||||
private static function open_rail( $title, $lead, $view_all_url, $view_all_text ) {
|
private static function open_rail( $title, $lead, $view_all_url, $view_all_text, $rail_class = 'rail--dest' ) {
|
||||||
$id = 'vcRail' . ( ++self::$rail_seq );
|
$id = 'vcRail' . ( ++self::$rail_seq );
|
||||||
ob_start();
|
ob_start();
|
||||||
?>
|
?>
|
||||||
@@ -143,7 +156,7 @@ class VC_Sections {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="rail rail--dest" id="<?php echo esc_attr( $id ); ?>">
|
<ul class="rail <?php echo esc_attr( $rail_class ); ?>" id="<?php echo esc_attr( $id ); ?>">
|
||||||
<?php
|
<?php
|
||||||
return array( 'id' => $id, 'head' => ob_get_clean() );
|
return array( 'id' => $id, 'head' => ob_get_clean() );
|
||||||
}
|
}
|
||||||
@@ -396,15 +409,15 @@ class VC_Sections {
|
|||||||
self::REVIEWS
|
self::REVIEWS
|
||||||
);
|
);
|
||||||
|
|
||||||
$a1 = vc_asset_url( 'img/ellipse-17.png' );
|
// Avatars are optional; supply them via the vc_testimonials filter
|
||||||
$a2 = vc_asset_url( 'img/ellipse-17-2.png' );
|
// ('avatar' => attachment URL) — none are bundled with the plugin.
|
||||||
$items = apply_filters(
|
$items = apply_filters(
|
||||||
'vc_testimonials',
|
'vc_testimonials',
|
||||||
array(
|
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' => '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' => '', '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' => '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' => '', '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' => 'Sofia Rossi', 'role' => __( 'Sydney Tour', 'viator-collector' ), 'text' => __( 'Smooth from start to finish. Highly recommended.', 'viator-collector' ), 'avatar' => '', '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' => '' ),
|
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' => '', 'size' => '' ),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if ( empty( $items ) ) {
|
if ( empty( $items ) ) {
|
||||||
@@ -443,6 +456,299 @@ class VC_Sections {
|
|||||||
return ob_get_clean();
|
return ob_get_clean();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [viator_bus_tours limit="4" tag="hop-on-hop-off" image_id="123"]
|
||||||
|
*
|
||||||
|
* The "Open Top Bus Tours…" promo block + a 4-up grid of tour cards. Backed
|
||||||
|
* by the same curated/filtered tour query as the rails. The bus illustration
|
||||||
|
* is a heavy image, so it comes from the Media Library via image_id (or a
|
||||||
|
* direct image URL) — nothing is bundled with the plugin.
|
||||||
|
*/
|
||||||
|
public static function bus_tours( $atts ) {
|
||||||
|
$atts = shortcode_atts(
|
||||||
|
array(
|
||||||
|
'ids' => '',
|
||||||
|
'codes' => '',
|
||||||
|
'destination' => '',
|
||||||
|
'tag' => '',
|
||||||
|
'limit' => 4,
|
||||||
|
'orderby' => 'rating',
|
||||||
|
'title' => __( 'Open Top<br>Bus Tours…', 'viator-collector' ),
|
||||||
|
'note' => __( 'An open-top hop on hop off bus is the best way to discover the historic and cultural sights of any city.', 'viator-collector' ),
|
||||||
|
'chip' => __( 'Very Fun!', 'viator-collector' ),
|
||||||
|
'image_id' => '', // WordPress media attachment ID for the bus illustration.
|
||||||
|
'image' => '', // …or a direct image URL.
|
||||||
|
),
|
||||||
|
$atts,
|
||||||
|
self::BUS_TOURS
|
||||||
|
);
|
||||||
|
|
||||||
|
$query = self::tour_query( $atts );
|
||||||
|
if ( ! $query->have_posts() ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$archive = get_post_type_archive_link( 'viator_tour' );
|
||||||
|
$archive = $archive ? $archive : home_url( '/' );
|
||||||
|
|
||||||
|
$bus_img = '';
|
||||||
|
if ( '' !== $atts['image_id'] ) {
|
||||||
|
// 'full' (uncropped original) — some sites register a hard-cropped
|
||||||
|
// square 'large', which slices decorative illustrations in half.
|
||||||
|
$bus_img = wp_get_attachment_image( absint( $atts['image_id'] ), 'full', false, array( 'alt' => '', 'loading' => 'lazy' ) );
|
||||||
|
} elseif ( '' !== $atts['image'] ) {
|
||||||
|
$bus_img = '<img src="' . esc_url( $atts['image'] ) . '" alt="" loading="lazy">';
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="vc-app vc-sections">
|
||||||
|
<section class="section section--tight">
|
||||||
|
<div class="container bus">
|
||||||
|
<div class="promo">
|
||||||
|
<svg class="promo__route" viewBox="0 0 416 526" fill="none" aria-hidden="true" preserveAspectRatio="none">
|
||||||
|
<path d="M-20 470 C 90 470, 60 350, 150 330 S 300 300, 300 210 S 210 120, 300 60 S 430 40, 440 -10" stroke="#2CAFC2" stroke-dasharray="3 3" />
|
||||||
|
</svg>
|
||||||
|
<div class="promo__head">
|
||||||
|
<h2 class="promo__title"><?php echo wp_kses( $atts['title'], array( 'br' => array() ) ); ?></h2>
|
||||||
|
<a class="btn btn--white" href="<?php echo esc_url( $archive ); ?>"><span><?php echo esc_html__( 'See all', 'viator-collector' ); ?></span><?php echo vc_icon( 'arrow-ne' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></a>
|
||||||
|
</div>
|
||||||
|
<?php if ( $atts['chip'] ) : ?><span class="chip chip--accent promo__chip"><?php echo esc_html( $atts['chip'] ); ?></span><?php endif; ?>
|
||||||
|
<?php if ( $bus_img ) : ?><div class="promo__bus" aria-hidden="true"><?php echo $bus_img; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div><?php endif; ?>
|
||||||
|
<?php if ( $atts['note'] ) : ?><p class="promo__note"><?php echo esc_html( $atts['note'] ); ?></p><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="bus__grid">
|
||||||
|
<?php
|
||||||
|
$i = 0;
|
||||||
|
while ( $query->have_posts() ) :
|
||||||
|
$query->the_post();
|
||||||
|
++$i;
|
||||||
|
$pid = get_the_ID();
|
||||||
|
$price = get_post_meta( $pid, '_vc_price_from', true );
|
||||||
|
$currency = get_post_meta( $pid, '_vc_currency', true );
|
||||||
|
$terms = get_the_terms( $pid, 'viator_destination' );
|
||||||
|
$kicker = ( $terms && ! is_wp_error( $terms ) ) ? $terms[0]->name : '';
|
||||||
|
$active = ( 1 === $i );
|
||||||
|
$badge = vc_ring_badge( 'BOOK NOW · BOOK NOW · ', get_permalink( $pid ), 'card' );
|
||||||
|
if ( $active ) {
|
||||||
|
$badge = str_replace( 'ring-badge--card', 'ring-badge--card ring-badge--accent', $badge );
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<li class="tcard<?php echo $active ? ' is-active' : ''; ?>">
|
||||||
|
<?php echo vc_tour_image_html( $pid, 'tcard__img' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||||
|
<div class="tcard__top">
|
||||||
|
<?php if ( $kicker ) : ?><span class="tcard__k"><?php echo esc_html( $kicker ); ?></span><?php endif; ?>
|
||||||
|
<h3 class="tcard__title"><a href="<?php echo esc_url( get_permalink( $pid ) ); ?>"><?php echo esc_html( get_the_title( $pid ) ); ?></a></h3>
|
||||||
|
</div>
|
||||||
|
<div class="tcard__bottom">
|
||||||
|
<?php if ( '' !== (string) $price ) : ?>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="meta__k"><?php echo esc_html__( 'Start at', 'viator-collector' ); ?></span>
|
||||||
|
<span class="meta__v"><?php echo esc_html( vc_money( $price, $currency ) ); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php echo $badge; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<?php endwhile; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
wp_reset_postdata();
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [viator_about image_id="123" title="Hop On Hop Off" highlight="Memorable"
|
||||||
|
* stats="25+|Years Experience;;500+|Tours Conducted"]
|
||||||
|
*
|
||||||
|
* A static "About" marketing block (headline + chip, paragraph, stat counters
|
||||||
|
* and a decorative bus). All copy is editable via attributes; the isometric
|
||||||
|
* bus is a heavy image supplied from the Media Library (image_id / image).
|
||||||
|
*/
|
||||||
|
public static function about( $atts ) {
|
||||||
|
$atts = shortcode_atts(
|
||||||
|
array(
|
||||||
|
'title' => __( 'Hop On Hop Off', 'viator-collector' ),
|
||||||
|
'highlight' => __( 'Memorable', 'viator-collector' ),
|
||||||
|
'text' => __( 'Discover cities the memorable way — hop on and off our open-top buses at every major sight and explore each place at your own pace, with expert local commentary along the way.', 'viator-collector' ),
|
||||||
|
'button_text' => __( 'About us', 'viator-collector' ),
|
||||||
|
'button_url' => '',
|
||||||
|
'image_id' => '',
|
||||||
|
'image' => '',
|
||||||
|
'stats' => '', // "25+|Years Experience;;500+|Tours Conducted;;430+|Tours this Year;;15+|Different Tours"
|
||||||
|
),
|
||||||
|
$atts,
|
||||||
|
self::ABOUT
|
||||||
|
);
|
||||||
|
|
||||||
|
$stats = array();
|
||||||
|
if ( '' !== $atts['stats'] ) {
|
||||||
|
foreach ( explode( ';;', $atts['stats'] ) as $pair ) {
|
||||||
|
$p = array_map( 'trim', explode( '|', $pair, 2 ) );
|
||||||
|
if ( '' !== $p[0] && isset( $p[1] ) ) {
|
||||||
|
$stats[] = array( 'num' => $p[0], 'label' => $p[1] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( empty( $stats ) ) {
|
||||||
|
$stats = array(
|
||||||
|
array( 'num' => '25+', 'label' => __( 'Years Experience', 'viator-collector' ) ),
|
||||||
|
array( 'num' => '500+', 'label' => __( 'Tours Conducted', 'viator-collector' ) ),
|
||||||
|
array( 'num' => '430+', 'label' => __( 'Tours this Year', 'viator-collector' ) ),
|
||||||
|
array( 'num' => '15+', 'label' => __( 'Different Tours', 'viator-collector' ) ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$button_url = $atts['button_url'];
|
||||||
|
if ( '' === $button_url ) {
|
||||||
|
$button_url = get_post_type_archive_link( 'viator_tour' );
|
||||||
|
$button_url = $button_url ? $button_url : home_url( '/' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$bus_img = '';
|
||||||
|
if ( '' !== $atts['image_id'] ) {
|
||||||
|
// 'full' (uncropped original) — some sites register a hard-cropped
|
||||||
|
// square 'large', which slices decorative illustrations in half.
|
||||||
|
$bus_img = wp_get_attachment_image( absint( $atts['image_id'] ), 'full', false, array( 'alt' => '', 'loading' => 'lazy' ) );
|
||||||
|
} elseif ( '' !== $atts['image'] ) {
|
||||||
|
$bus_img = '<img src="' . esc_url( $atts['image'] ) . '" alt="" loading="lazy">';
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="vc-app vc-sections">
|
||||||
|
<section class="section">
|
||||||
|
<div class="container about">
|
||||||
|
<div class="about__art" aria-hidden="true">
|
||||||
|
<span class="about__glow"></span>
|
||||||
|
<span class="about__trail" aria-hidden="true"></span>
|
||||||
|
<?php if ( $bus_img ) : ?><span class="about__bus"><?php echo $bus_img; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span><?php endif; ?>
|
||||||
|
<svg class="about__arcs" viewBox="0 0 480 440" fill="none" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<path id="busArc" pathLength="100" d="M20 336.6V221.6C20 96.6 170 23.4 170 148.4V263.4" />
|
||||||
|
</defs>
|
||||||
|
<g stroke="#FFCC00" stroke-width="9" stroke-linecap="round" fill="none" transform="translate(16 24)">
|
||||||
|
<use href="#busArc" opacity="0.1" stroke-dasharray="62 100" />
|
||||||
|
<use href="#busArc" opacity="0.25" stroke-dasharray="69 100" transform="translate(48 22)" />
|
||||||
|
<use href="#busArc" opacity="0.45" stroke-dasharray="76 100" transform="translate(96 44)" />
|
||||||
|
<use href="#busArc" opacity="0.8" stroke-dasharray="83 100" transform="translate(144 66)" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about__body">
|
||||||
|
<div class="about__head">
|
||||||
|
<h2 class="about__title"><?php echo esc_html( $atts['title'] ); ?><?php if ( $atts['highlight'] ) : ?><span class="chip chip--accent about__chip"><?php echo esc_html( $atts['highlight'] ); ?></span><?php endif; ?></h2>
|
||||||
|
<a class="btn btn--dark" href="<?php echo esc_url( $button_url ); ?>"><span><?php echo esc_html( $atts['button_text'] ); ?></span><?php echo vc_icon( 'arrow-right' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ( $atts['text'] ) : ?><p class="about__text"><?php echo esc_html( $atts['text'] ); ?></p><?php endif; ?>
|
||||||
|
|
||||||
|
<dl class="stats">
|
||||||
|
<?php
|
||||||
|
foreach ( $stats as $s ) :
|
||||||
|
$num = $s['num'];
|
||||||
|
$suffix = '';
|
||||||
|
if ( preg_match( '/^(.*?)(\+)$/', $num, $m ) ) {
|
||||||
|
$num = $m[1];
|
||||||
|
$suffix = $m[2];
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="stat">
|
||||||
|
<dt class="stat__num"><?php echo esc_html( $num ); ?><?php if ( $suffix ) : ?><span><?php echo esc_html( $suffix ); ?></span><?php endif; ?></dt>
|
||||||
|
<dd class="stat__label"><?php echo esc_html( $s['label'] ); ?></dd>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [viator_articles limit="8" category="news"]
|
||||||
|
*
|
||||||
|
* A rail of the latest blog posts, styled as the design's "Latest Articles"
|
||||||
|
* cards. Uses each post's featured image, time and excerpt; links to the post.
|
||||||
|
*/
|
||||||
|
public static function articles( $atts ) {
|
||||||
|
$atts = shortcode_atts(
|
||||||
|
array(
|
||||||
|
'title' => __( 'Latest<br>Articles', 'viator-collector' ),
|
||||||
|
'lead' => __( 'News, tips and stories from our tours.', 'viator-collector' ),
|
||||||
|
'limit' => 8,
|
||||||
|
'category' => '',
|
||||||
|
'view_all' => '',
|
||||||
|
),
|
||||||
|
$atts,
|
||||||
|
self::ARTICLES
|
||||||
|
);
|
||||||
|
|
||||||
|
$args = array(
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => max( 1, absint( $atts['limit'] ) ),
|
||||||
|
'ignore_sticky_posts' => true,
|
||||||
|
'no_found_rows' => true,
|
||||||
|
);
|
||||||
|
if ( '' !== $atts['category'] ) {
|
||||||
|
$args['category_name'] = sanitize_title( $atts['category'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = new WP_Query( $args );
|
||||||
|
if ( ! $query->have_posts() ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$view_all = $atts['view_all'];
|
||||||
|
if ( '' === $view_all ) {
|
||||||
|
$posts_page = (int) get_option( 'page_for_posts' );
|
||||||
|
$view_all = $posts_page ? get_permalink( $posts_page ) : home_url( '/' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$rail = self::open_rail( $atts['title'], $atts['lead'], $view_all, __( 'See all', 'viator-collector' ), 'rail--art' );
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
echo $rail['head']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
|
$i = 0;
|
||||||
|
while ( $query->have_posts() ) :
|
||||||
|
$query->the_post();
|
||||||
|
++$i;
|
||||||
|
$pid = get_the_ID();
|
||||||
|
$active = ( 1 === $i );
|
||||||
|
$img = has_post_thumbnail( $pid ) ? get_the_post_thumbnail( $pid, 'large', array( 'class' => 'acard__img', 'loading' => 'lazy', 'alt' => '' ) ) : '';
|
||||||
|
$time = human_time_diff( (int) get_post_time( 'U', true, $pid ), (int) current_time( 'timestamp', true ) );
|
||||||
|
$excerpt = get_the_excerpt( $pid );
|
||||||
|
$badge = vc_ring_badge( 'READ MORE · READ MORE · ', get_permalink( $pid ), 'card' );
|
||||||
|
if ( $active ) {
|
||||||
|
$badge = str_replace( 'ring-badge--card', 'ring-badge--card ring-badge--accent', $badge );
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<li class="acard<?php echo $active ? ' is-active' : ''; ?>">
|
||||||
|
<div class="acard__media">
|
||||||
|
<?php echo $img; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||||
|
<span class="chip chip--glass acard__time"><?php printf( /* translators: %s: human-readable time difference, e.g. "3 hours". */ esc_html__( '%s ago', 'viator-collector' ), esc_html( $time ) ); ?></span>
|
||||||
|
<div class="acard__overlay">
|
||||||
|
<h3 class="acard__title"><a href="<?php echo esc_url( get_permalink( $pid ) ); ?>"><?php echo esc_html( get_the_title( $pid ) ); ?></a></h3>
|
||||||
|
<?php echo $badge; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if ( $excerpt ) : ?><p class="acard__excerpt"><?php echo esc_html( $excerpt ); ?></p><?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php
|
||||||
|
endwhile;
|
||||||
|
echo self::close_rail(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
|
wp_reset_postdata();
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared tour query for the landing sections. Supports curation (ids /
|
* Shared tour query for the landing sections. Supports curation (ids /
|
||||||
* product codes) and filtering (destination / tag), with a sane fallback.
|
* product codes) and filtering (destination / tag), with a sane fallback.
|
||||||
|
|||||||
@@ -63,6 +63,33 @@ class VC_Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suggested URL slug for the Viator Structure screen: keep an existing
|
||||||
|
// term's slug so live /tour-destination/{slug}/ links still resolve
|
||||||
|
// (and 301), otherwise WordPress's own slug for the name.
|
||||||
|
$existing = array();
|
||||||
|
if ( $results ) {
|
||||||
|
$terms = get_terms(
|
||||||
|
array(
|
||||||
|
'taxonomy' => 'viator_destination',
|
||||||
|
'hide_empty' => false,
|
||||||
|
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery
|
||||||
|
array(
|
||||||
|
'key' => '_vc_viator_destination_id',
|
||||||
|
'value' => array_column( $results, 'id' ),
|
||||||
|
'compare' => 'IN',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
foreach ( is_wp_error( $terms ) ? array() : $terms as $term ) {
|
||||||
|
$existing[ (string) get_term_meta( $term->term_id, '_vc_viator_destination_id', true ) ] = $term->slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ( $results as &$result ) {
|
||||||
|
$result['slug'] = isset( $existing[ $result['id'] ] ) ? $existing[ $result['id'] ] : sanitize_title( $result['name'] );
|
||||||
|
}
|
||||||
|
unset( $result );
|
||||||
|
|
||||||
wp_send_json_success( array( 'results' => $results ) );
|
wp_send_json_success( array( 'results' => $results ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +102,8 @@ class VC_Settings {
|
|||||||
'timeout' => 20,
|
'timeout' => 20,
|
||||||
'default_post_status' => 'publish',
|
'default_post_status' => 'publish',
|
||||||
'hero_image' => '',
|
'hero_image' => '',
|
||||||
|
'hero_image_id' => 0,
|
||||||
|
'single_hero_figure_id' => 0,
|
||||||
'hero_slides' => '',
|
'hero_slides' => '',
|
||||||
'crawl_destinations' => '',
|
'crawl_destinations' => '',
|
||||||
'rate_limit_per_minute' => 25,
|
'rate_limit_per_minute' => 25,
|
||||||
@@ -122,6 +151,8 @@ class VC_Settings {
|
|||||||
'timeout' => max( 5, absint( $input['timeout'] ?? 20 ) ),
|
'timeout' => max( 5, absint( $input['timeout'] ?? 20 ) ),
|
||||||
'default_post_status' => sanitize_key( $input['default_post_status'] ?? 'publish' ),
|
'default_post_status' => sanitize_key( $input['default_post_status'] ?? 'publish' ),
|
||||||
'hero_image' => esc_url_raw( $input['hero_image'] ?? '' ),
|
'hero_image' => esc_url_raw( $input['hero_image'] ?? '' ),
|
||||||
|
'hero_image_id' => absint( $input['hero_image_id'] ?? 0 ),
|
||||||
|
'single_hero_figure_id' => absint( $input['single_hero_figure_id'] ?? 0 ),
|
||||||
'hero_slides' => self::sanitize_id_list( $input['hero_slides'] ?? '' ),
|
'hero_slides' => self::sanitize_id_list( $input['hero_slides'] ?? '' ),
|
||||||
'crawl_destinations' => self::sanitize_id_list( $input['crawl_destinations'] ?? '' ),
|
'crawl_destinations' => self::sanitize_id_list( $input['crawl_destinations'] ?? '' ),
|
||||||
'rate_limit_per_minute' => max( 1, min( 60, absint( $input['rate_limit_per_minute'] ?? 25 ) ) ),
|
'rate_limit_per_minute' => max( 1, min( 60, absint( $input['rate_limit_per_minute'] ?? 25 ) ) ),
|
||||||
@@ -198,9 +229,10 @@ class VC_Settings {
|
|||||||
<th scope="row"><label for="vc_hero_image"><?php echo esc_html__( 'Hero image', 'viator-collector' ); ?></label></th>
|
<th scope="row"><label for="vc_hero_image"><?php echo esc_html__( 'Hero image', 'viator-collector' ); ?></label></th>
|
||||||
<td>
|
<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://…">
|
<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://…">
|
||||||
|
<input id="vc_hero_image_id" type="hidden" name="vc_settings[hero_image_id]" value="<?php echo esc_attr( $options['hero_image_id'] ); ?>">
|
||||||
<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-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>
|
<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>
|
<p class="description"><?php echo esc_html__( 'Banner at the top of the tours listing / home hero. Pick from the Media Library so the browser gets responsive, device-sized images. 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;">
|
<div id="vc-hero-preview" style="margin-top:8px;">
|
||||||
<?php if ( $options['hero_image'] ) : ?>
|
<?php if ( $options['hero_image'] ) : ?>
|
||||||
<img src="<?php echo esc_url( $options['hero_image'] ); ?>" alt="" style="max-width:320px;height:auto;border-radius:8px;">
|
<img src="<?php echo esc_url( $options['hero_image'] ); ?>" alt="" style="max-width:320px;height:auto;border-radius:8px;">
|
||||||
@@ -221,6 +253,7 @@ class VC_Settings {
|
|||||||
frame.on('select', function () {
|
frame.on('select', function () {
|
||||||
var img = frame.state().get('selection').first().toJSON();
|
var img = frame.state().get('selection').first().toJSON();
|
||||||
$('#vc_hero_image').val(img.url);
|
$('#vc_hero_image').val(img.url);
|
||||||
|
$('#vc_hero_image_id').val(img.id);
|
||||||
$('#vc-hero-preview').html($('<img>', { src: img.url, alt: '' }).css({ maxWidth: '320px', height: 'auto', borderRadius: '8px' }));
|
$('#vc-hero-preview').html($('<img>', { src: img.url, alt: '' }).css({ maxWidth: '320px', height: 'auto', borderRadius: '8px' }));
|
||||||
});
|
});
|
||||||
frame.open();
|
frame.open();
|
||||||
@@ -228,12 +261,56 @@ class VC_Settings {
|
|||||||
$('#vc-hero-clear').on('click', function (e) {
|
$('#vc-hero-clear').on('click', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$('#vc_hero_image').val('');
|
$('#vc_hero_image').val('');
|
||||||
|
$('#vc_hero_image_id').val('');
|
||||||
$('#vc-hero-preview').empty();
|
$('#vc-hero-preview').empty();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><label><?php echo esc_html__( 'Single tour hero figure', 'viator-collector' ); ?></label></th>
|
||||||
|
<td>
|
||||||
|
<input id="vc_single_figure_id" type="hidden" name="vc_settings[single_hero_figure_id]" value="<?php echo esc_attr( $options['single_hero_figure_id'] ); ?>">
|
||||||
|
<button type="button" class="button" id="vc-figure-pick"><?php echo esc_html__( 'Select image', 'viator-collector' ); ?></button>
|
||||||
|
<button type="button" class="button" id="vc-figure-clear"><?php echo esc_html__( 'Clear', 'viator-collector' ); ?></button>
|
||||||
|
<p class="description"><?php echo esc_html__( 'Optional decorative cut-out shown over the single-tour hero (e.g. a landmark PNG with a transparent background). Leave empty to hide it.', 'viator-collector' ); ?></p>
|
||||||
|
<div id="vc-figure-preview" style="margin-top:8px;">
|
||||||
|
<?php
|
||||||
|
if ( $options['single_hero_figure_id'] ) {
|
||||||
|
echo wp_get_attachment_image( (int) $options['single_hero_figure_id'], 'medium', false, array( 'style' => 'max-width:200px;height:auto;' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
jQuery(function ($) {
|
||||||
|
var frame;
|
||||||
|
$('#vc-figure-pick').on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (frame) { frame.open(); return; }
|
||||||
|
frame = wp.media({
|
||||||
|
title: <?php echo wp_json_encode( __( 'Select hero figure', '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_single_figure_id').val(img.id);
|
||||||
|
var src = (img.sizes && img.sizes.medium) ? img.sizes.medium.url : img.url;
|
||||||
|
$('#vc-figure-preview').html($('<img>', { src: src, alt: '' }).css({ maxWidth: '200px', height: 'auto' }));
|
||||||
|
});
|
||||||
|
frame.open();
|
||||||
|
});
|
||||||
|
$('#vc-figure-clear').on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
$('#vc_single_figure_id').val('');
|
||||||
|
$('#vc-figure-preview').empty();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row"><label for="vc_hero_slides"><?php echo esc_html__( 'Hero slider images', 'viator-collector' ); ?></label></th>
|
<th scope="row"><label for="vc_hero_slides"><?php echo esc_html__( 'Hero slider images', 'viator-collector' ); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
@@ -295,7 +372,7 @@ class VC_Settings {
|
|||||||
<th scope="row"><label for="vc_crawl_destinations"><?php echo esc_html__( 'Destination filter', 'viator-collector' ); ?></label></th>
|
<th scope="row"><label for="vc_crawl_destinations"><?php echo esc_html__( 'Destination filter', 'viator-collector' ); ?></label></th>
|
||||||
<td>
|
<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' ); ?>">
|
<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>
|
<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. Used only when no cities are set on the Viator Structure screen.', 'viator-collector' ); ?></p>
|
||||||
<div class="vc-dest-finder" style="margin-top:8px;max-width:640px;">
|
<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' ); ?>">
|
<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>
|
<button type="button" class="button" id="vc-dest-search-btn"><?php echo esc_html__( 'Search', 'viator-collector' ); ?></button>
|
||||||
|
|||||||
475
viator-collector/includes/class-vc-structure-admin.php
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
<?php
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings → Viator Structure: the client's region → city tree. Cities can
|
||||||
|
* only be picked from Viator's destination list, and a save that fails
|
||||||
|
* VC_Structure::validate() is rejected as a whole (the draft is kept so the
|
||||||
|
* admin can fix it).
|
||||||
|
*/
|
||||||
|
class VC_Structure_Admin {
|
||||||
|
const PAGE = 'viator-collector-structure';
|
||||||
|
|
||||||
|
private static $sync_errors = array();
|
||||||
|
|
||||||
|
public static function init() {
|
||||||
|
add_action( 'admin_menu', array( __CLASS__, 'admin_menu' ) );
|
||||||
|
add_action( 'admin_post_vc_save_structure', array( __CLASS__, 'handle_save' ) );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'admin_assets' ) );
|
||||||
|
add_action( 'admin_notices', array( __CLASS__, 'page_clash_notice' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function admin_menu() {
|
||||||
|
add_options_page(
|
||||||
|
__( 'Viator Structure', 'viator-collector' ),
|
||||||
|
__( 'Viator Structure', 'viator-collector' ),
|
||||||
|
'manage_options',
|
||||||
|
self::PAGE,
|
||||||
|
array( __CLASS__, 'render_page' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function admin_assets( $hook ) {
|
||||||
|
if ( 'settings_page_' . self::PAGE !== $hook ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wp_enqueue_script( 'vc-structure-admin', VC_PLUGIN_URL . 'assets/js/vc-structure-admin.js', array(), VC_VERSION, true );
|
||||||
|
wp_localize_script(
|
||||||
|
'vc-structure-admin',
|
||||||
|
'vcStructure',
|
||||||
|
array(
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'nonce' => wp_create_nonce( 'vc_search_destinations' ),
|
||||||
|
'i18n' => array(
|
||||||
|
'searching' => __( 'Searching…', 'viator-collector' ),
|
||||||
|
'noMatches' => __( 'No matches.', 'viator-collector' ),
|
||||||
|
'failed' => __( 'Search failed.', 'viator-collector' ),
|
||||||
|
'add' => __( 'Add', 'viator-collector' ),
|
||||||
|
'used' => __( 'Already added', 'viator-collector' ),
|
||||||
|
'confirm' => __( 'Remove this region and all its cities from the structure?', 'viator-collector' ),
|
||||||
|
'confirmEmpty' => __( 'Save with no regions at all? Every region and city URL will stop working.', 'viator-collector' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function draft_key() {
|
||||||
|
return 'vc_structure_draft_' . get_current_user_id();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_page() {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$draft = get_transient( self::draft_key() );
|
||||||
|
$errors = array();
|
||||||
|
$structure = VC_Structure::get();
|
||||||
|
if ( is_array( $draft ) ) {
|
||||||
|
delete_transient( self::draft_key() );
|
||||||
|
$errors = (array) $draft['errors'];
|
||||||
|
$structure = (array) $draft['structure'];
|
||||||
|
}
|
||||||
|
$warnings = get_transient( self::warnings_key() );
|
||||||
|
$warnings = is_array( $warnings ) ? $warnings : array();
|
||||||
|
if ( $warnings ) {
|
||||||
|
delete_transient( self::warnings_key() );
|
||||||
|
}
|
||||||
|
$notice = isset( $_GET['vc_notice'] ) ? sanitize_text_field( wp_unslash( $_GET['vc_notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1><?php echo esc_html__( 'Viator Structure', 'viator-collector' ); ?></h1>
|
||||||
|
<p><?php echo esc_html__( 'Regions appear in the header menu and at /{region}/. Each city is a Viator destination: its tours are imported automatically and listed at /{city}/, with tour pages at /{city}/{tour}/.', 'viator-collector' ); ?></p>
|
||||||
|
|
||||||
|
<?php if ( $notice ) : ?>
|
||||||
|
<div class="notice <?php echo $warnings ? 'notice-warning' : 'notice-success'; ?> is-dismissible"><p><?php echo esc_html( $notice ); ?></p></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ( $warnings ) : ?>
|
||||||
|
<div class="notice notice-warning">
|
||||||
|
<ul style="list-style:disc;margin-left:20px;">
|
||||||
|
<?php foreach ( $warnings as $warning ) : ?>
|
||||||
|
<li><?php echo esc_html( $warning ); ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ( $errors ) : ?>
|
||||||
|
<div class="notice notice-error">
|
||||||
|
<p><strong><?php echo esc_html__( 'Nothing was saved. Please fix these problems:', 'viator-collector' ); ?></strong></p>
|
||||||
|
<ul style="list-style:disc;margin-left:20px;">
|
||||||
|
<?php foreach ( $errors as $error ) : ?>
|
||||||
|
<li><?php echo esc_html( $error ); ?></li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vc-region{background:#fff;border:1px solid #c3c4c7;border-radius:6px;padding:12px 16px;margin:0 0 16px;max-width:900px}
|
||||||
|
.vc-region__head{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||||
|
.vc-region__head .vc-tools{margin-left:auto}
|
||||||
|
.vc-cities{margin:12px 0;width:100%}
|
||||||
|
.vc-cities td{padding:4px 8px 4px 0;vertical-align:middle}
|
||||||
|
.vc-city-search{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||||
|
.vc-results{width:100%}
|
||||||
|
.vc-results div{display:flex;gap:8px;align-items:center;padding:2px 0}
|
||||||
|
.vc-slug-prefix{color:#646970}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" id="vc-structure-form">
|
||||||
|
<input type="hidden" name="action" value="vc_save_structure">
|
||||||
|
<?php wp_nonce_field( 'vc_save_structure' ); ?>
|
||||||
|
<div id="vc-regions">
|
||||||
|
<?php
|
||||||
|
foreach ( $structure as $region ) {
|
||||||
|
self::render_region( $region );
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<p><button type="button" class="button" id="vc-add-region"><?php echo esc_html__( 'Add region', 'viator-collector' ); ?></button></p>
|
||||||
|
<?php submit_button( __( 'Save structure', 'viator-collector' ) ); ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template id="vc-tpl-region"><?php self::render_region( array( 'name' => '', 'slug' => '', 'term_id' => 0, 'cities' => array() ) ); ?></template>
|
||||||
|
<template id="vc-tpl-city"><?php self::render_city( array( 'id' => '', 'name' => '', 'slug' => '' ) ); ?></template>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inputs carry data-f (field) only; the script assigns indexed names on
|
||||||
|
* submit so reordering never leaves gaps.
|
||||||
|
*/
|
||||||
|
private static function render_region( array $region ) {
|
||||||
|
?>
|
||||||
|
<div class="vc-region">
|
||||||
|
<div class="vc-region__head">
|
||||||
|
<label><?php echo esc_html__( 'Region', 'viator-collector' ); ?>
|
||||||
|
<input type="text" data-f="name" value="<?php echo esc_attr( $region['name'] ); ?>" required>
|
||||||
|
</label>
|
||||||
|
<label><span class="vc-slug-prefix">/</span><input type="text" data-f="slug" value="<?php echo esc_attr( $region['slug'] ); ?>" pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="europe">/</label>
|
||||||
|
<input type="hidden" data-f="term_id" value="<?php echo esc_attr( (string) (int) $region['term_id'] ); ?>">
|
||||||
|
<span class="vc-tools">
|
||||||
|
<button type="button" class="button button-small" data-act="up" aria-label="<?php echo esc_attr__( 'Move up', 'viator-collector' ); ?>">↑</button>
|
||||||
|
<button type="button" class="button button-small" data-act="down" aria-label="<?php echo esc_attr__( 'Move down', 'viator-collector' ); ?>">↓</button>
|
||||||
|
<button type="button" class="button button-small button-link-delete" data-act="remove-region"><?php echo esc_html__( 'Remove region', 'viator-collector' ); ?></button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="vc-cities"><tbody>
|
||||||
|
<?php
|
||||||
|
foreach ( $region['cities'] as $city ) {
|
||||||
|
self::render_city( $city );
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</tbody></table>
|
||||||
|
<div class="vc-city-search">
|
||||||
|
<input type="search" class="regular-text" placeholder="<?php echo esc_attr__( 'Find a Viator city, e.g. Amsterdam', 'viator-collector' ); ?>">
|
||||||
|
<button type="button" class="button" data-act="search"><?php echo esc_html__( 'Search', 'viator-collector' ); ?></button>
|
||||||
|
<div class="vc-results"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function render_city( array $city ) {
|
||||||
|
?>
|
||||||
|
<tr class="vc-city">
|
||||||
|
<td>
|
||||||
|
<strong class="vc-city__name"><?php echo esc_html( $city['name'] ); ?></strong>
|
||||||
|
<span class="description">(ID <span class="vc-city__id"><?php echo esc_html( $city['id'] ); ?></span>)</span>
|
||||||
|
<input type="hidden" data-f="id" value="<?php echo esc_attr( $city['id'] ); ?>">
|
||||||
|
<input type="hidden" data-f="name" value="<?php echo esc_attr( $city['name'] ); ?>">
|
||||||
|
</td>
|
||||||
|
<td><span class="vc-slug-prefix">/</span><input type="text" data-f="slug" value="<?php echo esc_attr( $city['slug'] ); ?>" pattern="[a-z0-9]+(-[a-z0-9]+)*" required>/</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="button button-small" data-act="up" aria-label="<?php echo esc_attr__( 'Move up', 'viator-collector' ); ?>">↑</button>
|
||||||
|
<button type="button" class="button button-small" data-act="down" aria-label="<?php echo esc_attr__( 'Move down', 'viator-collector' ); ?>">↓</button>
|
||||||
|
<button type="button" class="button button-small button-link-delete" data-act="remove-city"><?php echo esc_html__( 'Remove', 'viator-collector' ); ?></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function handle_save() {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_die( esc_html__( 'You do not have permission to perform this action.', 'viator-collector' ) );
|
||||||
|
}
|
||||||
|
check_admin_referer( 'vc_save_structure' );
|
||||||
|
|
||||||
|
$raw = isset( $_POST['vc_structure'] ) && is_array( $_POST['vc_structure'] ) ? wp_unslash( $_POST['vc_structure'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- normalized + validated below.
|
||||||
|
$structure = VC_Structure::normalize( $raw );
|
||||||
|
foreach ( $structure as &$region ) {
|
||||||
|
$region['name'] = sanitize_text_field( $region['name'] );
|
||||||
|
foreach ( $region['cities'] as &$city ) {
|
||||||
|
$city['name'] = sanitize_text_field( $city['name'] );
|
||||||
|
}
|
||||||
|
unset( $city );
|
||||||
|
}
|
||||||
|
unset( $region );
|
||||||
|
|
||||||
|
$map = ( new VC_API() )->get_destination_map();
|
||||||
|
if ( empty( $_POST['vc_structure_present'] ) ) {
|
||||||
|
$errors = array( __( 'The structure editor did not load correctly, so nothing was saved. Reload the page and try again.', 'viator-collector' ) );
|
||||||
|
} elseif ( ! $structure && empty( $_POST['vc_confirm_empty'] ) ) {
|
||||||
|
$errors = array( __( 'Saving an empty structure needs confirmation; nothing was saved.', 'viator-collector' ) );
|
||||||
|
} elseif ( is_wp_error( $map ) ) {
|
||||||
|
$errors = array( __( 'Could not load Viator destinations; nothing was saved.', 'viator-collector' ) );
|
||||||
|
} else {
|
||||||
|
$errors = VC_Structure::validate( $structure, (array) $map, self::taken_slugs( $structure ) );
|
||||||
|
// Names always come from Viator, never from the form.
|
||||||
|
foreach ( $structure as &$region ) {
|
||||||
|
foreach ( $region['cities'] as &$city ) {
|
||||||
|
if ( isset( $map[ $city['id'] ] ) ) {
|
||||||
|
$city['name'] = $map[ $city['id'] ];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset( $city );
|
||||||
|
}
|
||||||
|
unset( $region );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $errors ) {
|
||||||
|
set_transient(
|
||||||
|
self::draft_key(),
|
||||||
|
array(
|
||||||
|
'errors' => $errors,
|
||||||
|
// Without the editor's fields there is no draft worth keeping.
|
||||||
|
'structure' => empty( $_POST['vc_structure_present'] ) ? VC_Structure::get() : $structure,
|
||||||
|
),
|
||||||
|
10 * MINUTE_IN_SECONDS
|
||||||
|
);
|
||||||
|
self::redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$sync_errors = array();
|
||||||
|
$structure = self::sync_terms( VC_Structure::get(), $structure );
|
||||||
|
update_option( VC_Structure::OPTION, $structure );
|
||||||
|
VC_Structure::flush_cache();
|
||||||
|
update_option( 'vc_flush_rewrite', 1 );
|
||||||
|
VC_Logger::info( sprintf( 'Structure saved: %d regions, %d cities.', count( $structure ), count( VC_Structure::cities( $structure ) ) ) );
|
||||||
|
if ( self::$sync_errors ) {
|
||||||
|
set_transient( self::warnings_key(), self::$sync_errors, 10 * MINUTE_IN_SECONDS );
|
||||||
|
self::redirect( __( 'Structure saved, but some pages could not be set up (see below).', 'viator-collector' ) );
|
||||||
|
}
|
||||||
|
self::redirect( __( 'Structure saved.', 'viator-collector' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function warnings_key() {
|
||||||
|
return 'vc_structure_warnings_' . get_current_user_id();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a term-sync failure: logged, and shown to the admin after save.
|
||||||
|
*/
|
||||||
|
private static function sync_error( $message ) {
|
||||||
|
VC_Logger::error( 'Structure: ' . $message );
|
||||||
|
self::$sync_errors[] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function redirect( $notice = '' ) {
|
||||||
|
$args = array( 'page' => self::PAGE );
|
||||||
|
if ( '' !== $notice ) {
|
||||||
|
$args['vc_notice'] = rawurlencode( $notice );
|
||||||
|
}
|
||||||
|
wp_safe_redirect( add_query_arg( $args, admin_url( 'options-general.php' ) ) );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slugs (from this structure) already owned by something outside it:
|
||||||
|
* slug => human label.
|
||||||
|
*/
|
||||||
|
public static function taken_slugs( array $structure ) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$slugs = array();
|
||||||
|
foreach ( $structure as $region ) {
|
||||||
|
$slugs[] = $region['slug'];
|
||||||
|
}
|
||||||
|
$cities = VC_Structure::cities( $structure );
|
||||||
|
foreach ( $cities as $city ) {
|
||||||
|
$slugs[] = $city['slug'];
|
||||||
|
}
|
||||||
|
$slugs = array_values( array_filter( array_unique( $slugs ) ) );
|
||||||
|
if ( ! $slugs ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
$taken = array();
|
||||||
|
|
||||||
|
$types = array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment', 'viator_tour' ) );
|
||||||
|
if ( $types ) {
|
||||||
|
$slug_in = implode( ',', array_fill( 0, count( $slugs ), '%s' ) );
|
||||||
|
$type_in = implode( ',', array_fill( 0, count( $types ), '%s' ) );
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery
|
||||||
|
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_name, post_title, post_type, post_parent FROM {$wpdb->posts} WHERE post_name IN ($slug_in) AND post_type IN ($type_in) AND post_status NOT IN ('trash','auto-draft','inherit')", array_merge( $slugs, array_values( $types ) ) ) );
|
||||||
|
foreach ( (array) $rows as $row ) {
|
||||||
|
if ( 'page' === $row->post_type && (int) $row->post_parent ) {
|
||||||
|
continue; // Child pages live under their parent's path.
|
||||||
|
}
|
||||||
|
$label = 'page' === $row->post_type ? 'page "%s"' : 'post "%s"';
|
||||||
|
$taken[ $row->post_name ] = sprintf( $label, $row->post_title );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destination terms that the structure does not own.
|
||||||
|
$region_ids = array_filter( array_map( 'intval', array_column( $structure, 'term_id' ) ) );
|
||||||
|
// A new region (no term yet) adopts an existing term with its slug.
|
||||||
|
$adoptable = array();
|
||||||
|
foreach ( $structure as $region ) {
|
||||||
|
if ( ! (int) $region['term_id'] ) {
|
||||||
|
$adoptable[] = $region['slug'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$terms = get_terms(
|
||||||
|
array(
|
||||||
|
'taxonomy' => 'viator_destination',
|
||||||
|
'slug' => $slugs,
|
||||||
|
'hide_empty' => false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
foreach ( is_wp_error( $terms ) ? array() : $terms as $term ) {
|
||||||
|
if ( in_array( (int) $term->term_id, $region_ids, true ) || in_array( $term->slug, $adoptable, true ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$viator_id = (string) get_term_meta( $term->term_id, '_vc_viator_destination_id', true );
|
||||||
|
if ( '' !== $viator_id && isset( $cities[ $viator_id ] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$taken[ $term->slug ] = sprintf( 'destination "%s"', $term->name );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $taken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror the structure into viator_destination terms: regions are
|
||||||
|
* top-level terms flagged _vc_region, cities are re-parented under them.
|
||||||
|
* Products are never touched.
|
||||||
|
*
|
||||||
|
* @return array $new with every region's term_id filled in.
|
||||||
|
*/
|
||||||
|
public static function sync_terms( array $old, array $new ) {
|
||||||
|
$tax = 'viator_destination';
|
||||||
|
|
||||||
|
foreach ( $new as &$region ) {
|
||||||
|
$term_id = (int) $region['term_id'];
|
||||||
|
if ( $term_id && ! get_term( $term_id, $tax ) ) {
|
||||||
|
$term_id = 0;
|
||||||
|
}
|
||||||
|
if ( ! $term_id ) {
|
||||||
|
$existing = get_term_by( 'slug', $region['slug'], $tax );
|
||||||
|
$term_id = $existing ? (int) $existing->term_id : 0;
|
||||||
|
}
|
||||||
|
if ( $term_id ) {
|
||||||
|
$result = wp_update_term(
|
||||||
|
$term_id,
|
||||||
|
$tax,
|
||||||
|
array(
|
||||||
|
'name' => $region['name'],
|
||||||
|
'slug' => $region['slug'],
|
||||||
|
'parent' => 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$result = wp_insert_term( $region['name'], $tax, array( 'slug' => $region['slug'] ) );
|
||||||
|
}
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
self::sync_error( 'region "' . $region['name'] . '": ' . $result->get_error_message() );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$region['term_id'] = (int) $result['term_id'];
|
||||||
|
update_term_meta( $region['term_id'], '_vc_region', 1 );
|
||||||
|
|
||||||
|
foreach ( $region['cities'] as $city ) {
|
||||||
|
$city_term = VC_Taxonomies::get_or_create_destination_by_id( $city['id'], $city['name'] );
|
||||||
|
if ( ! $city_term ) {
|
||||||
|
self::sync_error( 'could not create the page for city ' . $city['id'] );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result = wp_update_term(
|
||||||
|
$city_term,
|
||||||
|
$tax,
|
||||||
|
array(
|
||||||
|
'slug' => $city['slug'],
|
||||||
|
'parent' => $region['term_id'],
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
self::sync_error( 'city "' . $city['name'] . '": ' . $result->get_error_message() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset( $region );
|
||||||
|
|
||||||
|
// Regions that were removed: delete our own terms; an adopted Viator
|
||||||
|
// destination term just loses its region flag (it may carry products).
|
||||||
|
$kept = array_map( 'intval', array_column( $new, 'term_id' ) );
|
||||||
|
foreach ( $old as $region ) {
|
||||||
|
$term_id = (int) $region['term_id'];
|
||||||
|
if ( ! $term_id || in_array( $term_id, $kept, true ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( '' !== (string) get_term_meta( $term_id, '_vc_viator_destination_id', true ) ) {
|
||||||
|
delete_term_meta( $term_id, '_vc_region' );
|
||||||
|
} else {
|
||||||
|
wp_delete_term( $term_id, $tax );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cities that were removed go back to the top level.
|
||||||
|
$new_cities = VC_Structure::cities( $new );
|
||||||
|
foreach ( array_keys( VC_Structure::cities( $old ) ) as $id ) {
|
||||||
|
if ( isset( $new_cities[ (string) $id ] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$found = get_terms(
|
||||||
|
array(
|
||||||
|
'taxonomy' => $tax,
|
||||||
|
'hide_empty' => false,
|
||||||
|
'number' => 1,
|
||||||
|
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery
|
||||||
|
array(
|
||||||
|
'key' => '_vc_viator_destination_id',
|
||||||
|
'value' => (string) $id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( ! is_wp_error( $found ) && $found ) {
|
||||||
|
wp_update_term( $found[0]->term_id, $tax, array( 'parent' => 0 ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $new;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPress does not stop a Page from taking a region/city slug, so warn.
|
||||||
|
*/
|
||||||
|
public static function page_clash_notice() {
|
||||||
|
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
|
||||||
|
if ( ! $screen || 'post' !== $screen->base || 'page' !== $screen->post_type ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$post = get_post();
|
||||||
|
if ( ! $post || '' === $post->post_name || $post->post_parent ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$structure = VC_Structure::get();
|
||||||
|
$owner = '';
|
||||||
|
foreach ( $structure as $region ) {
|
||||||
|
if ( $region['slug'] === $post->post_name ) {
|
||||||
|
$owner = $region['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ( VC_Structure::cities( $structure ) as $city ) {
|
||||||
|
if ( $city['slug'] === $post->post_name ) {
|
||||||
|
$owner = $city['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( '' === $owner ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo '<div class="notice notice-warning"><p>' . esc_html( sprintf( __( 'This page\'s URL is also used by the Viator region/city "%s"; the Viator page will take priority.', 'viator-collector' ), $owner ) ) . '</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
221
viator-collector/includes/class-vc-structure.php
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client-managed region → city structure behind the /{region}/,
|
||||||
|
* /{city}/ and /{city}/{product}/ URLs.
|
||||||
|
*
|
||||||
|
* Everything here except get() is plain PHP so it can be unit-tested
|
||||||
|
* without WordPress (see tests/run.php).
|
||||||
|
*
|
||||||
|
* Shape: list of regions, each
|
||||||
|
* array( 'name', 'slug', 'term_id', 'cities' => list of array( 'id', 'name', 'slug' ) )
|
||||||
|
* where a city's 'id' is its Viator destination id.
|
||||||
|
*/
|
||||||
|
class VC_Structure {
|
||||||
|
const OPTION = 'vc_structure';
|
||||||
|
|
||||||
|
private static $cache = null;
|
||||||
|
|
||||||
|
public static function default_regions() {
|
||||||
|
$regions = array(
|
||||||
|
'uk' => 'UK',
|
||||||
|
'europe' => 'Europe',
|
||||||
|
'asia' => 'Asia',
|
||||||
|
'middle-east-and-africa' => 'Middle East and Africa',
|
||||||
|
'americas' => 'Americas',
|
||||||
|
'australasia' => 'Australasia',
|
||||||
|
);
|
||||||
|
$out = array();
|
||||||
|
foreach ( $regions as $slug => $name ) {
|
||||||
|
$out[] = array(
|
||||||
|
'name' => $name,
|
||||||
|
'slug' => $slug,
|
||||||
|
'term_id' => 0,
|
||||||
|
'cities' => array(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get() {
|
||||||
|
if ( null === self::$cache ) {
|
||||||
|
$value = get_option( self::OPTION, self::default_regions() );
|
||||||
|
self::$cache = is_array( $value ) ? $value : self::default_regions();
|
||||||
|
}
|
||||||
|
return self::$cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the per-request cache (after the option is saved).
|
||||||
|
*/
|
||||||
|
public static function flush_cache() {
|
||||||
|
self::$cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function slugify( $s ) {
|
||||||
|
$s = strtolower( (string) $s );
|
||||||
|
$s = preg_replace( '/[^a-z0-9]+/', '-', $s );
|
||||||
|
return trim( $s, '-' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape submitted form data into the structure. Admin-typed slugs are only
|
||||||
|
* trimmed/lowercased here — validate() rejects anything still invalid
|
||||||
|
* rather than silently rewriting it.
|
||||||
|
*/
|
||||||
|
public static function normalize( array $raw ) {
|
||||||
|
$out = array();
|
||||||
|
foreach ( $raw as $region ) {
|
||||||
|
if ( ! is_array( $region ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$name = trim( (string) ( $region['name'] ?? '' ) );
|
||||||
|
$slug = strtolower( trim( (string) ( $region['slug'] ?? '' ) ) );
|
||||||
|
$cities = array();
|
||||||
|
foreach ( (array) ( $region['cities'] ?? array() ) as $city ) {
|
||||||
|
$id = trim( (string) ( is_array( $city ) ? ( $city['id'] ?? '' ) : '' ) );
|
||||||
|
if ( '' === $id ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$city_name = trim( (string) ( $city['name'] ?? '' ) );
|
||||||
|
$city_slug = strtolower( trim( (string) ( $city['slug'] ?? '' ) ) );
|
||||||
|
$cities[] = array(
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $city_name,
|
||||||
|
'slug' => '' !== $city_slug ? $city_slug : self::slugify( $city_name ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$out[] = array(
|
||||||
|
'name' => $name,
|
||||||
|
'slug' => '' !== $slug ? $slug : self::slugify( $name ),
|
||||||
|
'term_id' => (int) ( $region['term_id'] ?? 0 ),
|
||||||
|
'cities' => $cities,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reserved_slugs() {
|
||||||
|
return array( 'wp-admin', 'wp-content', 'wp-includes', 'wp-json', 'feed', 'page', 'search', 'tag', 'category', 'author', 'tours', 'tour-destination', 'tour-tag', 'tour-category', 'blog', 'about', 'contact', 'faq', 'embed', 'attachment' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $destination_map Viator destination id => name.
|
||||||
|
* @param array $taken slug => label, for slugs owned outside the structure.
|
||||||
|
* @return string[] Errors; empty when valid.
|
||||||
|
*/
|
||||||
|
public static function validate( array $structure, array $destination_map, array $taken ) {
|
||||||
|
$errors = array();
|
||||||
|
$slugs = array();
|
||||||
|
$city_ids = array();
|
||||||
|
|
||||||
|
foreach ( $structure as $i => $region ) {
|
||||||
|
if ( '' === trim( (string) $region['name'] ) ) {
|
||||||
|
$errors[] = sprintf( 'Region %d has no name.', $i + 1 );
|
||||||
|
}
|
||||||
|
$slugs[] = (string) $region['slug'];
|
||||||
|
|
||||||
|
foreach ( $region['cities'] as $city ) {
|
||||||
|
$id = (string) $city['id'];
|
||||||
|
if ( ! isset( $destination_map[ $id ] ) ) {
|
||||||
|
$errors[] = sprintf( 'City ID %s is not a Viator destination.', $id );
|
||||||
|
}
|
||||||
|
if ( isset( $city_ids[ $id ] ) ) {
|
||||||
|
$errors[] = sprintf( '%s is listed more than once.', '' !== $city['name'] ? $city['name'] : $id );
|
||||||
|
}
|
||||||
|
$city_ids[ $id ] = true;
|
||||||
|
$slugs[] = (string) $city['slug'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reserved = self::reserved_slugs();
|
||||||
|
foreach ( array_count_values( $slugs ) as $slug => $count ) {
|
||||||
|
$slug = (string) $slug;
|
||||||
|
if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) {
|
||||||
|
$errors[] = sprintf( 'Slug "%s" is invalid: use only a-z, 0-9 and -.', $slug );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( in_array( $slug, $reserved, true ) ) {
|
||||||
|
$errors[] = sprintf( 'Slug "%s" is reserved.', $slug );
|
||||||
|
}
|
||||||
|
if ( $count > 1 ) {
|
||||||
|
$errors[] = sprintf( 'Slug "%s" is used more than once.', $slug );
|
||||||
|
}
|
||||||
|
if ( isset( $taken[ $slug ] ) ) {
|
||||||
|
$errors[] = sprintf( 'Slug "%s" is already used by %s.', $slug, $taken[ $slug ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values( array_unique( $errors ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configured cities in settings order: viator id => city + 'region' slug.
|
||||||
|
*/
|
||||||
|
public static function cities( array $structure ) {
|
||||||
|
$out = array();
|
||||||
|
foreach ( $structure as $region ) {
|
||||||
|
foreach ( $region['cities'] as $city ) {
|
||||||
|
$out[ (string) $city['id'] ] = $city + array( 'region' => $region['slug'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The city a product's URL lives under: its Viator primary destination
|
||||||
|
* when configured, else the first configured city it belongs to, else ''.
|
||||||
|
*/
|
||||||
|
public static function canonical_city( array $structure, array $dest_ids, $primary_id ) {
|
||||||
|
$cities = self::cities( $structure );
|
||||||
|
if ( '' !== (string) $primary_id && isset( $cities[ (string) $primary_id ] ) ) {
|
||||||
|
return $cities[ (string) $primary_id ]['slug'];
|
||||||
|
}
|
||||||
|
$dest_ids = array_map( 'strval', $dest_ids );
|
||||||
|
foreach ( $cities as $id => $city ) {
|
||||||
|
if ( in_array( (string) $id, $dest_ids, true ) ) {
|
||||||
|
return $city['slug'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function primary_destination_id( array $destinations ) {
|
||||||
|
foreach ( $destinations as $destination ) {
|
||||||
|
if ( is_array( $destination ) && ! empty( $destination['primary'] ) ) {
|
||||||
|
$ref = $destination['ref'] ?? ( $destination['destinationId'] ?? '' );
|
||||||
|
return (string) $ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit per-slug rules (no catch-alls), regions first, then cities.
|
||||||
|
* Only regions synced to a term (term_id set) are routed, so the unsaved
|
||||||
|
* presets never claim /europe/ etc. over existing pages. Archives get a
|
||||||
|
* /page/N/ rule because core's redirect_canonical turns ?paged=N into it.
|
||||||
|
*/
|
||||||
|
public static function rewrite_rules( array $structure ) {
|
||||||
|
$synced = array_values(
|
||||||
|
array_filter(
|
||||||
|
$structure,
|
||||||
|
function ( $region ) {
|
||||||
|
return (int) $region['term_id'] > 0;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$rules = array();
|
||||||
|
foreach ( $synced as $region ) {
|
||||||
|
$rules[ '^' . $region['slug'] . '/page/([0-9]+)/?$' ] = 'index.php?viator_destination=' . $region['slug'] . '&paged=$matches[1]';
|
||||||
|
$rules[ '^' . $region['slug'] . '/?$' ] = 'index.php?viator_destination=' . $region['slug'];
|
||||||
|
}
|
||||||
|
foreach ( self::cities( $synced ) as $city ) {
|
||||||
|
$rules[ '^' . $city['slug'] . '/page/([0-9]+)/?$' ] = 'index.php?viator_destination=' . $city['slug'] . '&paged=$matches[1]';
|
||||||
|
$rules[ '^' . $city['slug'] . '/?$' ] = 'index.php?viator_destination=' . $city['slug'];
|
||||||
|
$rules[ '^' . $city['slug'] . '/([^/]+)/?$' ] = 'index.php?viator_tour=$matches[1]';
|
||||||
|
}
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,34 @@ function vc_hero_image_url() {
|
|||||||
return apply_filters( 'vc_hero_image_url', $url );
|
return apply_filters( 'vc_hero_image_url', $url );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hero image *source* for the current view: an attachment ID when available
|
||||||
|
* (so it can be rendered responsively), otherwise a URL. Resolution order:
|
||||||
|
* 1. On a term archive, that term's own image.
|
||||||
|
* 2. The "Hero image" chosen in Settings (attachment ID preferred).
|
||||||
|
* 3. Nothing.
|
||||||
|
*
|
||||||
|
* @return int|string Attachment ID, image URL, or '' .
|
||||||
|
*/
|
||||||
|
function vc_hero_image_source() {
|
||||||
|
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 ) {
|
||||||
|
return apply_filters( 'vc_hero_image_source', $url );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) vc_get_option( 'hero_image_id', 0 );
|
||||||
|
if ( $id ) {
|
||||||
|
return apply_filters( 'vc_hero_image_source', $id );
|
||||||
|
}
|
||||||
|
|
||||||
|
return apply_filters( 'vc_hero_image_source', (string) vc_get_option( 'hero_image', '' ) );
|
||||||
|
}
|
||||||
|
|
||||||
function vc_term_image_url( $term_id, $taxonomy = '' ) {
|
function vc_term_image_url( $term_id, $taxonomy = '' ) {
|
||||||
$term_id = absint( $term_id );
|
$term_id = absint( $term_id );
|
||||||
if ( ! $term_id ) {
|
if ( ! $term_id ) {
|
||||||
@@ -506,6 +534,45 @@ function vc_asset_url( $rel ) {
|
|||||||
return VC_PLUGIN_URL . 'assets/' . ltrim( (string) $rel, '/' );
|
return VC_PLUGIN_URL . 'assets/' . ltrim( (string) $rel, '/' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hero <img> markup. When the source is a WordPress media item (an attachment
|
||||||
|
* ID, or a URL that resolves to one), it is rendered with wp_get_attachment_image
|
||||||
|
* so the browser gets responsive `srcset`/`sizes` and device-appropriate files.
|
||||||
|
* Anything else (external URLs, bundled fallbacks) falls back to a plain <img>.
|
||||||
|
*
|
||||||
|
* @param int|string $src Attachment ID or image URL.
|
||||||
|
* @param string $class CSS class.
|
||||||
|
* @param bool $eager Prioritise loading (above-the-fold hero).
|
||||||
|
*/
|
||||||
|
function vc_hero_img_html( $src, $class = 'hero__img', $eager = true ) {
|
||||||
|
$loading = $eager ? 'eager' : 'lazy';
|
||||||
|
|
||||||
|
$id = is_numeric( $src ) ? (int) $src : (int) attachment_url_to_postid( (string) $src );
|
||||||
|
if ( $id ) {
|
||||||
|
$img = wp_get_attachment_image(
|
||||||
|
$id,
|
||||||
|
'full',
|
||||||
|
false,
|
||||||
|
array(
|
||||||
|
'class' => $class,
|
||||||
|
'loading' => $loading,
|
||||||
|
'alt' => '',
|
||||||
|
'sizes' => '100vw',
|
||||||
|
'fetchpriority' => $eager ? 'high' : 'auto',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( $img ) {
|
||||||
|
return $img;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! $src ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$fetch = $eager ? ' fetchpriority="high"' : '';
|
||||||
|
return '<img class="' . esc_attr( $class ) . '" src="' . esc_url( $src ) . '" alt="" loading="' . esc_attr( $loading ) . '"' . $fetch . '>';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print the "Hop On Hop Off" SVG icon sprite once per request. The sprite
|
* 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
|
* holds every <symbol id="i-…"> plus the #ringPathScroll circular text path
|
||||||
@@ -528,7 +595,10 @@ function vc_print_icon_sprite() {
|
|||||||
* e.g. vc_icon( 'star', 'rating__star' ) => <svg class="icon rating__star">…</svg>
|
* e.g. vc_icon( 'star', 'rating__star' ) => <svg class="icon rating__star">…</svg>
|
||||||
*/
|
*/
|
||||||
function vc_icon( $name, $extra_class = '' ) {
|
function vc_icon( $name, $extra_class = '' ) {
|
||||||
$name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . ltrim( (string) $name, 'i-' ) );
|
// Strip an existing "i-" prefix (once), then re-add it. ltrim() with a
|
||||||
|
// charlist would eat any leading i/-, turning "instagram" into "nstagram".
|
||||||
|
$slug = preg_replace( '/^i-/', '', (string) $name );
|
||||||
|
$name = preg_replace( '/[^a-z0-9\-]/', '', 'i-' . $slug );
|
||||||
$class = 'icon' . ( $extra_class ? ' ' . $extra_class : '' );
|
$class = 'icon' . ( $extra_class ? ' ' . $extra_class : '' );
|
||||||
return '<svg class="' . esc_attr( $class ) . '" aria-hidden="true"><use href="#' . esc_attr( $name ) . '"></use></svg>';
|
return '<svg class="' . esc_attr( $class ) . '" aria-hidden="true"><use href="#' . esc_attr( $name ) . '"></use></svg>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
* Filtering stays server-side: every control lives inside #vc-filter-form and
|
* Filtering stays server-side: every control lives inside #vc-filter-form and
|
||||||
* submits via GET. VC_Display::filter_archive_query() reads the same state.
|
* submits via GET. VC_Display::filter_archive_query() reads the same state.
|
||||||
*
|
*
|
||||||
* Args: $vc_title, $vc_subtitle, $vc_query, $vc_form_action.
|
* 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).
|
||||||
*/
|
*/
|
||||||
defined( 'ABSPATH' ) || exit;
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
$vc_after_hero = isset( $vc_after_hero ) ? (string) $vc_after_hero : '';
|
||||||
|
|
||||||
$f = VC_Display::get_filter_state();
|
$f = VC_Display::get_filter_state();
|
||||||
|
|
||||||
$vc_title = isset( $vc_title ) ? $vc_title : __( 'Explore Tours', 'viator-collector' );
|
$vc_title = isset( $vc_title ) ? $vc_title : __( 'Explore Tours', 'viator-collector' );
|
||||||
@@ -75,14 +78,11 @@ $vc_page_url = function ( $n ) use ( $vc_action, $vc_base_args ) {
|
|||||||
return empty( $args ) ? $vc_action : add_query_arg( $args, $vc_action );
|
return empty( $args ) ? $vc_action : add_query_arg( $args, $vc_action );
|
||||||
};
|
};
|
||||||
|
|
||||||
$vc_hero_img = vc_hero_image_url();
|
$vc_hero_img = vc_hero_image_source();
|
||||||
if ( ! $vc_hero_img ) {
|
|
||||||
$vc_hero_img = vc_asset_url( 'img/hero2.png' );
|
|
||||||
}
|
|
||||||
?>
|
?>
|
||||||
<section class="hero hero--archive">
|
<section class="hero hero--archive">
|
||||||
<div class="hero__media">
|
<div class="hero__media">
|
||||||
<img class="hero__img" src="<?php echo esc_url( $vc_hero_img ); ?>" alt="" fetchpriority="high">
|
<?php echo vc_hero_img_html( $vc_hero_img, 'hero__img', true ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||||
<div class="hero__scrim" aria-hidden="true"></div>
|
<div class="hero__scrim" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,6 +182,8 @@ if ( ! $vc_hero_img ) {
|
|||||||
<?php echo vc_ring_badge( 'SCROLL DOWN · SCROLL DOWN · ', '#tour-list', 'scroll' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
<?php echo vc_ring_badge( 'SCROLL DOWN · SCROLL DOWN · ', '#tour-list', 'scroll' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<?php echo $vc_after_hero; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built by the calling template. ?>
|
||||||
|
|
||||||
<section class="section" id="tour-list">
|
<section class="section" id="tour-list">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="results">
|
<div class="results">
|
||||||
|
|||||||
73
viator-collector/templates/region.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Region landing page (/{region}/): hero, a grid of the region's cities in
|
||||||
|
* Viator Structure order, then every tour in those cities — the main query
|
||||||
|
* on a parent term already includes its child terms.
|
||||||
|
*/
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Child city terms, ordered as on the Viator Structure screen.
|
||||||
|
$vc_cities = array();
|
||||||
|
$vc_order = array_keys( VC_Structure::cities( VC_Structure::get() ) );
|
||||||
|
$vc_terms = get_terms(
|
||||||
|
array(
|
||||||
|
'taxonomy' => 'viator_destination',
|
||||||
|
'parent' => $vc_term ? (int) $vc_term->term_id : 0,
|
||||||
|
'hide_empty' => false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
foreach ( is_wp_error( $vc_terms ) ? array() : $vc_terms as $vc_city ) {
|
||||||
|
$vc_pos = array_search( (string) get_term_meta( $vc_city->term_id, '_vc_viator_destination_id', true ), array_map( 'strval', $vc_order ), true );
|
||||||
|
$vc_cities[ $vc_pos === false ? PHP_INT_MAX - count( $vc_cities ) : $vc_pos ] = $vc_city;
|
||||||
|
}
|
||||||
|
ksort( $vc_cities );
|
||||||
|
|
||||||
|
$vc_after_hero = '';
|
||||||
|
if ( $vc_cities ) {
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<section class="vc-section vc-section--destinations">
|
||||||
|
<div class="vc-container">
|
||||||
|
<header class="vc-section__head vc-reveal">
|
||||||
|
<?php /* translators: %s: region name. */ ?>
|
||||||
|
<h2 class="vc-section__title"><?php echo esc_html( sprintf( __( 'Cities in %s', 'viator-collector' ), $vc_name ) ); ?></h2>
|
||||||
|
</header>
|
||||||
|
<div class="vc-destination-grid">
|
||||||
|
<?php
|
||||||
|
foreach ( $vc_cities as $vc_city ) {
|
||||||
|
echo vc_get_template( 'card-destination.php', array( 'term' => $vc_city ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php
|
||||||
|
$vc_after_hero = ob_get_clean();
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<main class="vc-app vc-listing vc-listing--term vc-listing--region">
|
||||||
|
<?php
|
||||||
|
echo vc_get_template( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
|
'listing.php',
|
||||||
|
array(
|
||||||
|
'vc_title' => $vc_name,
|
||||||
|
/* translators: %s: region name. */
|
||||||
|
'vc_subtitle' => $vc_desc ? wp_strip_all_tags( $vc_desc ) : sprintf( __( 'Explore the best tours in %s.', 'viator-collector' ), $vc_name ),
|
||||||
|
'vc_after_hero' => $vc_after_hero,
|
||||||
|
'vc_crumb' => array(
|
||||||
|
array(
|
||||||
|
'label' => __( 'Tours', 'viator-collector' ),
|
||||||
|
'url' => get_post_type_archive_link( 'viator_tour' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</main>
|
||||||
|
<?php
|
||||||
|
get_footer();
|
||||||
@@ -38,16 +38,30 @@ while ( have_posts() ) :
|
|||||||
?>
|
?>
|
||||||
<div class="vc-app vc-single">
|
<div class="vc-app vc-single">
|
||||||
<section class="hero-tour">
|
<section class="hero-tour">
|
||||||
<?php if ( $primary ) : ?>
|
<?php
|
||||||
|
// Prefer a WordPress featured image (full size, responsive srcset) for a
|
||||||
|
// sharp hero — Viator's own image URLs cap out around 720px and look soft
|
||||||
|
// blown up to a full-width banner. Fall back to the Viator primary image.
|
||||||
|
if ( has_post_thumbnail( $post_id ) ) :
|
||||||
|
echo get_the_post_thumbnail( $post_id, 'full', array( 'class' => 'hero-tour__photo', 'fetchpriority' => 'high', 'alt' => '', 'sizes' => '100vw' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
|
elseif ( $primary ) :
|
||||||
|
?>
|
||||||
<img class="hero-tour__photo" src="<?php echo esc_url( $primary ); ?>" alt="" fetchpriority="high">
|
<img class="hero-tour__photo" src="<?php echo esc_url( $primary ); ?>" alt="" fetchpriority="high">
|
||||||
<?php endif; ?>
|
<?php
|
||||||
|
endif;
|
||||||
|
?>
|
||||||
<div class="hero-tour__sky" aria-hidden="true"></div>
|
<div class="hero-tour__sky" aria-hidden="true"></div>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="hero-tour__block">
|
<div class="hero-tour__block">
|
||||||
<p class="hero-tour__kicker"><?php echo esc_html__( 'Welcome to', 'viator-collector' ); ?></p>
|
<p class="hero-tour__kicker"><?php echo esc_html__( 'Welcome to', 'viator-collector' ); ?></p>
|
||||||
<div class="hero-tour__stage">
|
<div class="hero-tour__stage">
|
||||||
<h1 class="hero-tour__word"><?php echo esc_html( $big_word ); ?></h1>
|
<h1 class="hero-tour__word"><?php echo esc_html( $big_word ); ?></h1>
|
||||||
<img class="hero-tour__figure" src="<?php echo esc_url( vc_asset_url( 'img/tower.png' ) ); ?>" alt="" loading="eager">
|
<?php
|
||||||
|
$vc_figure_id = (int) vc_get_option( 'single_hero_figure_id', 0 );
|
||||||
|
if ( $vc_figure_id ) {
|
||||||
|
echo wp_get_attachment_image( $vc_figure_id, 'medium_large', false, array( 'class' => 'hero-tour__figure', 'loading' => 'eager', 'alt' => '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||||
|
}
|
||||||
|
?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
viator-collector/tests/run.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Minimal test runner for the WordPress-free parts of the plugin.
|
||||||
|
*
|
||||||
|
* php tests/run.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
define( 'ABSPATH', __DIR__ . '/' );
|
||||||
|
|
||||||
|
require_once dirname( __DIR__ ) . '/includes/class-vc-structure.php';
|
||||||
|
|
||||||
|
function vc_assert_same( $expected, $actual, $msg = '' ) {
|
||||||
|
if ( $expected !== $actual ) {
|
||||||
|
throw new Exception( $msg . "\n expected: " . var_export( $expected, true ) . "\n actual: " . var_export( $actual, true ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function vc_assert_contains( $needle, array $errors ) {
|
||||||
|
if ( ! in_array( $needle, $errors, true ) ) {
|
||||||
|
throw new Exception( 'Missing error: ' . $needle . "\n got: " . var_export( $errors, true ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( glob( __DIR__ . '/test-*.php' ) as $file ) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
$passed = 0;
|
||||||
|
$failed = 0;
|
||||||
|
foreach ( get_defined_functions()['user'] as $fn ) {
|
||||||
|
if ( 0 !== strpos( $fn, 'test_' ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$fn();
|
||||||
|
$passed++;
|
||||||
|
} catch ( Throwable $e ) {
|
||||||
|
$failed++;
|
||||||
|
echo 'FAIL ' . $fn . ': ' . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $passed . ' passed, ' . $failed . " failed\n";
|
||||||
|
exit( $failed ? 1 : 0 );
|
||||||
173
viator-collector/tests/test-structure.php
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* VC_Structure unit tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function vc_fixture_structure() {
|
||||||
|
return array(
|
||||||
|
array(
|
||||||
|
'name' => 'Europe',
|
||||||
|
'slug' => 'europe',
|
||||||
|
'term_id' => 0,
|
||||||
|
'cities' => array(
|
||||||
|
array( 'id' => '737', 'name' => 'Amsterdam', 'slug' => 'amsterdam' ),
|
||||||
|
array( 'id' => '479', 'name' => 'Paris', 'slug' => 'paris' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'name' => 'Americas',
|
||||||
|
'slug' => 'americas',
|
||||||
|
'term_id' => 0,
|
||||||
|
'cities' => array(
|
||||||
|
array( 'id' => '678', 'name' => 'Boston', 'slug' => 'boston' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vc_fixture_map() {
|
||||||
|
return array( '737' => 'Amsterdam', '479' => 'Paris', '678' => 'Boston', '22' => 'Victoria' );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_default_regions() {
|
||||||
|
$regions = VC_Structure::default_regions();
|
||||||
|
vc_assert_same(
|
||||||
|
array( 'uk', 'europe', 'asia', 'middle-east-and-africa', 'americas', 'australasia' ),
|
||||||
|
array_column( $regions, 'slug' )
|
||||||
|
);
|
||||||
|
vc_assert_same( 'Middle East and Africa', $regions[3]['name'] );
|
||||||
|
vc_assert_same( array(), $regions[0]['cities'] );
|
||||||
|
vc_assert_same( 0, $regions[0]['term_id'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_normalize_fills_slugs() {
|
||||||
|
$s = VC_Structure::normalize(
|
||||||
|
array(
|
||||||
|
array(
|
||||||
|
'name' => ' Middle East and Africa ',
|
||||||
|
'slug' => '',
|
||||||
|
'term_id' => '7',
|
||||||
|
'cities' => array(
|
||||||
|
array( 'id' => '', 'name' => 'Ghost', 'slug' => 'ghost' ),
|
||||||
|
array( 'id' => '828', 'name' => 'Dubai', 'slug' => '' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
vc_assert_same( 'Middle East and Africa', $s[0]['name'] );
|
||||||
|
vc_assert_same( 'middle-east-and-africa', $s[0]['slug'] );
|
||||||
|
vc_assert_same( 7, $s[0]['term_id'] );
|
||||||
|
vc_assert_same( array( array( 'id' => '828', 'name' => 'Dubai', 'slug' => 'dubai' ) ), $s[0]['cities'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_validate_ok() {
|
||||||
|
vc_assert_same( array(), VC_Structure::validate( vc_fixture_structure(), vc_fixture_map(), array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_validate_unknown_city() {
|
||||||
|
$s = vc_fixture_structure();
|
||||||
|
$s[1]['cities'][0]['id'] = '999';
|
||||||
|
vc_assert_contains( 'City ID 999 is not a Viator destination.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_validate_duplicate_city() {
|
||||||
|
$s = vc_fixture_structure();
|
||||||
|
$s[1]['cities'][] = array( 'id' => '737', 'name' => 'Amsterdam', 'slug' => 'amsterdam-2' );
|
||||||
|
vc_assert_contains( 'Amsterdam is listed more than once.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_validate_duplicate_slug() {
|
||||||
|
$s = vc_fixture_structure();
|
||||||
|
$s[1]['slug'] = 'paris';
|
||||||
|
vc_assert_contains( 'Slug "paris" is used more than once.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_validate_bad_slug() {
|
||||||
|
$s = vc_fixture_structure();
|
||||||
|
$s[0]['cities'][0]['slug'] = 'amsterdam!';
|
||||||
|
$s[1]['cities'][0]['slug'] = 'New York';
|
||||||
|
$errors = VC_Structure::validate( $s, vc_fixture_map(), array() );
|
||||||
|
vc_assert_contains( 'Slug "amsterdam!" is invalid: use only a-z, 0-9 and -.', $errors );
|
||||||
|
vc_assert_contains( 'Slug "New York" is invalid: use only a-z, 0-9 and -.', $errors );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_validate_reserved() {
|
||||||
|
$s = vc_fixture_structure();
|
||||||
|
$s[0]['cities'][0]['slug'] = 'contact';
|
||||||
|
vc_assert_contains( 'Slug "contact" is reserved.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_validate_taken_slug() {
|
||||||
|
$s = vc_fixture_structure();
|
||||||
|
$s[1]['cities'][] = array( 'id' => '22', 'name' => 'Victoria', 'slug' => 'victoria' );
|
||||||
|
$errors = VC_Structure::validate( $s, vc_fixture_map(), array( 'victoria' => 'destination "Victoria"' ) );
|
||||||
|
vc_assert_contains( 'Slug "victoria" is already used by destination "Victoria".', $errors );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_validate_empty_region() {
|
||||||
|
$s = vc_fixture_structure();
|
||||||
|
$s[0]['name'] = '';
|
||||||
|
vc_assert_contains( 'Region 1 has no name.', VC_Structure::validate( $s, vc_fixture_map(), array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_canonical_city_primary() {
|
||||||
|
vc_assert_same( 'paris', VC_Structure::canonical_city( vc_fixture_structure(), array( '479', '737' ), '479' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_canonical_city_fallback_order() {
|
||||||
|
vc_assert_same( 'amsterdam', VC_Structure::canonical_city( vc_fixture_structure(), array( '678', '479', '737' ), '5555' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_canonical_city_none() {
|
||||||
|
vc_assert_same( '', VC_Structure::canonical_city( vc_fixture_structure(), array( '5555' ), '' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_primary_destination_id() {
|
||||||
|
vc_assert_same( '2', VC_Structure::primary_destination_id( array( array( 'ref' => '1' ), array( 'ref' => '2', 'primary' => true ) ) ) );
|
||||||
|
vc_assert_same( '', VC_Structure::primary_destination_id( array() ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function vc_fixture_synced() {
|
||||||
|
$s = vc_fixture_structure();
|
||||||
|
$s[0]['term_id'] = 11;
|
||||||
|
$s[1]['term_id'] = 12;
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_rewrite_rules() {
|
||||||
|
vc_assert_same(
|
||||||
|
array(
|
||||||
|
'^europe/page/([0-9]+)/?$' => 'index.php?viator_destination=europe&paged=$matches[1]',
|
||||||
|
'^europe/?$' => 'index.php?viator_destination=europe',
|
||||||
|
'^americas/page/([0-9]+)/?$' => 'index.php?viator_destination=americas&paged=$matches[1]',
|
||||||
|
'^americas/?$' => 'index.php?viator_destination=americas',
|
||||||
|
'^amsterdam/page/([0-9]+)/?$' => 'index.php?viator_destination=amsterdam&paged=$matches[1]',
|
||||||
|
'^amsterdam/?$' => 'index.php?viator_destination=amsterdam',
|
||||||
|
'^amsterdam/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]',
|
||||||
|
'^paris/page/([0-9]+)/?$' => 'index.php?viator_destination=paris&paged=$matches[1]',
|
||||||
|
'^paris/?$' => 'index.php?viator_destination=paris',
|
||||||
|
'^paris/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]',
|
||||||
|
'^boston/page/([0-9]+)/?$' => 'index.php?viator_destination=boston&paged=$matches[1]',
|
||||||
|
'^boston/?$' => 'index.php?viator_destination=boston',
|
||||||
|
'^boston/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]',
|
||||||
|
),
|
||||||
|
VC_Structure::rewrite_rules( vc_fixture_synced() )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_rewrite_rules_empty_region() {
|
||||||
|
$rules = VC_Structure::rewrite_rules( array( array( 'name' => 'Europe', 'slug' => 'europe', 'term_id' => 11, 'cities' => array() ) ) );
|
||||||
|
vc_assert_same(
|
||||||
|
array(
|
||||||
|
'^europe/page/([0-9]+)/?$' => 'index.php?viator_destination=europe&paged=$matches[1]',
|
||||||
|
'^europe/?$' => 'index.php?viator_destination=europe',
|
||||||
|
),
|
||||||
|
$rules
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before the structure is ever saved, get() returns the unsynced presets:
|
||||||
|
// they must not claim /uk/, /europe/, … over existing pages.
|
||||||
|
function test_rewrite_rules_skip_unsynced_regions() {
|
||||||
|
vc_assert_same( array(), VC_Structure::rewrite_rules( VC_Structure::default_regions() ) );
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ defined('ABSPATH') || exit;
|
|||||||
/**
|
/**
|
||||||
* Plugin Name: Viator Collector
|
* Plugin Name: Viator Collector
|
||||||
* Description: Import Viator tours using the Viator Partner API and display them with native WordPress post types, templates, and shortcodes.
|
* Description: Import Viator tours using the Viator Partner API and display them with native WordPress post types, templates, and shortcodes.
|
||||||
* Version: 1.8.0
|
* Version: 1.9.0
|
||||||
* Author: Amir Bagheri
|
* Author: Amir Bagheri
|
||||||
* License: GPL-2.0-or-later
|
* License: GPL-2.0-or-later
|
||||||
* Text Domain: viator-collector
|
* Text Domain: viator-collector
|
||||||
*/
|
*/
|
||||||
|
|
||||||
define('VC_VERSION', '1.8.0');
|
define('VC_VERSION', '1.9.0');
|
||||||
define('VC_PLUGIN_FILE', __FILE__);
|
define('VC_PLUGIN_FILE', __FILE__);
|
||||||
define('VC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('VC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('VC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('VC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
|||||||