berrypod/docs/plans/dynamic-url-customisation.md

491 lines
18 KiB
Markdown
Raw Normal View History

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