# 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.