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 | | 100 | Blog post type | — | 3h | planned |
| 101 | Staff accounts & RBAC | — | 4h | 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. 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 | | 15-16 | Footer content (about, copyright, newsletter toggle) | 1.25h | done |
| 17-18 | Move branding from Theme to Site | 1.5h | 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 | | 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. 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 | | [profit-aware-pricing.md](docs/plans/profit-aware-pricing.md) | Planned |
| [security-hardening.md](docs/plans/security-hardening.md) | Planned | | [security-hardening.md](docs/plans/security-hardening.md) | Planned |
| [draft-publish-workflow.md](docs/plans/draft-publish-workflow.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 | | [seo-enhancements.md](docs/plans/seo-enhancements.md) | Planned |
| [competitive-gap-analysis.md](docs/plans/competitive-gap-analysis.md) | Reference | | [competitive-gap-analysis.md](docs/plans/competitive-gap-analysis.md) | Reference |
| [competitive-gaps.md](docs/plans/competitive-gaps.md) | Planned | | [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 { .admin-checkbox {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;

View File

@ -12,15 +12,24 @@ Current editing workflow uses explicit save — changes are either saved (immedi
## Solution ## 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 ## Design principles
1. **Drafts are per-user** — each admin sees their own pending changes 1. **Site-level lifecycle** — Site has a `first_published_at` timestamp; until then, visitors see "coming soon"
2. **Visitors always see published** — no accidental exposure of drafts 2. **Drafts are per-user** — each admin sees their own pending changes
3. **Auto-save constantly** — no risk of lost work 3. **Visitors always see published** — no accidental exposure of drafts
4. **Clear visual distinction** — obvious when viewing draft vs published 4. **Auto-save constantly** — no risk of lost work
5. **Single publish action** — one button publishes all pending changes 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 ## Scope
@ -34,47 +43,95 @@ Three types of editable content:
## Data model ## Data model
### Option A: Draft columns (recommended for simplicity) ### Site-level publishing
```elixir ```elixir
# Migration: add draft columns to pages # Add to settings or dedicated sites table
alter table(:pages) do # Option 1: Settings key
add :draft_blocks, :map %{"site_first_published_at" => nil} # nil = unpublished, datetime = published
add :draft_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
add :draft_updated_at, :utc_datetime_usec
end
# New settings keys # Option 2: Dedicated sites table (if multi-tenant later)
# - "draft_theme_settings" → JSON blob create table(:sites, primary_key: false) do
# - "draft_site_name" → string add :id, :binary_id, primary_key: true
# - "draft_header_nav" → JSON add :first_published_at, :utc_datetime_usec # nil until first publish
# etc. 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 ```elixir
create table(:drafts, primary_key: false) do create table(:page_versions, primary_key: false) do
add :id, :binary_id, primary_key: true add :id, :binary_id, primary_key: true
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false add :page_id, references(:pages, type: :binary_id, on_delete: :delete_all), null: false
add :entity_type, :string, null: false # "page", "theme", "settings" add :version_number, :integer, null: false
add :entity_id, :string # page id, or nil for global add :blocks, :map, null: false
add :data, :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) timestamps(type: :utc_datetime_usec)
end 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 ## Implementation
### Phase 1: Page drafts ### Phase 0: Site-level publishing
| Task | Est | Notes | | 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` | | 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 | | Modify PageEditorHook to auto-save drafts | 1h | Debounced save on every change |
| Add "Publish" and "Discard" buttons to editor | 1h | Replace current "Save" | | 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 | | | Update settings editor to use drafts | 1h | |
| Unified "Publish all" option | 1h | Publish page + theme + settings together | | 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 | | Task | Est | Notes |
|------|-----|-------| |------|-----|-------|
| Draft age indicator ("Last saved 5 mins ago") | 30m | | | Draft age indicator ("Last saved 5 mins ago") | 30m | |
| Draft conflict handling (stale draft warning) | 1h | Edge case: published changed since draft created | | 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 ## 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 ### Editing a page
1. Admin navigates to page, clicks "Edit" 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** 3. Admin makes changes — **auto-saved as draft every few seconds**
4. Admin can navigate away freely — draft persists 4. Admin can navigate away freely — draft persists
5. Admin returns later — draft loaded automatically 5. Admin returns later — draft loaded automatically
6. "Publish" button commits draft → published 6. "Publish" button commits draft → new version
7. "Discard" button deletes draft, reverts to published 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 ### Visual indicators
@ -163,22 +246,30 @@ end
- **Multi-user drafts** — currently single draft per page, could extend to per-user drafts - **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) - **Draft preview link** — shareable URL that shows draft to non-admins (for review)
- **Scheduled publishing** — "Publish at 9am tomorrow" - **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 ## Dependencies
- Unified editing mode (phases 1-7) must be complete first - Unified editing mode (phases 1-7) must be complete first
- This work builds on the editor panel UI established there - 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 ## Estimates
| Phase | Est | | 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 2: Theme drafts | 4h |
| Phase 3: Settings drafts | 4h | | Phase 3: Settings drafts | 4h |
| Phase 4: Polish | 5h | | Phase 4: Version history and rollback | 5.5h |
| **Total** | **18h** | | Phase 5: Polish | 2.5h |
| **Total** | **24.5h** |
## References ## 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 # Editor Panel Reorganisation
Status: In Progress Status: Complete
## Problem ## Problem
@ -217,8 +217,8 @@ end
| 18 | Theme tab: remove branding, polish remaining | 17 | 30m | done | | 18 | Theme tab: remove branding, polish remaining | 17 | 30m | done |
| 19 | Merge page settings into Page tab | — | 45m | done | | 19 | Merge page settings into Page tab | — | 45m | done |
| 20 | Remove Settings tab | 19 | 15m | done | | 20 | Remove Settings tab | 19 | 15m | done |
| 21 | Polish: responsive, empty states, validation | 1-20 | 1.5h | planned | | 21 | Polish: responsive, empty states, validation | 1-20 | 1.5h | done |
| 22 | Testing: all page types, edge cases | 21 | 30m | planned | | 22 | Testing: all page types, edge cases | 21 | 30m | done |
**Total estimate: ~15h** **Total estimate: ~15h**

View File

@ -622,6 +622,17 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
attr :event_prefix, :string, default: "site_" attr :event_prefix, :string, default: "site_"
defp nav_editor(assigns) do 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""" ~H"""
<div class="site-editor-nav-list"> <div class="site-editor-nav-list">
<ul :if={@items != []} class="site-editor-nav-items"> <ul :if={@items != []} class="site-editor-nav-items">
@ -630,6 +641,10 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
class="site-editor-nav-item" class="site-editor-nav-item"
data-item-id={item.id} 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 <form
class="site-editor-nav-item-form" class="site-editor-nav-item-form"
phx-change={@event_prefix <> "update_nav_item"} 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" class="admin-select admin-select-sm site-editor-nav-type"
aria-label="Link type" aria-label="Link type"
> >
<option value="page" selected={item.page_id != nil}>Page</option> <option value="page" selected={is_page_link}>Page</option>
<option value="url" selected={item.page_id == nil}>Custom URL</option> <option value="url" selected={not is_page_link}>Custom URL</option>
</select> </select>
<%= if item.page_id != nil or (item.url == "" and @pages != []) do %> <%= if is_page_link or (item.url == "" and @pages != []) do %>
<select <select
name={"nav_item[#{item.id}][page_id]"} name={"nav_item[#{item.id}][page_id]"}
class="admin-select admin-select-sm site-editor-nav-page" 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> <option value="">Select a page</option>
<optgroup label="Pages"> <optgroup label="Pages">
<option value="/" selected={item.url == "/" or item.url == "home"}>
Home
</option>
<option <option
:for={page <- @pages} :for={page <- @pages}
value={page.slug} value={page.slug}

View File

@ -173,7 +173,10 @@ defmodule BerrypodWeb.PageEditorHook do
if socket.assigns.site_editing do if socket.assigns.site_editing do
socket socket
else 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
end end

View File

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