From 9c707b70753da20aeeda4937e443f5a5307c06ee Mon Sep 17 00:00:00 2001 From: Amir Bagheri Date: Thu, 29 Oct 2026 15:22:26 +0330 Subject: [PATCH] feat(structure): admin screen with validated save and term sync Co-Authored-By: Claude Opus 5.5 --- .../assets/js/vc-structure-admin.js | 162 +++++++ viator-collector/includes/class-vc-plugin.php | 2 + .../includes/class-vc-structure-admin.php | 436 ++++++++++++++++++ 3 files changed, 600 insertions(+) create mode 100644 viator-collector/assets/js/vc-structure-admin.js create mode 100644 viator-collector/includes/class-vc-structure-admin.php 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 + ?> +
+

+

+ + +

+ + +
+

+
    + +
  • + +
+
+ + + + +
+ + +
+ +
+

+ +
+ + + +
+ +
+
+ + + + + + + + +
+ + +
+ +
+ + + + + (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 ) ) . '

'; + } +}