# 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](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. ```elixir 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: ```elixir # 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: ```elixir 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:** ```elixir # 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:** ```elixir # 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: ```elixir live "/", Shop.Page, :home ``` Everything else uses catch-all patterns: ```elixir # 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`: ```elixir 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`: ```elixir 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: ```elixir 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., `products` → `p`): 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:** ```elixir 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](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.