Files
hoponhopofftickets/viator-collector/docs/superpowers/specs/2026-10-29-region-city-urls-design.md
2026-10-29 15:18:26 +03:30

160 lines
8.2 KiB
Markdown

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