Files
hoponhopofftickets/viator-collector/includes/class-vc-structure-admin.php

437 lines
16 KiB
PHP

<?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>';
}
}