491 lines
18 KiB
Markdown
491 lines
18 KiB
Markdown
|
|
# 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.
|