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
aboutorwp-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_destinationsAJAX lookup inclass-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)
- 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. - Products in no configured city (left over from older crawls) keep
/tours/{slug}/and do not appear on any region/city page. - Data model: reuse the
viator_destinationtaxonomy (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_idmeta), with its parent set to its region. - Existing city terms are reused by Viator ID, so current slugs such as
washington-dcare 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.
- Region = top-level term with no Viator ID, marked by term meta
- The six regions come preset (slugs:
uk,europe,asia,middle-east-and-africa,americas,australasia). The client can rename, reorder, add or remove regions. - 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. - 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). - Links.
term_linkfilter returns/{slug}/for region and city terms;post_type_linkfilter returns the canonical product URL. Unconfigured destination terms keep/tour-destination/{slug}/. - Crawl. When the structure has at least one city, the crawl queue is
exactly the configured cities' Viator IDs. The old
crawl_destinationsallowlist is used only when the structure is empty, and its field is labelled as such. - Primary destination. The importer stores the Viator primary
destination ref (the entry in
destinationswithprimary: 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. - 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.
- 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. - 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-9and-. - 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, pluginviator-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_redirectis 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.