Files
hoponhopofftickets/viator-collector/includes/class-vc-structure.php
Amir Bagheri 930b7e0534 fix: paginated region/city pages, no routes before first save, safer structure save
- Add /{slug}/page/N/ rules and keep the page number through 301s
- Route only regions synced to a term, so unsaved presets never claim /europe/ etc.
- Flush rewrites on the request after activation so structure routes survive
- Suggest the existing term slug for new cities (keeps live /tour-destination/ URLs)
- Refuse saves without the editor sentinel; confirm before saving an empty structure
- Show term-sync failures to the admin instead of reporting success

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:33:01 +03:30

222 lines
7.1 KiB
PHP

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