diff --git a/viator-collector/docs/superpowers/plans/2026-10-29-region-city-urls.md b/viator-collector/docs/superpowers/plans/2026-10-29-region-city-urls.md
new file mode 100644
index 0000000..6b22c6e
--- /dev/null
+++ b/viator-collector/docs/superpowers/plans/2026-10-29-region-city-urls.md
@@ -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 ``. 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 ``. 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 `
`.
+
+- [ ] **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`.
diff --git a/viator-collector/docs/superpowers/specs/2026-10-29-region-city-urls-design.md b/viator-collector/docs/superpowers/specs/2026-10-29-region-city-urls-design.md
new file mode 100644
index 0000000..123a5bf
--- /dev/null
+++ b/viator-collector/docs/superpowers/specs/2026-10-29-region-city-urls-design.md
@@ -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.