diff --git a/viator-collector/assets/js/vc-structure-admin.js b/viator-collector/assets/js/vc-structure-admin.js
new file mode 100644
index 0000000..1547fa3
--- /dev/null
+++ b/viator-collector/assets/js/vc-structure-admin.js
@@ -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 + ']';
+ });
+ });
+ });
+ });
+})();
diff --git a/viator-collector/includes/class-vc-plugin.php b/viator-collector/includes/class-vc-plugin.php
index ee4d117..21d4ca2 100644
--- a/viator-collector/includes/class-vc-plugin.php
+++ b/viator-collector/includes/class-vc-plugin.php
@@ -19,6 +19,7 @@ final class VC_Plugin {
VC_CPT::init();
VC_Taxonomies::init();
VC_Settings::init();
+ VC_Structure_Admin::init();
VC_Cron::init();
VC_Display::init();
VC_Sections::init();
@@ -35,6 +36,7 @@ final class VC_Plugin {
'includes/class-vc-importer.php',
'includes/class-vc-cron.php',
'includes/class-vc-settings.php',
+ 'includes/class-vc-structure-admin.php',
'includes/class-vc-display.php',
'includes/class-vc-sections.php',
);
diff --git a/viator-collector/includes/class-vc-structure-admin.php b/viator-collector/includes/class-vc-structure-admin.php
new file mode 100644
index 0000000..590ba76
--- /dev/null
+++ b/viator-collector/includes/class-vc-structure-admin.php
@@ -0,0 +1,436 @@
+ 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
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
'', 'slug' => '', 'term_id' => 0, 'cities' => array() ) ); ?>
+
'', 'name' => '', 'slug' => '' ) ); ?>
+
+
+
+
+
+
+
+ (ID )
+
+
+
+ / /
+
+ ↑
+ ↓
+
+
+
+ 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 '' . 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 ) ) . '
';
+ }
+}