From 9a506357ebd1452e8b34a4084af30f50bb42c5ba Mon Sep 17 00:00:00 2001 From: jamey Date: Sun, 29 Mar 2026 18:50:07 +0100 Subject: [PATCH] complete editor panel reorganisation polish - 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 --- PROGRESS.md | 6 +- assets/css/admin/components.css | 10 + docs/plans/draft-publish-workflow.md | 163 ++++-- docs/plans/dynamic-url-customisation.md | 490 ++++++++++++++++++ docs/plans/editor-reorganisation.md | 6 +- .../components/shop_components/site_editor.ex | 24 +- lib/berrypod_web/page_editor_hook.ex | 5 +- lib/berrypod_web/page_renderer.ex | 37 +- 8 files changed, 687 insertions(+), 54 deletions(-) create mode 100644 docs/plans/dynamic-url-customisation.md diff --git a/PROGRESS.md b/PROGRESS.md index 2e5f840..db8a6a8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 | diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 3bba64e..9018d92 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -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; diff --git a/docs/plans/draft-publish-workflow.md b/docs/plans/draft-publish-workflow.md index f4159f4..50093e6 100644 --- a/docs/plans/draft-publish-workflow.md +++ b/docs/plans/draft-publish-workflow.md @@ -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 diff --git a/docs/plans/dynamic-url-customisation.md b/docs/plans/dynamic-url-customisation.md new file mode 100644 index 0000000..add1596 --- /dev/null +++ b/docs/plans/dynamic-url-customisation.md @@ -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. diff --git a/docs/plans/editor-reorganisation.md b/docs/plans/editor-reorganisation.md index abcce2c..6fe55e5 100644 --- a/docs/plans/editor-reorganisation.md +++ b/docs/plans/editor-reorganisation.md @@ -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** diff --git a/lib/berrypod_web/components/shop_components/site_editor.ex b/lib/berrypod_web/components/shop_components/site_editor.ex index 7da553c..98d0157 100644 --- a/lib/berrypod_web/components/shop_components/site_editor.ex +++ b/lib/berrypod_web/components/shop_components/site_editor.ex @@ -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"""
    @@ -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) + %>
    "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" > - - + + - <%= if item.page_id != nil or (item.url == "" and @pages != []) do %> + <%= if is_page_link or (item.url == "" and @pages != []) do %>
-
+

+ This product is no longer available +

+ +

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) + } />