complete editor panel reorganisation polish
All checks were successful
deploy / deploy (push) Successful in 6m49s

- 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>
This commit is contained in:
jamey 2026-03-29 18:50:07 +01:00
parent d3fe6f4b56
commit 9a506357eb
8 changed files with 687 additions and 54 deletions

View File

@ -149,7 +149,7 @@ Close critical gaps identified in the competitive analysis. Phased approach: cor
| 100 | Blog post type | — | 3h | planned |
| 101 | Staff accounts & RBAC | — | 4h | planned |
### Editor panel reorganisation ([plan](docs/plans/editor-reorganisation.md)) — In Progress
### Editor panel reorganisation ([plan](docs/plans/editor-reorganisation.md)) — Complete
Restructure the 3-tab editor panel for better discoverability. Replace Settings tab with Site tab for site-wide content (announcement bar text, social links, nav items, footer content). Move branding from Theme to Site. Merge page settings inline into Page tab.
@ -162,7 +162,7 @@ Restructure the 3-tab editor panel for better discoverability. Replace Settings
| 15-16 | Footer content (about, copyright, newsletter toggle) | 1.25h | done |
| 17-18 | Move branding from Theme to Site | 1.5h | done |
| 19-20 | Merge page settings into Page tab, remove Settings tab | 1h | done |
| 21-22 | Polish and testing | 2h | planned |
| 21-22 | Polish and testing | 2h | done |
Custom page settings (title, slug, meta description, published, navigation options) now appear inline in the Page tab as a collapsible section. The separate Settings tab has been fully removed along with the SettingsEditor component.
@ -256,7 +256,7 @@ All plans in [docs/plans/](docs/plans/). Completed plans are kept as architectur
| [profit-aware-pricing.md](docs/plans/profit-aware-pricing.md) | Planned |
| [security-hardening.md](docs/plans/security-hardening.md) | Planned |
| [draft-publish-workflow.md](docs/plans/draft-publish-workflow.md) | Planned |
| [editor-reorganisation.md](docs/plans/editor-reorganisation.md) | In Progress |
| [editor-reorganisation.md](docs/plans/editor-reorganisation.md) | Complete |
| [seo-enhancements.md](docs/plans/seo-enhancements.md) | Planned |
| [competitive-gap-analysis.md](docs/plans/competitive-gap-analysis.md) | Reference |
| [competitive-gaps.md](docs/plans/competitive-gaps.md) | Planned |

View File

@ -561,6 +561,16 @@
}
}
.admin-input-disabled,
.admin-input:disabled,
.admin-select:disabled,
.admin-textarea:disabled {
background-color: var(--t-surface-muted, var(--t-surface-base));
color: var(--t-text-muted);
cursor: not-allowed;
opacity: 0.7;
}
.admin-checkbox {
width: 1rem;
height: 1rem;

View File

@ -12,15 +12,24 @@ Current editing workflow uses explicit save — changes are either saved (immedi
## Solution
Implement a draft-then-publish model where all edits auto-save as drafts, visitors see only published versions, and explicit "Publish" commits changes live.
Implement a draft-then-publish model with **site-level publishing** and **version history**:
1. **Site starts unpublished** — New sites are in draft mode until first publish
2. **Auto-save drafts** — All edits auto-save, no risk of lost work
3. **Visitors see published only** — No accidental exposure of drafts
4. **Version history** — Each publish creates a snapshot, enabling rollback
5. **URLs are versioned** — URL slugs are part of version data (ties into dynamic URL customisation)
## Design principles
1. **Drafts are per-user** — each admin sees their own pending changes
2. **Visitors always see published** — no accidental exposure of drafts
3. **Auto-save constantly** — no risk of lost work
4. **Clear visual distinction** — obvious when viewing draft vs published
5. **Single publish action** — one button publishes all pending changes
1. **Site-level lifecycle** — Site has a `first_published_at` timestamp; until then, visitors see "coming soon"
2. **Drafts are per-user** — each admin sees their own pending changes
3. **Visitors always see published** — no accidental exposure of drafts
4. **Auto-save constantly** — no risk of lost work
5. **Clear visual distinction** — obvious when viewing draft vs published
6. **Single publish action** — one button publishes all pending changes
7. **Version snapshots** — each publish creates a version record for rollback
8. **Atomic rollback** — rolling back restores content AND URL (redirect chain updates automatically)
## Scope
@ -34,47 +43,95 @@ Three types of editable content:
## Data model
### Option A: Draft columns (recommended for simplicity)
### Site-level publishing
```elixir
# Migration: add draft columns to pages
alter table(:pages) do
add :draft_blocks, :map
add :draft_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
add :draft_updated_at, :utc_datetime_usec
end
# Add to settings or dedicated sites table
# Option 1: Settings key
%{"site_first_published_at" => nil} # nil = unpublished, datetime = published
# New settings keys
# - "draft_theme_settings" → JSON blob
# - "draft_site_name" → string
# - "draft_header_nav" → JSON
# etc.
# Option 2: Dedicated sites table (if multi-tenant later)
create table(:sites, primary_key: false) do
add :id, :binary_id, primary_key: true
add :first_published_at, :utc_datetime_usec # nil until first publish
timestamps(type: :utc_datetime_usec)
end
```
### Option B: Separate drafts table (more flexible)
### Page versions (recommended approach)
Rather than draft columns, use a proper version table. Each publish creates a version snapshot.
```elixir
create table(:drafts, primary_key: false) do
create table(:page_versions, primary_key: false) do
add :id, :binary_id, primary_key: true
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
add :entity_type, :string, null: false # "page", "theme", "settings"
add :entity_id, :string # page id, or nil for global
add :data, :map, null: false
add :page_id, references(:pages, type: :binary_id, on_delete: :delete_all), null: false
add :version_number, :integer, null: false
add :blocks, :map, null: false
add :url_slug, :string # URL is part of version data
add :meta_title, :string
add :meta_description, :string
add :published_at, :utc_datetime_usec
add :published_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
timestamps(type: :utc_datetime_usec)
end
create unique_index(:drafts, [:user_id, :entity_type, :entity_id])
create index(:page_versions, [:page_id, :version_number])
create unique_index(:page_versions, [:page_id, :version_number])
```
**Recommendation:** Start with Option A (simpler), migrate to Option B if multi-user or more complex draft needs emerge.
```elixir
# Pages table changes
alter table(:pages) do
add :published_version_id, references(:page_versions, type: :binary_id, on_delete: :nilify_all)
add :draft_blocks, :map
add :draft_url_slug, :string
add :draft_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
add :draft_updated_at, :utc_datetime_usec
end
```
**How it works:**
- `pages.draft_*` fields hold current work-in-progress
- On publish: create new `page_versions` record, update `pages.published_version_id`
- On rollback: set `published_version_id` to older version, update redirect chain
- Visitors always see the version pointed to by `published_version_id`
### URL versioning and redirects
When a page version has a different URL than the current published version:
1. The redirect system tracks URL history
2. All old URLs redirect to current canonical URL
3. On rollback, old URL becomes canonical again, redirect chain updates
```elixir
# Example: Page starts at /about, renamed to /about-us, then rolled back
# Version 1: url_slug = "about" <- created
# Version 2: url_slug = "about-us" <- published, redirect /about /about-us created
# Rollback to v1: redirect /about-us → /about created (or /about redirect deleted)
```
This ties directly into the [dynamic URL customisation plan](dynamic-url-customisation.md).
## Implementation
### Phase 1: Page drafts
### Phase 0: Site-level publishing
| Task | Est | Notes |
|------|-----|-------|
| Add draft columns to pages table | 30m | Migration |
| Add `site_first_published_at` setting | 15m | nil until first publish |
| Update coming soon page to check this setting | 30m | Show until site is published |
| Add "Publish site" button to admin dashboard | 30m | First-time publish action |
| Hide "Publish site" after first publish | 15m | One-time action |
### Phase 1: Page versions and drafts
| Task | Est | Notes |
|------|-----|-------|
| Create page_versions table | 30m | Migration with version data |
| Add draft columns and published_version_id to pages | 30m | Migration |
| Create PageVersions context module | 1.5h | `create_version/2`, `rollback_to/2`, `list_versions/1` |
| Update Pages context with draft functions | 1h | `save_draft/2`, `publish_draft/1`, `discard_draft/1`, `has_draft?/1` |
| Modify PageEditorHook to auto-save drafts | 1h | Debounced save on every change |
| Add "Publish" and "Discard" buttons to editor | 1h | Replace current "Save" |
@ -100,16 +157,33 @@ create unique_index(:drafts, [:user_id, :entity_type, :entity_id])
| Update settings editor to use drafts | 1h | |
| Unified "Publish all" option | 1h | Publish page + theme + settings together |
### Phase 4: Polish
### Phase 4: Version history and rollback
| Task | Est | Notes |
|------|-----|-------|
| Version history panel in editor | 1.5h | List of published versions with timestamps |
| Rollback action with confirmation | 1h | "Revert to version 3?" |
| URL redirect chain update on rollback | 1h | Update redirects to point to restored URL |
| Version comparison view (stretch) | 2h | Side-by-side diff |
### Phase 5: Polish
| Task | Est | Notes |
|------|-----|-------|
| Draft age indicator ("Last saved 5 mins ago") | 30m | |
| Draft conflict handling (stale draft warning) | 1h | Edge case: published changed since draft created |
| Version history / rollback (stretch goal) | 3h | Store previous published versions |
| Version pruning (keep last N versions) | 1h | Prevent unbounded growth |
## UX flow
### First-time site publish
1. New shop completes setup wizard
2. Site is in "coming soon" mode — visitors see placeholder
3. Admin edits pages, theme, settings (all auto-saved as drafts)
4. Admin clicks "Publish site" on dashboard
5. Site goes live — `first_published_at` set, all current drafts become version 1
### Editing a page
1. Admin navigates to page, clicks "Edit"
@ -117,8 +191,17 @@ create unique_index(:drafts, [:user_id, :entity_type, :entity_id])
3. Admin makes changes — **auto-saved as draft every few seconds**
4. Admin can navigate away freely — draft persists
5. Admin returns later — draft loaded automatically
6. "Publish" button commits draft → published
7. "Discard" button deletes draft, reverts to published
6. "Publish" button commits draft → new version
7. "Discard" button deletes draft, reverts to current published version
### Rolling back a page
1. Admin opens version history in editor
2. Sees list: "Version 3 (current) — 2 hours ago", "Version 2 — yesterday", etc.
3. Clicks "Revert to version 2"
4. Confirmation: "This will restore version 2 content and URL. Continue?"
5. On confirm: `published_version_id` updated, redirect chain adjusted
6. Current draft (if any) is NOT affected — can discard or keep editing
### Visual indicators
@ -163,22 +246,30 @@ end
- **Multi-user drafts** — currently single draft per page, could extend to per-user drafts
- **Draft preview link** — shareable URL that shows draft to non-admins (for review)
- **Scheduled publishing** — "Publish at 9am tomorrow"
- **Draft comparison** — side-by-side diff of draft vs published
- **Theme/settings versioning** — extend version history to theme and shop settings
- **Bulk rollback** — "Revert entire site to yesterday" across all pages
## Dependencies
- Unified editing mode (phases 1-7) must be complete first
- This work builds on the editor panel UI established there
## Related Plans
- [dynamic-url-customisation.md](dynamic-url-customisation.md) — URLs are versioned data; these plans integrate cleanly
- Can be implemented in either order, but implementing together gives the cleanest result
## Estimates
| Phase | Est |
|-------|-----|
| Phase 1: Page drafts | 5h |
| Phase 0: Site-level publishing | 1.5h |
| Phase 1: Page versions and drafts | 7h |
| Phase 2: Theme drafts | 4h |
| Phase 3: Settings drafts | 4h |
| Phase 4: Polish | 5h |
| **Total** | **18h** |
| Phase 4: Version history and rollback | 5.5h |
| Phase 5: Polish | 2.5h |
| **Total** | **24.5h** |
## References

View File

@ -0,0 +1,490 @@
# 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.

View File

@ -1,6 +1,6 @@
# Editor Panel Reorganisation
Status: In Progress
Status: Complete
## Problem
@ -217,8 +217,8 @@ end
| 18 | Theme tab: remove branding, polish remaining | 17 | 30m | done |
| 19 | Merge page settings into Page tab | — | 45m | done |
| 20 | Remove Settings tab | 19 | 15m | done |
| 21 | Polish: responsive, empty states, validation | 1-20 | 1.5h | planned |
| 22 | Testing: all page types, edge cases | 21 | 30m | planned |
| 21 | Polish: responsive, empty states, validation | 1-20 | 1.5h | done |
| 22 | Testing: all page types, edge cases | 21 | 30m | done |
**Total estimate: ~15h**

View File

@ -622,6 +622,17 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
attr :event_prefix, :string, default: "site_"
defp nav_editor(assigns) do
# Build a set of known page URLs for quick lookup
page_urls =
Enum.flat_map(assigns.pages, fn page ->
[page.slug, "/#{page.slug}"]
end)
|> MapSet.new()
|> MapSet.put("/collections/all")
|> MapSet.put("/")
assigns = assign(assigns, :page_urls, page_urls)
~H"""
<div class="site-editor-nav-list">
<ul :if={@items != []} class="site-editor-nav-items">
@ -630,6 +641,10 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
class="site-editor-nav-item"
data-item-id={item.id}
>
<%
# Determine if this item links to a known page (by URL match or page_id)
is_page_link = item.page_id != nil or MapSet.member?(@page_urls, item.url)
%>
<form
class="site-editor-nav-item-form"
phx-change={@event_prefix <> "update_nav_item"}
@ -651,10 +666,10 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
class="admin-select admin-select-sm site-editor-nav-type"
aria-label="Link type"
>
<option value="page" selected={item.page_id != nil}>Page</option>
<option value="url" selected={item.page_id == nil}>Custom URL</option>
<option value="page" selected={is_page_link}>Page</option>
<option value="url" selected={not is_page_link}>Custom URL</option>
</select>
<%= if item.page_id != nil or (item.url == "" and @pages != []) do %>
<%= if is_page_link or (item.url == "" and @pages != []) do %>
<select
name={"nav_item[#{item.id}][page_id]"}
class="admin-select admin-select-sm site-editor-nav-page"
@ -662,6 +677,9 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
>
<option value="">Select a page</option>
<optgroup label="Pages">
<option value="/" selected={item.url == "/" or item.url == "home"}>
Home
</option>
<option
:for={page <- @pages}
value={page.slug}

View File

@ -173,7 +173,10 @@ defmodule BerrypodWeb.PageEditorHook do
if socket.assigns.site_editing do
socket
else
load_site_state(socket)
# Site tab uses branding settings from theme, so load theme state too
socket
|> maybe_enter_theme_mode()
|> load_site_state()
end
end

View File

@ -305,9 +305,9 @@ defmodule BerrypodWeb.PageRenderer do
{if @editor_live_region_message, do: @editor_live_region_message}
</div>
<%!-- Page settings for custom pages --%>
<%!-- Page settings (custom and system pages, not product/collection) --%>
<.page_settings_section
:if={@page[:type] == "custom"}
:if={@page[:type] in ["custom", "system"]}
page={@page}
form={@settings_form}
dirty={@settings_dirty}
@ -364,7 +364,7 @@ defmodule BerrypodWeb.PageRenderer do
"""
end
# Page settings section for custom pages (collapsible)
# Page settings section (collapsible) for custom and system pages
attr :page, :map, required: true
attr :form, :map, default: nil
attr :dirty, :boolean, default: false
@ -372,9 +372,11 @@ defmodule BerrypodWeb.PageRenderer do
defp page_settings_section(assigns) do
form = assigns.form || %{}
is_custom = assigns.page[:type] == "custom"
assigns =
assigns
|> assign(:is_custom, is_custom)
|> assign(:form_title, form["title"] || assigns.page.title || "")
|> assign(:form_slug, form["slug"] || assigns.page.slug || "")
|> assign(:form_meta, form["meta_description"] || assigns.page.meta_description || "")
@ -420,8 +422,10 @@ defmodule BerrypodWeb.PageRenderer do
id="page-settings-slug"
name="page[slug]"
value={@form_slug}
class="admin-input"
class={["admin-input", !@is_custom && "admin-input-disabled"]}
pattern="[a-z0-9-]+"
disabled={!@is_custom}
title={if !@is_custom, do: "System page URLs cannot be changed", else: nil}
/>
</div>
</div>
@ -437,7 +441,8 @@ defmodule BerrypodWeb.PageRenderer do
>{@form_meta}</textarea>
</div>
<div class="page-settings-checks">
<%!-- Published and nav options only for custom pages --%>
<div :if={@is_custom} class="page-settings-checks">
<label class="admin-check-label">
<input
type="checkbox"
@ -461,7 +466,7 @@ defmodule BerrypodWeb.PageRenderer do
</label>
</div>
<div :if={@form_show_in_nav} class="page-settings-nav-options">
<div :if={@is_custom && @form_show_in_nav} class="page-settings-nav-options">
<div class="page-settings-field page-settings-field-inline">
<label class="page-settings-label" for="page-settings-nav-label">Nav label</label>
<input
@ -708,7 +713,17 @@ defmodule BerrypodWeb.PageRenderer do
<.quantity_selector quantity={assigns[:quantity] || 1} in_stock={@product.in_stock} />
<p
:if={assigns[:selected_variant] && !assigns[:selected_variant].is_available}
:if={assigns[:product_discontinued]}
class="variant-unavailable-msg"
>
This product is no longer available
</p>
<p
:if={
!assigns[:product_discontinued] && assigns[:selected_variant] &&
!assigns[:selected_variant].is_available
}
class="variant-unavailable-msg"
>
This option is currently unavailable
@ -716,7 +731,13 @@ defmodule BerrypodWeb.PageRenderer do
<.add_to_cart_button
mode={@mode}
disabled={assigns[:selected_variant] && !assigns[:selected_variant].is_available}
text={
if assigns[:product_discontinued], do: "No longer available", else: "Add to basket"
}
disabled={
assigns[:product_discontinued] ||
(assigns[:selected_variant] && !assigns[:selected_variant].is_available)
}
/>
</form>
</div>