berrypod/docs/plans/dynamic-url-customisation.md
jamey 9a506357eb
All checks were successful
deploy / deploy (push) Successful in 6m49s
complete editor panel reorganisation polish
- fix Site tab not loading theme state on direct URL navigation
- fix nav editor showing "Custom URL" for page links (detect by URL match)
- add Home option to nav page picker
- mark editor-reorganisation plan as complete
- add dynamic-url-customisation and draft-publish-workflow plans

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-29 18:50:07 +01:00

18 KiB

Dynamic URL Customisation

Allow shop owners to customise all URL paths — both system pages (e.g., /cart/basket) and route prefixes (e.g., /products//p/, /collections//shop/). Automatic redirects from old URLs.

Design Summary

Approach: Store URL customisations in Settings (JSON). Create BerrypodWeb.R module (short name for ergonomic use) for runtime path lookups with ETS caching. Refactor router to use flexible catch-all patterns.

Two types of customisation:

  1. Page slugs — Single-segment paths like /cart, /about, /contact
  2. Route prefixes — Multi-segment paths like /products/:id, /collections/:slug

Relationship with versioning: URLs are versioned data (see draft-publish-workflow.md). When a page is published with a new URL, the old URL creates a redirect. When rolling back to an old version, the URL reverts too and the redirect chain updates. Redirects are essentially a log of URL history, always pointing to the current canonical URL.

URL Classification

Route Type Default Renameable Unpublishable Notes
home / No No Root must be /
about /about Yes Yes Content page
delivery /delivery Yes Yes (warning) Legal page
privacy /privacy Yes Yes (warning) Legal page
terms /terms Yes Yes (warning) Legal page
contact /contact Yes Yes Content page
cart /cart Yes No Required for checkout
search /search Yes Yes Small catalogues may not need search
checkout_success /checkout/success Yes No Stripe callback
orders /orders Yes Yes Some shops prefer email-only updates
order_detail /orders/:num Prefix No Individual orders still accessible
collections /collections/:slug Prefix No Route required, individual items can be unpublished
products /products/:id Prefix No Route required, individual items can be unpublished

Unpublishing consequences:

  • Search unpublished → hide search icon in header, /search returns 404
  • Orders unpublished → hide order lookup form in contact, /orders returns 404 (individual order URLs still work)
  • Legal pages unpublished → warning shown, links removed from footer

Implementation Phases

Phase 1: Data Model (1.5h)

Two storage locations:

  1. Page slugs — Stored on individual Page records (url_slug field)
  2. Route prefixes — Stored in Settings JSON (url_prefixes key)

Migration: Add url_slug column to pages table.

alter table(:pages) do
  add :url_slug, :string
end

create unique_index(:pages, [:url_slug], where: "url_slug IS NOT NULL")

Schema changes in lib/berrypod/pages/page.ex:

  • Add field :url_slug, :string
  • Add effective_url/1 helper: returns url_slug || slug
  • Add validation: url_slug format, uniqueness, no conflicts with other slugs

Settings changes for route prefixes:

# In Settings context, add url_prefixes with defaults
%{
  "url_prefixes" => %{
    "products" => "products",      # /products/:id
    "collections" => "collections", # /collections/:slug
    "orders" => "orders"           # /orders/:num
  }
}

Validation: prefix format (lowercase, hyphens, no slashes), uniqueness across prefixes

Phase 2: Routes Module (2.5h)

New file: lib/berrypod_web/r.ex

Short module name R for ergonomic replacement of ~p paths:

defmodule BerrypodWeb.R do
  @moduledoc """
  Runtime URL paths with ETS caching. Short name for ergonomic use.

  Usage:
    R.cart()              # => "/basket" (or whatever it's configured to)
    R.product(123)        # => "/p/123"
    R.url(R.product(123)) # => "https://shop.com/p/123"
  """

  # === Single-segment pages (each gets its own function) ===

  def home, do: "/"
  def cart, do: "/" <> slug(:cart)
  def about, do: "/" <> slug(:about)
  def contact, do: "/" <> slug(:contact)
  def search, do: "/" <> slug(:search)
  def delivery, do: "/" <> slug(:delivery)
  def privacy, do: "/" <> slug(:privacy)
  def terms, do: "/" <> slug(:terms)
  def checkout_success, do: "/" <> slug(:checkout_success)

  # === Prefixed paths ===

  def product(id), do: "/" <> prefix(:products) <> "/" <> to_string(id)
  def collection(slug), do: "/" <> prefix(:collections) <> "/" <> slug
  def order(num), do: "/" <> prefix(:orders) <> "/" <> num

  # === Full URLs (for OG tags, emails, sitemaps) ===

  def url(path), do: BerrypodWeb.Endpoint.url() <> path

  # === Reverse lookups for router ===

  def page_type_from_slug(slug)
  # Returns {:page, :cart} | {:page, :about} | {:custom, page} | nil

  def prefix_type_from_segment(segment)
  # Returns :products | :collections | :orders | nil

  # === Cache management ===

  def start_cache/0    # Create ETS table
  def warm_cache/0     # Load all slugs + prefixes from DB
  def invalidate/1     # Clear specific key on change
  def invalidate_all/0 # Clear everything (for prefix changes)

  # === Private helpers ===

  defp slug(type), do: get_cached_slug(type)
  defp prefix(type), do: get_cached_prefix(type)
end

Comparison with ~p:

# Before                          # After
~p"/cart"                         R.cart()
~p"/products/#{id}"               R.product(id)
~p"/collections/#{slug}"          R.collection(slug)
BerrypodWeb.Endpoint.url() <>     R.url(R.product(id))
  ~p"/products/#{id}"

ETS cache structure:

# Page slugs: {:slug, :cart} => "basket"
# Prefixes: {:prefix, :products} => "p"
# Reverse lookups populated on warm

Startup: Add to application supervision tree to warm cache on boot.

Phase 3: Router Refactor (2.5h)

Changes to lib/berrypod_web/router.ex:

Only home stays as a static route:

live "/", Shop.Page, :home

Everything else uses catch-all patterns:

# Two-segment catch-all for prefixed routes (products, collections, orders)
live "/:prefix/:id_or_slug", Shop.Page, :dynamic_prefixed

# Single-segment catch-all for page slugs and custom pages
live "/:slug", Shop.Page, :dynamic_page

Order matters: Two-segment route must come before single-segment.

Changes to lib/berrypod_web/live/shop/page.ex:

Handle :dynamic_prefixed:

def handle_params(%{"prefix" => prefix, "id_or_slug" => id_or_slug}, _uri, socket) do
  case R.prefix_type_from_segment(prefix) do
    :products -> dispatch_to(:product, %{"id" => id_or_slug}, socket)
    :collections -> dispatch_to(:collection, %{"slug" => id_or_slug}, socket)
    :orders -> dispatch_to(:order_detail, %{"order_number" => id_or_slug}, socket)
    nil -> {:noreply, assign(socket, :page_not_found, true)}
  end
end

Handle :dynamic_page:

def handle_params(%{"slug" => slug}, _uri, socket) do
  case R.page_type_from_slug(slug) do
    {:page, type} -> dispatch_to(type, %{}, socket)
    {:custom, page} -> dispatch_to(:custom, %{"page" => page}, socket)
    nil -> {:noreply, assign(socket, :page_not_found, true)}
  end
end

Phase 4: Update Hardcoded References (4h)

Import R in web modules:

Add to lib/berrypod_web.ex in the relevant quote blocks:

alias BerrypodWeb.R

This makes R.cart(), R.product(id), etc. available everywhere.

Page path references (~50 places):

Controllers:

  • checkout_controller.ex: ~p"/cart"R.cart()
  • cart_controller.ex: Same pattern
  • contact_controller.ex: ~p"/contact"R.contact()
  • order_lookup_controller.ex: Same pattern

Components:

  • layout.ex: Header/footer cart links → R.cart()
  • page_renderer.ex: Inline links in content
  • All shop templates with /cart, /contact, etc.

Prefixed path references (~40 places):

Product links:

  • product.ex components: ~p"/products/#{id}"R.product(id)
  • page_renderer.ex product blocks
  • Cart drawer product links
  • Order confirmation product links

Collection links:

  • layout.ex nav links → R.collection(slug)
  • collection.ex components
  • Breadcrumbs

Order links:

  • Order confirmation emails → R.order(num)
  • Admin order links (keep /admin/orders separate)
  • Customer order lookup

SEO controller:

  • Make sitemap dynamic by querying R module for all current URLs
  • Include all custom prefixes

Page content modules:

  • Update OG URLs to use R.url(R.product(id)), etc.

Phase 5: Redirect Integration (1.5h)

Page slug changes in lib/berrypod/pages.ex:

When page URL changes (via publish with new URL):

  1. Create auto-redirect from old path to new path via Redirects.create_auto/1
  2. Invalidate Routes cache for that page
  3. Update nav items referencing old URL (Site context)

On rollback (when draft-publish-workflow is implemented):

  1. Check if rolled-back version has different URL
  2. If so, update/remove redirects to point to restored canonical URL
  3. Invalidate Routes cache

Prefix changes in lib/berrypod/settings.ex:

When a prefix changes (e.g., productsp):

  1. Create individual redirects for each existing item using that prefix
  2. Query all products/collections/orders that exist at change time
  3. Create redirect: /products/123/p/123 for each
  4. Invalidate entire Routes cache
  5. Update nav items referencing old prefix

Why per-item redirects, not pattern redirects:

  • Pattern redirects (/products/*/p/*) would need to stay forever (can't prune)
  • Per-item redirects are prunable after they stop being hit
  • New items created after the change never existed under the old prefix — nothing to redirect
  • Requests to /products/new-item (where new-item was created post-change) are just 404s for URLs that never existed

Redirect creation helper:

def create_prefix_redirects(old_prefix, new_prefix, type) do
  items = case type do
    :products -> Products.list_all_product_ids()
    :collections -> Products.list_all_collection_slugs()
    :orders -> Orders.list_all_order_numbers()
  end

  Enum.each(items, fn id_or_slug ->
    Redirects.create_auto(%{
      from_path: "/#{old_prefix}/#{id_or_slug}",
      to_path: "/#{new_prefix}/#{id_or_slug}"
    })
  end)
end

Note: For shops with many products, this could create thousands of redirects. Consider:

  • Running via Oban job to avoid blocking the request
  • Progress indicator in UI
  • Batch inserts for performance

Phase 6: Admin UI (3h)

Page settings in page_renderer.ex page_settings_section:

For renameable pages (about, contact, cart, search, legal, custom):

  • Show URL slug input (editable)
  • Live URL preview: yourshop.com/basket
  • Redirect notice: "Old URL will redirect here"

For home page:

  • Show URL slug input (disabled)
  • Tooltip: "Home page must be at /"

Published toggle:

  • Enabled for: custom pages, about, contact, search, orders
  • Enabled with warning for: legal pages (delivery, privacy, terms)
  • Disabled for: home, cart, checkout_success

Unpublish warnings:

  • Search: "Search icon will be hidden from header"
  • Orders: "Order lookup will be hidden from contact page"
  • Legal pages: "Link will be removed from footer. Consider legal requirements."

Route prefix settings in Site tab site_editor.ex:

New "URL Prefixes" section:

┌─────────────────────────────────────────┐
│ URL Prefixes                            │
├─────────────────────────────────────────┤
│ Products:    [p________] /p/my-product  │
│ Collections: [shop_____] /shop/t-shirts │
│ Orders:      [orders___] /orders/12345  │
│                                         │
│ ⓘ Changing prefixes creates redirects  │
│   for all existing items.               │
└─────────────────────────────────────────┘

Each prefix field:

  • Text input with pattern validation
  • Live URL preview with example slug
  • Info text about redirect creation
  • Confirmation dialog for changes (since it creates many redirects)

Phase 7: Testing (2.5h)

Routes module unit tests:

  • ETS cache creation and warming
  • Slug lookups (default and custom)
  • Prefix lookups (default and custom)
  • Reverse lookups (slug → page type, segment → prefix type)
  • Cache invalidation (single key and full)

Integration tests:

  • Page slug change creates redirect
  • Prefix change creates bulk redirects
  • Old URLs return 301 to new URLs
  • Router dispatches correctly with custom slugs/prefixes

E2E flow tests:

  • Change /cart to /basket:
    • /basket loads cart page
    • /cart redirects to /basket
    • Checkout cancel_url uses /basket
    • Stripe checkout flow completes
  • Change /products/ to /p/:
    • /p/123 loads product page
    • /products/123 redirects to /p/123
    • Product links use new prefix

SEO tests:

  • Sitemap uses current URLs
  • OG tags use current URLs
  • Canonical URLs use current URLs

Files to Modify

File Changes
priv/repo/migrations/xxx_add_url_slug.exs New migration for url_slug
lib/berrypod/pages/page.ex Add url_slug field, validations, effective_url/1
lib/berrypod/pages.ex Redirect creation on slug change
lib/berrypod/settings.ex Add url_prefixes to settings, prefix change handling
lib/berrypod_web/r.ex New module (ETS cache, path functions, reverse lookups)
lib/berrypod_web.ex Add alias BerrypodWeb.R to controller/live/component quotes
lib/berrypod_web/router.ex Replace static routes with catch-all patterns
lib/berrypod_web/live/shop/page.ex Handle :dynamic_page and :dynamic_prefixed
lib/berrypod_web/controllers/checkout_controller.ex ~p"/cart"R.cart()
lib/berrypod_web/controllers/cart_controller.ex ~p"/cart"R.cart()
lib/berrypod_web/controllers/contact_controller.ex ~p"/contact"R.contact()
lib/berrypod_web/controllers/order_lookup_controller.ex ~p"/orders/..."R.order(num)
lib/berrypod_web/controllers/seo_controller.ex Dynamic sitemap with current URLs
lib/berrypod_web/page_renderer.ex Update links, page settings UI
lib/berrypod_web/components/shop_components/layout.ex Update nav links to use R
lib/berrypod_web/components/shop_components/product.ex ~p"/products/..."R.product(id)
lib/berrypod_web/components/shop_components/cart.ex ~p"/products/..."R.product(id)
lib/berrypod_web/components/shop_components/site_editor.ex Add URL prefix section
lib/berrypod_web/live/shop/pages/*.ex Update OG URLs to R.url(R.product(id))
lib/berrypod_web/live/admin/orders/*.ex Update customer-facing order links
test/berrypod_web/r_test.exs New test file
test/berrypod/pages_test.exs URL change redirect tests
test/berrypod/settings_test.exs Prefix change tests

Verification

  1. Run test suite: mix test — all existing tests pass
  2. R module tests: mix test test/berrypod_web/r_test.exs
  3. Manual page slug test:
    • Edit cart page, change URL to "basket"
    • Visit /basket — cart loads
    • Visit /cart — 301 redirect to /basket
    • Complete checkout — cancel returns to /basket
  4. Manual prefix test:
    • Change products prefix to "p" in Site tab
    • Visit /p/any-product — product loads
    • Visit /products/any-product — 301 redirect to /p/...
    • Check product links throughout site use /p/
  5. SEO verification:
    • Check /sitemap.xml uses custom URLs
    • Check OG tags on custom URL pages
    • Check canonical URLs match current paths
  6. Admin flow: Ensure /admin/... routes unchanged

Estimate

Phase Time
1. Data model 1.5h
2. Routes module 2.5h
3. Router refactor 2.5h
4. Update references 4h
5. Redirect integration 1.5h
6. Admin UI 3h
7. Testing 2.5h
Total 17.5h

Risks

Risk Mitigation
Breaking Stripe checkout Test checkout flow with custom cart/success URLs thoroughly
Cache invalidation bugs Fallback to DB lookup on cache miss; comprehensive tests
Breaking existing bookmarks Auto-redirects created for all URL changes
SEO impact 301 redirects preserve SEO value; canonical URLs updated
Performance on prefix change Bulk redirect creation could be slow with many products; consider Oban job
Conflicting slugs/prefixes Validation prevents slug matching any prefix and vice versa
Rollback redirect complexity URL versioning (draft-publish-workflow) handles redirect chain updates atomically

Edge Cases

Collision prevention:

  • Page slug cannot match any route prefix (e.g., can't rename /about to /products)
  • Route prefix cannot match any page slug
  • Custom page slugs validated against system page slugs

checkout_success path:

  • Built as page_path(:checkout_success) which returns the configured slug
  • Stripe success_url/cancel_url constructed dynamically at checkout time
  • If renamed, old Stripe sessions still work (redirect catches them)

Admin routes unchanged:

  • All /admin/* routes stay hardcoded
  • Only shop-facing URLs are customisable

Interaction with Draft-Publish Workflow

This plan can be implemented independently, but integrates cleanly with draft-publish-workflow.md:

Without versioning (this plan alone):

  • URL changes take effect immediately on save
  • Redirect created from old URL to new URL
  • No rollback capability for URLs

With versioning (both plans):

  • URL is part of page version data (page_versions.url_slug)
  • URL changes only take effect on publish
  • Draft can have a different URL that's previewed but not live
  • Rollback restores both content AND URL
  • Redirects become a complete log of URL history
  • All old URLs always redirect to current canonical

Implementation order options:

  1. URL customisation first — Simpler start, add versioning later
  2. Versioning first — URLs become versioned from the start
  3. Together — More complex but fully integrated from day one

Recommendation: Implement URL customisation first (this plan), then add versioning. The redirect integration code will need minor updates when versioning is added, but the R module and router changes are independent.