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

8.2 KiB

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.