docs: region/city URL design and plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,293 @@
|
|||||||
|
# Region / City / Product URLs Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Serve regions at `/{region}/`, cities at `/{city}/` and products at `/{city}/{product}/`. The client manages the region → city structure from a validated settings screen.
|
||||||
|
|
||||||
|
**Architecture:** A pure-PHP `VC_Structure` class holds the structure, validation, canonical-city choice and rewrite-rule generation, and is unit-tested without WordPress. Thin WordPress layers sit on top of it:
|
||||||
|
- `VC_Structure_Admin`: settings screen, save, sync to `viator_destination` terms
|
||||||
|
- `VC_Routing`: rewrite rules, link filters, 301s
|
||||||
|
- `VC_Menu`: region items in the header menu
|
||||||
|
|
||||||
|
These layers are verified by `php -l` and a manual QA pass on a WordPress install.
|
||||||
|
|
||||||
|
**Tech Stack:** WordPress, PHP 8 (local CLI is PHP 8.4), plugin `viator-collector`, child theme `astra-child-hoponhopoff`. No Composer/PHPUnit: tests use a tiny runner at `viator-collector/tests/run.php`.
|
||||||
|
|
||||||
|
**Spec:** `viator-collector/docs/superpowers/specs/2026-10-29-region-city-urls-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Preset regions, in order: UK `uk`, Europe `europe`, Asia `asia`, Middle East and Africa `middle-east-and-africa`, Americas `americas`, Australasia `australasia`.
|
||||||
|
- Valid slug: `^[a-z0-9]+(?:-[a-z0-9]+)*$`.
|
||||||
|
- Reserved slugs: `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`.
|
||||||
|
- Term meta: region flag `_vc_region` = `1`; city link `_vc_viator_destination_id` (already exists).
|
||||||
|
- Post meta: `_vc_primary_destination` (Viator destination ref string).
|
||||||
|
- Options: `vc_structure` (structure array), `vc_flush_rewrite` (flag).
|
||||||
|
- All redirects are 301 and keep the query string.
|
||||||
|
- Match plugin style: static classes, `VC_` prefix, tabs, `vc_get_option`, `VC_Logger`, text domain `viator-collector`.
|
||||||
|
- Work on branch `feature/region-city-urls`, not `main`.
|
||||||
|
- `tests/` and `docs/` never ship in the zip.
|
||||||
|
|
||||||
|
**Structure array shape** (used everywhere):
|
||||||
|
```php
|
||||||
|
array(
|
||||||
|
array(
|
||||||
|
'name' => 'Europe',
|
||||||
|
'slug' => 'europe',
|
||||||
|
'term_id' => 12, // 0 until synced
|
||||||
|
'cities' => array(
|
||||||
|
array( 'id' => '737', 'name' => 'Amsterdam', 'slug' => 'amsterdam' ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
1. A city slug equal to an **unconfigured** existing destination term's slug (e.g. two Viator "Victoria"s) must be a validation error, not a silent `wp_update_term` failure. Covered by `test_validate_taken_slug` (Task 1).
|
||||||
|
2. A product whose Viator primary destination is not configured but which belongs to two configured cities must use the first city in settings order. Covered by `test_canonical_city_fallback_order` (Task 1).
|
||||||
|
3. A product in no configured city keeps `/tours/{slug}/`: `canonical_city()` returns `''`. Covered by `test_canonical_city_none` (Task 1).
|
||||||
|
4. An admin-typed slug like `New York` or `amsterdam!` must be rejected, not silently changed. Covered by `test_validate_bad_slug` (Task 1).
|
||||||
|
5. Filter and sort query strings (`?dest=…&sort=…&paged=2`) must survive every 301. Covered by manual check M6 (Task 7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `VC_Structure` core and test runner
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `viator-collector/includes/class-vc-structure.php`
|
||||||
|
- Create: `viator-collector/tests/run.php`, `viator-collector/tests/test-structure.php`
|
||||||
|
- Modify: `build-zip.php` (add `'tests'` to `$excluded_dirs`)
|
||||||
|
|
||||||
|
**Interfaces (Produces):** class `VC_Structure`, all `public static`:
|
||||||
|
- `const OPTION = 'vc_structure';`
|
||||||
|
- `default_regions(): array`: the six presets, `term_id` 0, no cities.
|
||||||
|
- `get(): array`: `get_option( self::OPTION, self::default_regions() )`, cached in a static per request. Untested; WordPress only.
|
||||||
|
- `normalize( array $raw ): array`
|
||||||
|
- Shape POST data into the structure shape: trim names, `trim(strtolower())` slugs, cast `term_id` to int and `id` to a digit string, drop city rows with an empty `id`, reindex.
|
||||||
|
- An empty region slug becomes `slugify( name )`. An empty city slug becomes `slugify( city name )`.
|
||||||
|
- `slugify( string $s ): string`: lowercase, runs of non `[a-z0-9]` become `-`, trim `-`.
|
||||||
|
- `reserved_slugs(): array`: the list in Global Constraints.
|
||||||
|
- `validate( array $structure, array $destination_map, array $taken ): array`
|
||||||
|
- `$destination_map` is Viator `id => name`. `$taken` is `slug => human label` for slugs owned by something outside the structure.
|
||||||
|
- Returns a list of error strings, empty when valid. Messages are exact:
|
||||||
|
- `Region %d has no name.` (1-based)
|
||||||
|
- `City ID %s is not a Viator destination.`
|
||||||
|
- `%s is listed more than once.` (city name)
|
||||||
|
- `Slug "%s" is invalid: use only a-z, 0-9 and -.`
|
||||||
|
- `Slug "%s" is reserved.`
|
||||||
|
- `Slug "%s" is used more than once.`
|
||||||
|
- `Slug "%s" is already used by %s.`
|
||||||
|
- `cities( array $structure ): array`: ordered `viator_id => city + array( 'region' => region slug )`.
|
||||||
|
- `canonical_city( array $structure, array $dest_ids, string $primary_id ): string`
|
||||||
|
- Returns the city slug for `$primary_id` if it is configured.
|
||||||
|
- Otherwise returns the slug of the first configured city (in `cities()` order) that appears in `$dest_ids`.
|
||||||
|
- Otherwise returns `''`.
|
||||||
|
- `primary_destination_id( array $destinations ): string`: `ref` (or `destinationId`) of the entry with truthy `primary`, else `''`.
|
||||||
|
- `rewrite_rules( array $structure ): array`, regex => query, in this order:
|
||||||
|
- each region: `'^europe/?$' => 'index.php?viator_destination=europe'`
|
||||||
|
- each city: `'^amsterdam/?$' => 'index.php?viator_destination=amsterdam'` and `'^amsterdam/([^/]+)/?$' => 'index.php?viator_tour=$matches[1]'`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the runner.** `tests/run.php` defines `ABSPATH`, requires `includes/class-vc-structure.php` and every `tests/test-*.php`, then calls each function whose name starts with `test_`. It provides `vc_assert_same( $expected, $actual, string $msg )`, which throws on mismatch, and `vc_assert_contains( string $needle, array $errors )`. It prints `N passed, M failed` and exits 1 on any failure.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing tests** in `tests/test-structure.php`. Use the fixture `$s` = Europe(`amsterdam` id 737, `paris` id 479) + Americas(`boston` id 678) and `$map = array('737'=>'Amsterdam','479'=>'Paris','678'=>'Boston','22'=>'Victoria')`.
|
||||||
|
```php
|
||||||
|
test_default_regions // 6 regions; slugs exactly the Global Constraints list, in order
|
||||||
|
test_normalize_fills_slugs // region name 'Middle East and Africa', slug '' -> 'middle-east-and-africa'; city row with id '' dropped
|
||||||
|
test_validate_ok // validate($s,$map,[]) === []
|
||||||
|
test_validate_unknown_city // city id '999' -> contains 'City ID 999 is not a Viator destination.'
|
||||||
|
test_validate_duplicate_city // 737 in Europe and Americas -> contains 'Amsterdam is listed more than once.'
|
||||||
|
test_validate_duplicate_slug // region slug 'paris' + city 'paris' -> contains 'Slug "paris" is used more than once.'
|
||||||
|
test_validate_bad_slug // 'New York' and 'amsterdam!' -> both 'Slug "…" is invalid: use only a-z, 0-9 and -.'
|
||||||
|
test_validate_reserved // city slug 'contact' -> contains 'Slug "contact" is reserved.'
|
||||||
|
test_validate_taken_slug // $taken=['victoria'=>'destination "Victoria"'], city slug 'victoria' -> contains 'Slug "victoria" is already used by destination "Victoria".'
|
||||||
|
test_validate_empty_region // region name '' -> contains 'Region 1 has no name.'
|
||||||
|
test_canonical_city_primary // canonical_city($s,['479','737'],'479') === 'paris'
|
||||||
|
test_canonical_city_fallback_order // canonical_city($s,['678','479','737'],'5555') === 'amsterdam'
|
||||||
|
test_canonical_city_none // canonical_city($s,['5555'],'') === ''
|
||||||
|
test_primary_destination_id // [['ref'=>'1'],['ref'=>'2','primary'=>true]] -> '2'; [] -> ''
|
||||||
|
test_rewrite_rules // exact array: europe, americas, amsterdam, amsterdam product, paris, paris product, boston, boston product (region rules first, then cities)
|
||||||
|
test_rewrite_rules_empty_region // region with no cities still yields '^europe/?$'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run and confirm the failure.** Run `php tests/run.php` from `viator-collector/`. Expected: a fatal error saying class `VC_Structure` was not found.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `VC_Structure`** with the interface above. Only `get()` may call WordPress functions.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run and confirm the pass.** Run `php tests/run.php`. Expected: `16 passed, 0 failed`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Exclude tests from the zip.** Add `'tests'` to `$excluded_dirs` in `build-zip.php`. Then run `php build-zip.php viator-collector "$TMP/vc.zip"` and confirm no `tests/` entries are in the zip.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit.** Commit message: `feat(structure): add region/city structure model with validation`.
|
||||||
|
|
||||||
|
### Task 2: Importer stores the primary destination; crawl follows the structure
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `viator-collector/includes/class-vc-importer.php` (the `$meta` array in `save_tour_meta`, ~line 370)
|
||||||
|
- Modify: `viator-collector/includes/class-vc-cron.php` (`start_run`, ~lines 159-166)
|
||||||
|
- Modify: `viator-collector/includes/class-vc-settings.php` (description of the crawl destinations field)
|
||||||
|
- Modify: `viator-collector/includes/class-vc-plugin.php` (load `class-vc-structure.php` before the importer)
|
||||||
|
|
||||||
|
**Interfaces (Consumes):** `VC_Structure::primary_destination_id()`, `VC_Structure::cities()`, `VC_Structure::get()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Store the primary destination.** Add `'_vc_primary_destination' => VC_Structure::primary_destination_id( (array) vc_array_get( $product, 'destinations', array() ) )` to the `$meta` array.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the crawl queue from the structure.** In `start_run`:
|
||||||
|
- If `array_keys( VC_Structure::cities( VC_Structure::get() ) )` is non-empty, the queue is exactly that list.
|
||||||
|
- Otherwise keep the existing allowlist/full-catalogue logic.
|
||||||
|
- Log `Sync run started from structure — %d cities queued.` in the structure case.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Relabel the old allowlist.** Append `Used only when no cities are set on the Viator Structure screen.` to the crawl destinations field's description.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lint.** Run `php -l` on each changed file. Expected: `No syntax errors detected` for each. Then run `php tests/run.php`, which should still pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.** Commit message: `feat(import): store primary destination; crawl configured cities`.
|
||||||
|
|
||||||
|
### Task 3: Structure settings screen, validated save, and term sync
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `viator-collector/includes/class-vc-structure-admin.php`
|
||||||
|
- Create: `viator-collector/assets/js/vc-structure-admin.js`
|
||||||
|
- Modify: `viator-collector/includes/class-vc-plugin.php` (load the file and call `VC_Structure_Admin::init()`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all of `VC_Structure`; existing AJAX action `vc_search_destinations` (nonce `vc_search_destinations`, POST `q`, returns `{results:[{id,name}]}`); `VC_Taxonomies::get_or_create_destination_by_id( $id, $name )`; `VC_API::get_destination_map()`.
|
||||||
|
- Produces:
|
||||||
|
- `VC_Structure_Admin::init()`
|
||||||
|
- `VC_Structure_Admin::taken_slugs( array $structure ): array`, slug => label
|
||||||
|
- `VC_Structure_Admin::sync_terms( array $old, array $new ): array`, returns `$new` with region `term_id`s filled
|
||||||
|
- Sets option `vc_flush_rewrite` = 1 after a successful save
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the screen.** Register a submenu under Settings with `add_options_page`: title `Viator Structure`, slug `viator-collector-structure`, capability `manage_options`.
|
||||||
|
- The page renders one block per region: name input, slug input, a hidden `term_id`, and up/down/remove buttons.
|
||||||
|
- Each region block holds its city rows. A city row shows the name read-only, an editable slug, and a hidden `id` and `name`, with up/down/remove buttons.
|
||||||
|
- Each region has a city search box that calls `vc_search_destinations`. Results whose id is already used anywhere in the form are shown disabled.
|
||||||
|
- An "Add region" button sits at the end.
|
||||||
|
- Field names: `vc_structure[i][name|slug|term_id]` and `vc_structure[i][cities][j][id|name|slug]`. The JS renumbers indexes before submit.
|
||||||
|
- The form posts to `admin-post.php`, action `vc_save_structure`, nonce `vc_save_structure`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement `taken_slugs( $structure )`.** It returns these as `slug => label`:
|
||||||
|
- top-level Pages (`post_parent` 0), labelled `page "Title"`
|
||||||
|
- posts of every public post type in any status except trash and auto-draft, labelled `post "Title"`
|
||||||
|
- `viator_destination` terms that are neither a structure region (`term_id`) nor a structure city (matched through `_vc_viator_destination_id`), labelled `destination "Name"`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the save handler.**
|
||||||
|
1. Check the capability and nonce.
|
||||||
|
2. Run `normalize( wp_unslash( $_POST['vc_structure'] ?? array() ) )`.
|
||||||
|
3. Load the destination map. If that returns a `WP_Error`, treat it as the single error `Could not load Viator destinations; nothing was saved.`
|
||||||
|
4. Run `validate( $s, $map, self::taken_slugs( $s ) )`.
|
||||||
|
5. On errors, store `array( 'errors' => …, 'structure' => $s )` in transient `vc_structure_draft_{user_id}` (10 minutes) and redirect back. The screen re-renders that draft with a red notice listing every error. Nothing is saved.
|
||||||
|
6. On success: `$s = self::sync_terms( VC_Structure::get(), $s )`, then `update_option( VC_Structure::OPTION, $s )`, then `update_option( 'vc_flush_rewrite', 1 )`, then redirect with the notice `Structure saved.`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `sync_terms( $old, $new )`.**
|
||||||
|
- **Region:** update the term by `term_id` if it exists (name, slug, parent 0). Otherwise `wp_insert_term( name, 'viator_destination', array( 'slug' => slug ) )`. In both cases set `_vc_region` = 1 and store the `term_id` back.
|
||||||
|
- **City:** `$tid = VC_Taxonomies::get_or_create_destination_by_id( id, name )`, then `wp_update_term( $tid, 'viator_destination', array( 'slug' => slug, 'parent' => region term_id ) )`.
|
||||||
|
- **Removed regions** (old `term_id`s that are no longer present): `wp_delete_term`.
|
||||||
|
- **Removed cities** (old ids that are no longer present): set `parent` to 0.
|
||||||
|
- Log every `WP_Error` with `VC_Logger::error`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the page-slug clash notice.** On `admin_notices`, on the page edit screen, if the current page's `post_name` is a structure region or city slug, show the warning `This page's URL is also used by the Viator region/city "%s"; the Viator page will take priority.`
|
||||||
|
|
||||||
|
- [ ] **Step 6: Lint.** Run `php -l includes/class-vc-structure-admin.php` and `node --check assets/js/vc-structure-admin.js`. Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit.** Commit message: `feat(structure): admin screen with validated save and term sync`.
|
||||||
|
|
||||||
|
### Task 4: Routing — rewrite rules, links, redirects
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `viator-collector/includes/class-vc-routing.php`
|
||||||
|
- Modify: `viator-collector/includes/class-vc-plugin.php` (load the file and call `VC_Routing::init()`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `VC_Structure::get()`, `::rewrite_rules()`, `::cities()`, `::canonical_city()`; option `vc_flush_rewrite`.
|
||||||
|
- Produces: `VC_Routing::init()`, `VC_Routing::is_configured_term( WP_Term $term ): bool`, `VC_Routing::product_city( int $post_id ): string`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the rules.** On `init` at priority 20, call `add_rewrite_rule( $regex, $query, 'top' )` for each `rewrite_rules( get() )` entry. Then, if `get_option( 'vc_flush_rewrite' )` is set, delete it and call `flush_rewrite_rules( false )`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement `is_configured_term`.** It is true when the term is in `viator_destination` and either has `_vc_region` = 1 or its `_vc_viator_destination_id` is a key of `cities()`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `product_city`.** Build `$dest_ids` from the `ref` / `destinationId` values in the post's `_vc_destinations` JSON meta. Return `canonical_city( get(), $dest_ids, (string) _vc_primary_destination )`. Cache the result per `post_id` in a static.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the link filters.**
|
||||||
|
- `term_link`: for a configured term, return `home_url( '/' . $term->slug . '/' )`.
|
||||||
|
- `post_type_link`: for a `viator_tour` with a non-empty `post_name` and a non-empty `product_city`, return `home_url( "/{city}/{post_name}/" )`.
|
||||||
|
- Everything else is returned unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the 301s.** On `template_redirect`, for `is_singular( 'viator_tour' )` (published, not a preview) and for configured `is_tax( 'viator_destination' )`:
|
||||||
|
- Compute the canonical URL (`get_permalink()` / `get_term_link()`).
|
||||||
|
- Compare `untrailingslashit` paths of the canonical URL and `REQUEST_URI` with the query string removed.
|
||||||
|
- If they differ, `wp_safe_redirect( canonical + '?' + original query string if any, 301 )` and exit.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add the term canonical tag.** On `wp_head`, for a configured term archive, print `<link rel="canonical" href="…">`. Skip this when `WPSEO_VERSION` or `RANK_MATH_VERSION` is defined. Singular products already get core's canonical from `get_permalink()`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Lint and commit.** Run `php -l includes/class-vc-routing.php`. Expected: no errors. Commit message: `feat(routing): region/city/product URLs with 301s from old paths`.
|
||||||
|
|
||||||
|
### Task 5: Region landing page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `viator-collector/templates/region.php`
|
||||||
|
- Modify: `viator-collector/includes/class-vc-display.php` (`taxonomy_template`, ~line 534)
|
||||||
|
- Modify: `viator-collector/templates/listing.php` (new optional arg `$vc_after_hero`)
|
||||||
|
|
||||||
|
**Interfaces (Consumes):** `VC_Routing::is_configured_term()`; existing `vc_get_template()`, `card-destination.php` (arg `term`), `listing.php`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Route region terms.** In `taxonomy_template`, when the queried term has `_vc_region` = 1, return `get_stylesheet_directory() . '/viator-collector/region.php'` if it exists, else `VC_PLUGIN_DIR . 'templates/region.php'`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `$vc_after_hero` to `listing.php`.** It is an optional HTML string, default `''`, echoed immediately after the listing hero section closes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `templates/region.php`.**
|
||||||
|
- Copy the structure of `taxonomy-viator.php`.
|
||||||
|
- Build `$vc_after_hero` as `<section class="vc-section vc-section--destinations">`. It holds the heading `Cities in %s` and a `vc-destination-grid` of `card-destination.php` cards.
|
||||||
|
- The cards are the child terms from `get_terms( array( 'taxonomy' => 'viator_destination', 'parent' => term_id, 'hide_empty' => false ) )`, ordered as in the structure (the order of `cities()`).
|
||||||
|
- The listing's main query already includes child terms, so it shows every tour in the region.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lint and commit.** Run `php -l` on the three files. Commit message: `feat(display): region landing page with city grid`.
|
||||||
|
|
||||||
|
### Task 6: Regions in the header menu
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `viator-collector/includes/class-vc-menu.php`
|
||||||
|
- Modify: `viator-collector/includes/class-vc-plugin.php` (load the file and call `VC_Menu::init()`)
|
||||||
|
- Modify: `astra-child-hoponhopoff/inc/nav-walker.php` (`hoho_nav_fallback`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `VC_Structure::get()`; region and city terms (`term_id`, links via `get_term_link`).
|
||||||
|
- Produces: `VC_Menu::items(): array` of menu-item objects.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement `VC_Menu::items()`.** Build `stdClass` items with these fields: `ID`, `db_id`, `menu_item_parent` (string), `title`, `url`, `classes` (array), `type` `'custom'`, `object` `'custom'`, `object_id`, `target` `''`, `xfn` `''`, `attr_title` `''`, `description` `''`, `current` false.
|
||||||
|
- Use negative unique `ID`/`db_id`: `-1000 - n`.
|
||||||
|
- Each region gets a top-level item.
|
||||||
|
- A region with cities gets these children, in order: `All of %s` (the region URL, because the theme walker turns a parent with children into a toggle button with no link), then one item per city.
|
||||||
|
- Regions whose term no longer exists are skipped.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Append to the header menu.** Add the filter `wp_nav_menu_objects` (`$items`, `$args`). When `$args->theme_location === 'hoho_primary'`, return `array_merge( $items, self::items() )`. Set `menu_order` sequentially after the existing items.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the theme fallback.** In `hoho_nav_fallback`, after the Tours link and when `class_exists( 'VC_Menu' )`, echo `walk_nav_menu_tree( VC_Menu::items(), 2, (object) array( 'walker' => new Hoho_Nav_Walker() ) )` inside the same `<ul>`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lint and commit.** Run `php -l` on both PHP files. Commit message: `feat(menu): list regions and cities in the header menu`.
|
||||||
|
|
||||||
|
### Task 7: Version bump, build, and manual QA
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `viator-collector/viator-collector.php` (Version header and `VC_VERSION` → `1.9.0`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump the version, run the tests and build the zips.**
|
||||||
|
- Bump the version to 1.9.0.
|
||||||
|
- Run `php tests/run.php`. Expected: all tests pass.
|
||||||
|
- From the repo root, run `php build-zip.php viator-collector viator-collector.zip`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manual QA on a WordPress install** with the client's live data copied over. Record pass or fail for each check:
|
||||||
|
- M1. Save the preset regions and add `Boston` and `New York City` under Americas. The menu shows the six regions, and Americas drops down to `All of Americas`, Boston and New York City.
|
||||||
|
- M2. `/americas/` shows the city grid and all of their tours. `/boston/` lists Boston tours.
|
||||||
|
- M3. `/tour-destination/boston/` gives a 301 to `/boston/`. Do the same check for all 15 of the client's URLs once those cities are added.
|
||||||
|
- M4. A Boston tour's card links to `/boston/{slug}/`. `/tours/{slug}/` and `/new-york-city/{slug}/` both give a 301 to it.
|
||||||
|
- M5. Saving each of these shows an error, and the previous structure is unchanged:
|
||||||
|
- a city twice
|
||||||
|
- slug `contact`
|
||||||
|
- slug `New York`
|
||||||
|
- a city slug equal to an existing page's slug
|
||||||
|
- M6. `/boston/?sort=price_asc&paged=2` keeps its query. `/tour-destination/boston/?sort=price_asc` gives a 301 to `/boston/?sort=price_asc`.
|
||||||
|
- M7. Run a manual sync. The log reads `cities queued` with the configured count.
|
||||||
|
- M8. `/contact/` and other pages still load.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit.** Commit message: `chore: bump viator-collector to 1.9.0`.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Region / City / Product URL Structure — Design
|
||||||
|
|
||||||
|
Date: 2026-10-29
|
||||||
|
Status: draft, awaiting approval
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
The client wants the site organised by place, like hoponhopoffplus.com:
|
||||||
|
visitors pick a **region**, then a **city**, then a **product**. URLs should
|
||||||
|
read the same way:
|
||||||
|
|
||||||
|
| Page | URL | Example |
|
||||||
|
|---------|---------------------------------------|----------------------------------------------------------------|
|
||||||
|
| Region | `/{region-slug}/` | `/europe/` |
|
||||||
|
| City | `/{city-slug}/` | `/amsterdam/` |
|
||||||
|
| Product | `/{city-slug}/{product-slug}/` | `/amsterdam/city-sightseeing-hop-on-hop-off-amsterdam-bus-tour-24-hours/` |
|
||||||
|
|
||||||
|
The header menu lists the regions: **UK, Europe, Asia, Middle East and
|
||||||
|
Africa, Americas, Australasia**.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- The client builds the region → city structure in the plugin settings.
|
||||||
|
- Products keep importing automatically from Viator. A city page shows
|
||||||
|
**every** Viator product in that city, not only hop-on hop-off tours.
|
||||||
|
- The settings screen makes bad data impossible to save (see Validation).
|
||||||
|
- Existing live URLs keep working through 301 redirects. The client sent
|
||||||
|
these, which are live today and likely the first Americas cities:
|
||||||
|
washington-dc, boston, chicago, new-york-city, new-orleans, miami,
|
||||||
|
las-vegas, los-angeles, san-diego, san-francisco, seattle, victoria,
|
||||||
|
vancouver, montreal, toronto (all under `/tour-destination/{slug}/`).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- The new static pages from `new front/` (about, contact, faq, blog,
|
||||||
|
blog-post). That is a separate, later project.
|
||||||
|
- A hand-built sample product page. Products come from the Viator plugin.
|
||||||
|
- Redesigning the listing, card or single-tour templates.
|
||||||
|
- Region in the city URL (`/europe/amsterdam/`). The client's examples
|
||||||
|
rule this out.
|
||||||
|
|
||||||
|
## Anti-goals (would count as failure even if it "works")
|
||||||
|
|
||||||
|
- A city or region URL that hijacks an existing WordPress page, post or
|
||||||
|
system path (e.g. a city slugged `about` or `wp-admin`).
|
||||||
|
- The same product reachable at two indexable URLs (duplicate content).
|
||||||
|
- Any of the client's existing `/tour-destination/...` links returning 404.
|
||||||
|
- Admin able to type a city that does not exist in Viator.
|
||||||
|
|
||||||
|
## Decisions (agreed in conversation)
|
||||||
|
|
||||||
|
- Structure lives in plugin settings; client owns it.
|
||||||
|
- Cities are picked only from Viator's destination list (reuse the existing
|
||||||
|
`vc_search_destinations` AJAX lookup in `class-vc-settings.php`).
|
||||||
|
- A product that belongs to several configured cities **appears on every
|
||||||
|
one of those city pages**.
|
||||||
|
- Old `/tour-destination/{slug}/` URLs 301 to `/{slug}/`.
|
||||||
|
|
||||||
|
## Decisions made by Claude (user delegated URL choices)
|
||||||
|
|
||||||
|
1. **One canonical product URL.** City in the URL = the product's Viator
|
||||||
|
*primary* destination if that destination is a configured city;
|
||||||
|
otherwise the first configured city (in settings order) the product
|
||||||
|
belongs to. Requesting the product under any other city
|
||||||
|
(`/paris/same-tour/`) 301s to the canonical URL. `/tours/{slug}/`
|
||||||
|
also 301s to the canonical URL.
|
||||||
|
2. **Products in no configured city** (left over from older crawls) keep
|
||||||
|
`/tours/{slug}/` and do not appear on any region/city page.
|
||||||
|
3. **Data model: reuse the `viator_destination` taxonomy (hierarchical).**
|
||||||
|
- Region = top-level term with no Viator ID, marked by term meta
|
||||||
|
`_vc_region = 1`.
|
||||||
|
- City = term linked to a Viator destination ID (existing
|
||||||
|
`_vc_viator_destination_id` meta), with its parent set to its region.
|
||||||
|
- Existing city terms are reused by Viator ID, so current slugs such as
|
||||||
|
`washington-dc` are kept as-is.
|
||||||
|
- A region page queries its child terms with `include_children`, so it
|
||||||
|
lists every product in its cities without extra assignment.
|
||||||
|
- The structure itself is stored as one option (`vc_structure`): an
|
||||||
|
ordered list of regions, each with an ordered list of
|
||||||
|
`{viator_id, name, slug}` cities. Saving the settings syncs it into
|
||||||
|
terms (create/re-parent/rename), never deleting products.
|
||||||
|
4. **The six regions come preset** (slugs: `uk`, `europe`, `asia`,
|
||||||
|
`middle-east-and-africa`, `americas`, `australasia`). The client can
|
||||||
|
rename, reorder, add or remove regions.
|
||||||
|
5. **City slug** defaults to `sanitize_title( Viator name )`, or the
|
||||||
|
existing term's slug if one exists. The admin may edit it; edits go
|
||||||
|
through the same validation.
|
||||||
|
6. **Routing: explicit rewrite rules per configured slug, not catch-alls.**
|
||||||
|
For each region and city, add rules for `^{slug}/?$`; for each city also
|
||||||
|
`^{slug}/([^/]+)/?$` → product. Rules are rebuilt and flushed when the
|
||||||
|
structure is saved. Unconfigured paths fall through to normal WordPress
|
||||||
|
routing, so pages like `/contact/` are unaffected. Listing pagination
|
||||||
|
already uses `?paged=`, so no `/page/N/` rule is needed (the builder
|
||||||
|
should confirm this).
|
||||||
|
7. **Links.** `term_link` filter returns `/{slug}/` for region and city
|
||||||
|
terms; `post_type_link` filter returns the canonical product URL.
|
||||||
|
Unconfigured destination terms keep `/tour-destination/{slug}/`.
|
||||||
|
8. **Crawl.** When the structure has at least one city, the crawl queue is
|
||||||
|
exactly the configured cities' Viator IDs. The old
|
||||||
|
`crawl_destinations` allowlist is used only when the structure is empty,
|
||||||
|
and its field is labelled as such.
|
||||||
|
9. **Primary destination.** The importer stores the Viator primary
|
||||||
|
destination ref (the entry in `destinations` with `primary: true`) in
|
||||||
|
post meta `_vc_primary_destination`. Products imported before this change
|
||||||
|
get it on the next sync; until then the fallback rule in (1) applies.
|
||||||
|
10. **Menu.** Regions are added automatically to the "HoHo — Header Menu"
|
||||||
|
location as top-level items, each with a dropdown of its cities (the
|
||||||
|
existing nav walker already renders dropdowns). Items the client added
|
||||||
|
by hand in the menu stay, and the regions are appended after them.
|
||||||
|
11. **Region landing page.** Hero (region name and term image), a grid of
|
||||||
|
its city cards (reuse `card-destination.php`), then the standard
|
||||||
|
listing of all tours in the region. **City landing page** = the
|
||||||
|
existing taxonomy listing template, unchanged apart from the URLs.
|
||||||
|
12. **SEO.** Canonical tags follow the canonical URLs above. All redirects
|
||||||
|
are 301.
|
||||||
|
|
||||||
|
## Validation (settings save)
|
||||||
|
|
||||||
|
The whole save is rejected, with the previous structure kept, and the admin
|
||||||
|
sees a list of every problem, if any of these is true:
|
||||||
|
|
||||||
|
- A city's Viator ID is not in Viator's destination list.
|
||||||
|
- The same city (Viator ID) appears twice, in one region or across regions.
|
||||||
|
- Two items (regions or cities) share a slug.
|
||||||
|
- A region has an empty name.
|
||||||
|
- A slug is empty or contains anything other than `a-z`, `0-9` and `-`.
|
||||||
|
- A slug collides with an existing page, post, or other public post-type
|
||||||
|
slug or archive (`tours`), or with a reserved word: WordPress reserved
|
||||||
|
query vars, `wp-admin`, `wp-content`, `wp-includes`, `feed`, `page`,
|
||||||
|
`search`, `tag`, `category`, `author`, `tour-destination`, `tour-tag`,
|
||||||
|
`tour-category`, `blog`, `about`, `contact`, `faq`.
|
||||||
|
|
||||||
|
The editor UI also prevents most of these before submit: cities are added
|
||||||
|
only from search results, and already-used cities are greyed out in the
|
||||||
|
results.
|
||||||
|
|
||||||
|
Also, when a Page is created or renamed to a slug a region or city already
|
||||||
|
uses, show an admin notice warning about the clash. (WordPress itself does
|
||||||
|
not block this.)
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- WordPress, Astra child theme `astra-child-hoponhopoff`, plugin
|
||||||
|
`viator-collector`. Match the existing plugin style (static classes,
|
||||||
|
`VC_` prefix, `vc_get_option`, `VC_Logger`).
|
||||||
|
- Viator Basic access: the catalogue is only enumerable per destination,
|
||||||
|
which suits per-city crawling.
|
||||||
|
- No changes to how products are fetched or priced.
|
||||||
|
|
||||||
|
## Left to the builder
|
||||||
|
|
||||||
|
- Settings editor UI details (drag-to-reorder or up/down buttons, etc.)
|
||||||
|
as long as the validation and pick-from-Viator rules hold.
|
||||||
|
- Whether the structure editor is a section of the existing settings page
|
||||||
|
or its own sub-page.
|
||||||
|
- Exact redirect hook (`template_redirect` is the expected place).
|
||||||
|
- What happens to a city removed from the structure: its term stays (it is
|
||||||
|
un-parented and loses its `/{slug}/` route, so it falls back to
|
||||||
|
`/tour-destination/{slug}/`). Products are not deleted.
|
||||||
Reference in New Issue
Block a user