feat(structure): admin screen with validated save and term sync
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
162
viator-collector/assets/js/vc-structure-admin.js
Normal file
162
viator-collector/assets/js/vc-structure-admin.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* 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 = 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
|
list.querySelectorAll('.vc-region').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 + ']';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -19,6 +19,7 @@ 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_Cron::init();
|
VC_Cron::init();
|
||||||
VC_Display::init();
|
VC_Display::init();
|
||||||
VC_Sections::init();
|
VC_Sections::init();
|
||||||
@@ -35,6 +36,7 @@ final class VC_Plugin {
|
|||||||
'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-display.php',
|
'includes/class-vc-display.php',
|
||||||
'includes/class-vc-sections.php',
|
'includes/class-vc-sections.php',
|
||||||
);
|
);
|
||||||
|
|||||||
436
viator-collector/includes/class-vc-structure-admin.php
Normal file
436
viator-collector/includes/class-vc-structure-admin.php
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
<?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';
|
||||||
|
|
||||||
|
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' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'];
|
||||||
|
}
|
||||||
|
$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 notice-success is-dismissible"><p><?php echo esc_html( $notice ); ?></p></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 ( 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,
|
||||||
|
'structure' => $structure,
|
||||||
|
),
|
||||||
|
10 * MINUTE_IN_SECONDS
|
||||||
|
);
|
||||||
|
self::redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 ) ) ) );
|
||||||
|
self::redirect( __( 'Structure saved.', 'viator-collector' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ) ) {
|
||||||
|
VC_Logger::error( 'Structure: 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 ) {
|
||||||
|
VC_Logger::error( 'Structure: could not create city term for ' . $city['id'] );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result = wp_update_term(
|
||||||
|
$city_term,
|
||||||
|
$tax,
|
||||||
|
array(
|
||||||
|
'slug' => $city['slug'],
|
||||||
|
'parent' => $region['term_id'],
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
VC_Logger::error( 'Structure: 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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user