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