diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css
index 9018d92..4da415d 100644
--- a/assets/css/admin/components.css
+++ b/assets/css/admin/components.css
@@ -2543,6 +2543,73 @@
width: 5rem;
}
+.page-settings-hint {
+ font-size: 0.6875rem;
+ color: var(--admin-text-secondary);
+ margin: 0;
+ padding-top: 0.125rem;
+}
+
+/* URL editor - segmented input for page URLs */
+
+.url-editor {
+ display: flex;
+ align-items: center;
+ gap: 0;
+}
+
+.url-editor-slash {
+ padding: 0.375rem 0.25rem;
+ background: var(--admin-bg);
+ border: 1px solid var(--admin-border);
+ border-right: none;
+ font-size: 0.875rem;
+ color: var(--admin-text-tertiary);
+
+ &:first-child {
+ border-radius: var(--admin-radius) 0 0 var(--admin-radius);
+ }
+}
+
+.url-editor-input {
+ border-radius: 0;
+ flex: 1;
+ min-width: 4rem;
+
+ &:last-child {
+ border-radius: 0 var(--admin-radius) var(--admin-radius) 0;
+ }
+}
+
+.url-editor-segmented .url-editor-input {
+ flex: 0 1 auto;
+ width: auto;
+}
+
+.url-editor-prefix {
+ max-width: 8rem;
+}
+
+.url-editor-fixed {
+ padding: 0.375rem 0.5rem;
+ background: var(--admin-bg);
+ border: 1px solid var(--admin-border);
+ border-left: none;
+ border-radius: 0 var(--admin-radius) var(--admin-radius) 0;
+ font-size: 0.875rem;
+ color: var(--admin-text-tertiary);
+ font-style: italic;
+}
+
+.url-editor-fixed-path {
+ padding: 0.375rem 0.5rem;
+ background: var(--admin-bg);
+ border: 1px solid var(--admin-border);
+ border-radius: var(--admin-radius);
+ font-size: 0.875rem;
+ color: var(--admin-text-secondary);
+}
+
/* Block list in editor */
.block-list {
diff --git a/lib/berrypod/site.ex b/lib/berrypod/site.ex
index ba5f895..9bed53f 100644
--- a/lib/berrypod/site.ex
+++ b/lib/berrypod/site.ex
@@ -22,6 +22,7 @@ defmodule Berrypod.Site do
NavItem
|> where([n], n.location == ^location)
|> order_by([n], asc: n.position)
+ |> preload(:page)
|> Repo.all()
end
@@ -89,14 +90,42 @@ defmodule Berrypod.Site do
end
defp nav_item_to_map(%NavItem{} = item) do
+ # When page_id is set, resolve URL dynamically via R module
+ # This ensures nav links stay up to date when page URLs change
+ href = resolve_nav_item_url(item)
+
%{
"label" => item.label,
- "href" => item.url,
- "slug" => slug_from_url(item.url),
+ "href" => href,
+ "slug" => slug_from_url(href),
"page_id" => item.page_id
}
end
+ defp resolve_nav_item_url(%NavItem{page: %Berrypod.Pages.Page{} = page}) do
+ BerrypodWeb.R.page(page.slug)
+ end
+
+ defp resolve_nav_item_url(%NavItem{url: "/" <> slug}) when slug != "" do
+ # Internal page link stored as URL - resolve through R module
+ # This handles cases where page_id isn't set but URL points to a page
+ # Strip any trailing path segments (e.g., /collections/all -> collections)
+ base_slug = slug |> String.split("/") |> List.first()
+
+ # Route system pages through R module to get custom URL slugs
+ if Berrypod.Pages.Page.system_slug?(base_slug) do
+ BerrypodWeb.R.page(base_slug)
+ else
+ # Check if this is a custom page with a url_slug
+ case Berrypod.Pages.get_page(base_slug) do
+ %{url_slug: url_slug} when not is_nil(url_slug) -> "/" <> url_slug
+ _ -> "/" <> slug
+ end
+ end
+ end
+
+ defp resolve_nav_item_url(%NavItem{url: url}), do: url
+
defp slug_from_url("/"), do: "home"
defp slug_from_url("/collections" <> _), do: "collection"
defp slug_from_url("/products" <> _), do: "pdp"
diff --git a/lib/berrypod_web.ex b/lib/berrypod_web.ex
index 67c0883..05faf7f 100644
--- a/lib/berrypod_web.ex
+++ b/lib/berrypod_web.ex
@@ -44,6 +44,8 @@ defmodule BerrypodWeb do
use Gettext, backend: BerrypodWeb.Gettext
import Plug.Conn
+ # Runtime URL paths (custom slugs/prefixes)
+ alias BerrypodWeb.R
unquote(verified_routes())
end
@@ -53,6 +55,11 @@ defmodule BerrypodWeb do
quote do
use Phoenix.LiveView
+ # Enable sandbox access for LiveView processes in test mode
+ if Application.compile_env(:berrypod, :env) == :test do
+ on_mount BerrypodWeb.LiveSandboxHook
+ end
+
unquote(html_helpers())
end
end
@@ -93,6 +100,8 @@ defmodule BerrypodWeb do
# Common modules used in templates
alias Phoenix.LiveView.JS
alias BerrypodWeb.Layouts
+ # Runtime URL paths (custom slugs/prefixes)
+ alias BerrypodWeb.R
# Routes generation with the ~p sigil
unquote(verified_routes())
diff --git a/lib/berrypod_web/components/shop_components/cart.ex b/lib/berrypod_web/components/shop_components/cart.ex
index 37265e5..1273e68 100644
--- a/lib/berrypod_web/components/shop_components/cart.ex
+++ b/lib/berrypod_web/components/shop_components/cart.ex
@@ -6,6 +6,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
import BerrypodWeb.ShopComponents.Base
alias Berrypod.Products.{Product, ProductImage}
+ alias BerrypodWeb.R
defp close_cart_drawer_js do
Phoenix.LiveView.JS.push("close_cart_drawer")
@@ -193,7 +194,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
>
<%= if @mode != :preview do %>
<.link
- patch={"/products/#{@item.product_id}"}
+ patch={R.product(@item.product_id)}
class={["cart-item-image", !@item.image && "cart-item-image--empty"]}
data-size={if @size == :compact, do: "compact"}
style={if @item.image, do: "background-image: url('#{@item.image}');"}
@@ -212,7 +213,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
Checkout isn't available yet.
<.shop_link_outline
- href="/collections/all"
+ href={R.collection("all")}
class="order-summary-continue"
>
Continue shopping
@@ -544,7 +545,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
Remove unavailable items to checkout.
<.shop_link_outline
- href="/collections/all"
+ href={R.collection("all")}
class="order-summary-continue"
>
Continue shopping
@@ -557,7 +558,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
<.shop_link_outline
- href="/collections/all"
+ href={R.collection("all")}
class="order-summary-continue"
>
Continue shopping
diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex
index 2479796..56ac723 100644
--- a/lib/berrypod_web/components/shop_components/layout.ex
+++ b/lib/berrypod_web/components/shop_components/layout.ex
@@ -4,6 +4,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
import BerrypodWeb.ShopComponents.Cart
import BerrypodWeb.ShopComponents.Content
+ alias BerrypodWeb.R
+
@doc """
Renders the announcement bar.
@@ -153,10 +155,18 @@ defmodule BerrypodWeb.ShopComponents.Layout do
# Extract a slug from a URL for nav item matching
defp extract_slug_from_url(url) when is_binary(url) do
cond do
- url == "/" -> "home"
- String.starts_with?(url, "/collections") -> "collection"
- String.starts_with?(url, "/products") -> "pdp"
- true -> url |> String.trim_leading("/") |> String.split("/") |> List.first() || ""
+ url == "/" ->
+ "home"
+
+ true ->
+ # Extract first segment and check if it's a known prefix
+ first_segment = url |> String.trim_leading("/") |> String.split("/") |> List.first() || ""
+
+ case R.prefix_type_from_segment(first_segment) do
+ :collections -> "collection"
+ :products -> "pdp"
+ _ -> first_segment
+ end
end
end
@@ -651,7 +661,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
aria-selected="false"
>
<.link
- patch={"/products/#{item.product.slug || item.product.id}"}
+ patch={R.product(item.product.slug || item.product.id)}
class="search-result"
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
>
@@ -782,7 +792,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<% else %>
<.link
- patch={"/collections/#{category.slug}"}
+ patch={R.collection(category.slug)}
class="mobile-nav-link"
phx-click={
Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")
@@ -868,7 +878,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<% else %>
<.link
- patch={"/collections/#{category.slug}"}
+ patch={R.collection(category.slug)}
class="footer-link"
>
{category.name}
@@ -1013,7 +1023,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<.admin_cog_svg />
<% else %>
<.link
- patch={"/products/#{Map.get(@product, :slug) || Map.get(@product, :id)}"}
+ patch={R.product(Map.get(@product, :slug) || Map.get(@product, :id))}
class="stretched-link"
>
{@product.title}
@@ -629,7 +630,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
<% else %>
<.link
- patch={"/collections/#{category.slug}"}
+ patch={R.collection(category.slug)}
class="category-card"
>
Cart.put_in_session(cart)
|> put_flash(:info, "Added to basket")
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
end
def add(conn, _params) do
conn
|> put_flash(:error, "Could not add item to basket")
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
end
@doc """
@@ -60,7 +60,7 @@ defmodule BerrypodWeb.CartController do
conn
|> Cart.put_in_session(cart)
|> put_flash(:info, "Removed from basket")
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
end
@doc """
@@ -73,7 +73,7 @@ defmodule BerrypodWeb.CartController do
conn
|> Cart.put_in_session(cart)
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
end
@doc """
@@ -82,11 +82,11 @@ defmodule BerrypodWeb.CartController do
def update_country(conn, %{"country" => code}) when is_binary(code) and code != "" do
conn
|> put_session("country_code", code)
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
end
def update_country(conn, _params) do
- redirect(conn, to: ~p"/cart")
+ redirect(conn, to: R.cart())
end
defp parse_quantity(str) when is_binary(str) do
diff --git a/lib/berrypod_web/controllers/checkout_controller.ex b/lib/berrypod_web/controllers/checkout_controller.ex
index 8fa4294..6fb5f87 100644
--- a/lib/berrypod_web/controllers/checkout_controller.ex
+++ b/lib/berrypod_web/controllers/checkout_controller.ex
@@ -11,7 +11,7 @@ defmodule BerrypodWeb.CheckoutController do
unless Settings.has_secret?("stripe_api_key") do
conn
|> put_flash(:error, "Checkout isn't available yet")
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
else
cart_items = Cart.get_from_session(get_session(conn))
hydrated = Cart.hydrate(cart_items)
@@ -20,12 +20,12 @@ defmodule BerrypodWeb.CheckoutController do
hydrated == [] ->
conn
|> put_flash(:error, "Your basket is empty")
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
Enum.any?(hydrated, &(&1.is_available == false)) ->
conn
|> put_flash(:error, "Some items in your basket are no longer available")
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
true ->
track_checkout_start(conn)
@@ -45,7 +45,7 @@ defmodule BerrypodWeb.CheckoutController do
conn
|> put_flash(:error, "Something went wrong. Please try again.")
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
end
end
@@ -67,14 +67,12 @@ defmodule BerrypodWeb.CheckoutController do
}
end)
- base_url = BerrypodWeb.Endpoint.url()
-
params =
%{
mode: "payment",
line_items: line_items,
- success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
- cancel_url: "#{base_url}/cart",
+ success_url: R.url(R.checkout_success()) <> "?session_id={CHECKOUT_SESSION_ID}",
+ cancel_url: R.url(R.cart()),
metadata: %{"order_id" => order.id},
shipping_address_collection: %{
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
@@ -96,7 +94,7 @@ defmodule BerrypodWeb.CheckoutController do
conn
|> put_flash(:error, "Payment setup failed. Please try again.")
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
{:error, reason} ->
Logger.error("Stripe session creation failed: #{inspect(reason)}")
@@ -104,7 +102,7 @@ defmodule BerrypodWeb.CheckoutController do
conn
|> put_flash(:error, "Payment setup failed. Please try again.")
- |> redirect(to: ~p"/cart")
+ |> redirect(to: R.cart())
end
end
diff --git a/lib/berrypod_web/controllers/contact_controller.ex b/lib/berrypod_web/controllers/contact_controller.ex
index 74d2262..69cd9cf 100644
--- a/lib/berrypod_web/controllers/contact_controller.ex
+++ b/lib/berrypod_web/controllers/contact_controller.ex
@@ -13,17 +13,17 @@ defmodule BerrypodWeb.ContactController do
{:ok, _} ->
conn
|> put_flash(:info, "Message sent! We'll get back to you soon.")
- |> redirect(to: ~p"/contact")
+ |> redirect(to: R.contact())
{:error, :invalid_params} ->
conn
|> put_flash(:error, "Please fill in all required fields.")
- |> redirect(to: ~p"/contact")
+ |> redirect(to: R.contact())
{:error, _} ->
conn
|> put_flash(:error, "Sorry, something went wrong. Please try again.")
- |> redirect(to: ~p"/contact")
+ |> redirect(to: R.contact())
end
end
end
diff --git a/lib/berrypod_web/controllers/order_lookup_controller.ex b/lib/berrypod_web/controllers/order_lookup_controller.ex
index e79034a..62efc0c 100644
--- a/lib/berrypod_web/controllers/order_lookup_controller.ex
+++ b/lib/berrypod_web/controllers/order_lookup_controller.ex
@@ -19,7 +19,7 @@ defmodule BerrypodWeb.OrderLookupController do
:error,
"No orders found for that address. Make sure you use the same email you checked out with."
)
- |> redirect(to: ~p"/contact")
+ |> redirect(to: R.contact())
else
token = generate_token(email)
link = BerrypodWeb.Endpoint.url() <> ~p"/orders/verify/#{token}"
@@ -30,14 +30,14 @@ defmodule BerrypodWeb.OrderLookupController do
:info,
"We've sent a link to your email address. It'll expire after an hour."
)
- |> redirect(to: ~p"/contact")
+ |> redirect(to: R.contact())
end
end
def lookup(conn, _params) do
conn
|> put_flash(:error, "Please enter your email address.")
- |> redirect(to: ~p"/contact")
+ |> redirect(to: R.contact())
end
def verify(conn, %{"token" => token}) do
@@ -45,17 +45,17 @@ defmodule BerrypodWeb.OrderLookupController do
{:ok, email} ->
conn
|> put_session(:order_lookup_email, email)
- |> redirect(to: ~p"/orders")
+ |> redirect(to: R.orders())
{:error, :expired} ->
conn
|> put_flash(:error, "That link has expired. Please request a new one.")
- |> redirect(to: ~p"/contact")
+ |> redirect(to: R.contact())
{:error, _} ->
conn
|> put_flash(:error, "That link is invalid.")
- |> redirect(to: ~p"/contact")
+ |> redirect(to: R.contact())
end
end
diff --git a/lib/berrypod_web/controllers/seo_controller.ex b/lib/berrypod_web/controllers/seo_controller.ex
index f7fb2c7..f275d38 100644
--- a/lib/berrypod_web/controllers/seo_controller.ex
+++ b/lib/berrypod_web/controllers/seo_controller.ex
@@ -2,6 +2,7 @@ defmodule BerrypodWeb.SeoController do
use BerrypodWeb, :controller
alias Berrypod.{Pages, Products}
+ alias BerrypodWeb.R
def robots(conn, _params) do
base = BerrypodWeb.Endpoint.url()
@@ -29,23 +30,23 @@ defmodule BerrypodWeb.SeoController do
categories = Products.list_categories()
static_pages = [
- {"/", "daily", "1.0"},
- {"/collections/all", "daily", "0.9"},
- {"/about", "monthly", "0.5"},
- {"/contact", "monthly", "0.5"},
- {"/delivery", "monthly", "0.5"},
- {"/privacy", "monthly", "0.3"},
- {"/terms", "monthly", "0.3"}
+ {R.home(), "daily", "1.0"},
+ {R.collection("all"), "daily", "0.9"},
+ {R.about(), "monthly", "0.5"},
+ {R.contact(), "monthly", "0.5"},
+ {R.delivery(), "monthly", "0.5"},
+ {R.privacy(), "monthly", "0.3"},
+ {R.terms(), "monthly", "0.3"}
]
category_pages =
Enum.map(categories, fn cat ->
- {"/collections/#{cat.slug}", "daily", "0.8"}
+ {R.collection(cat.slug), "daily", "0.8"}
end)
product_pages =
Enum.map(products, fn product ->
- {"/products/#{product.slug}", "weekly", "0.9"}
+ {R.product(product.slug), "weekly", "0.9"}
end)
custom_pages =
diff --git a/lib/berrypod_web/live/admin/product_show.ex b/lib/berrypod_web/live/admin/product_show.ex
index b4aaf2a..a5063cf 100644
--- a/lib/berrypod_web/live/admin/product_show.ex
+++ b/lib/berrypod_web/live/admin/product_show.ex
@@ -4,6 +4,7 @@ defmodule BerrypodWeb.Admin.ProductShow do
alias Berrypod.Products
alias Berrypod.Products.{Product, ProductImage, ProductVariant}
alias Berrypod.Cart
+ alias BerrypodWeb.R
@impl true
def mount(%{"id" => id}, _session, socket) do
@@ -109,7 +110,7 @@ defmodule BerrypodWeb.Admin.ProductShow do
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" />
<.link
- navigate={~p"/products/#{@product.slug}"}
+ navigate={R.product(@product.slug)}
class="admin-btn admin-btn-ghost admin-btn-sm"
>
View on shop <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" />
diff --git a/lib/berrypod_web/live/admin/settings.ex b/lib/berrypod_web/live/admin/settings.ex
index f9acb42..152ae67 100644
--- a/lib/berrypod_web/live/admin/settings.ex
+++ b/lib/berrypod_web/live/admin/settings.ex
@@ -18,7 +18,15 @@ defmodule BerrypodWeb.Admin.Settings do
|> assign(:from_address_status, :idle)
|> assign(:signing_secret_status, :idle)
|> assign_stripe_state()
- |> assign_products_state()}
+ |> assign_products_state()
+ |> assign_url_prefixes()}
+ end
+
+ defp assign_url_prefixes(socket) do
+ socket
+ |> assign(:products_prefix, Settings.get_url_prefix(:products))
+ |> assign(:collections_prefix, Settings.get_url_prefix(:collections))
+ |> assign(:prefix_status, :idle)
end
# -- Stripe assigns --
@@ -117,6 +125,51 @@ defmodule BerrypodWeb.Admin.Settings do
end
end
+ # -- Events: URL prefixes --
+
+ def handle_event("change_prefix", _params, socket) do
+ {:noreply, assign(socket, :prefix_status, :idle)}
+ end
+
+ def handle_event("save_prefixes", %{"prefixes" => params}, socket) do
+ products_prefix = params["products"] || ""
+ collections_prefix = params["collections"] || ""
+
+ errors = []
+
+ # Update products prefix if changed
+ errors =
+ if products_prefix != socket.assigns.products_prefix do
+ case Settings.update_url_prefix(:products, products_prefix) do
+ {:ok, _} -> errors
+ {:error, reason} -> [{:products, reason} | errors]
+ end
+ else
+ errors
+ end
+
+ # Update collections prefix if changed
+ errors =
+ if collections_prefix != socket.assigns.collections_prefix do
+ case Settings.update_url_prefix(:collections, collections_prefix) do
+ {:ok, _} -> errors
+ {:error, reason} -> [{:collections, reason} | errors]
+ end
+ else
+ errors
+ end
+
+ if errors == [] do
+ {:noreply,
+ socket
+ |> assign_url_prefixes()
+ |> assign(:prefix_status, :saved)}
+ else
+ error_message = format_prefix_errors(errors)
+ {:noreply, put_flash(socket, :error, error_message)}
+ end
+ end
+
# -- Events: Stripe --
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
@@ -388,6 +441,67 @@ defmodule BerrypodWeb.Admin.Settings do
+
+ <%!-- URL prefixes --%>
+
+ URL prefixes
+
+ Customise the URL structure for products and collections.
+ Old URLs will automatically redirect to the new ones.
+
+
+
"""
end
@@ -639,4 +753,20 @@ defmodule BerrypodWeb.Admin.Settings do
true -> "#{div(diff, 86400)} days ago"
end
end
+
+ defp format_prefix_errors(errors) do
+ Enum.map_join(errors, "; ", fn {field, reason} ->
+ field_name = if field == :products, do: "Products", else: "Collections"
+
+ message =
+ case reason do
+ :empty_prefix -> "can't be blank"
+ :invalid_format -> "must contain only letters, numbers, and hyphens"
+ :reserved_prefix -> "is reserved"
+ _ -> "is invalid"
+ end
+
+ "#{field_name} prefix #{message}"
+ end)
+ end
end
diff --git a/lib/berrypod_web/live/shop/pages/checkout_success.ex b/lib/berrypod_web/live/shop/pages/checkout_success.ex
index 80322a3..f866668 100644
--- a/lib/berrypod_web/live/shop/pages/checkout_success.ex
+++ b/lib/berrypod_web/live/shop/pages/checkout_success.ex
@@ -8,6 +8,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
import Phoenix.LiveView, only: [connected?: 1, redirect: 2]
alias Berrypod.{Analytics, Orders, Pages}
+ alias BerrypodWeb.R
def init(socket, %{"session_id" => session_id}, _uri) do
order = Orders.get_order_by_stripe_session(session_id)
@@ -21,7 +22,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do
attrs =
BerrypodWeb.AnalyticsHook.attrs(socket)
- |> Map.merge(%{pathname: "/checkout/success", revenue: order.total})
+ |> Map.merge(%{pathname: R.checkout_success(), revenue: order.total})
Analytics.track_event("purchase", attrs)
end
@@ -54,7 +55,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
end
def init(socket, _params, _uri) do
- {:redirect, redirect(socket, to: "/")}
+ {:redirect, redirect(socket, to: R.home())}
end
def handle_params(_params, _uri, socket) do
diff --git a/lib/berrypod_web/live/shop/pages/collection.ex b/lib/berrypod_web/live/shop/pages/collection.ex
index 2f287c2..2ce9a30 100644
--- a/lib/berrypod_web/live/shop/pages/collection.ex
+++ b/lib/berrypod_web/live/shop/pages/collection.ex
@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3]
alias Berrypod.{Pages, Pagination, Products}
+ alias BerrypodWeb.R
@sort_options [
{"featured", "Featured"},
@@ -29,6 +30,11 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
{:noreply, socket}
end
+ # When accessed via custom URL (e.g. /shop) without a collection slug, show all products
+ def handle_params(params, uri, socket) when not is_map_key(params, "slug") do
+ handle_params(Map.put(params, "slug", "all"), uri, socket)
+ end
+
def handle_params(%{"slug" => slug} = params, _uri, socket) do
sort = params["sort"] || "featured"
page_num = Pagination.parse_page(params)
@@ -39,7 +45,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
socket
|> assign(:page_title, title)
|> assign(:page_description, collection_description(title))
- |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}")
+ |> assign(:og_url, R.url(R.collection(slug)))
|> assign(:collection_title, title)
|> assign(:collection_slug, slug)
|> assign(:current_category, category)
@@ -53,7 +59,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
socket =
socket
|> put_flash(:error, "Collection not found")
- |> push_navigate(to: "/collections/all")
+ |> push_navigate(to: R.collection("all"))
{:noreply, socket}
end
@@ -67,7 +73,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
category -> category.slug
end
- {:noreply, push_patch(socket, to: "/collections/#{slug}?sort=#{sort}")}
+ {:noreply, push_patch(socket, to: R.collection(slug) <> "?sort=#{sort}")}
end
def handle_event(_event, _params, _socket), do: :cont
diff --git a/lib/berrypod_web/live/shop/pages/contact.ex b/lib/berrypod_web/live/shop/pages/contact.ex
index 0d7a380..a95cefe 100644
--- a/lib/berrypod_web/live/shop/pages/contact.ex
+++ b/lib/berrypod_web/live/shop/pages/contact.ex
@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
import Phoenix.LiveView, only: [push_navigate: 2, put_flash: 3]
alias Berrypod.{ContactNotifier, Orders}
+ alias BerrypodWeb.R
alias Berrypod.Orders.OrderNotifier
alias Berrypod.Pages
alias BerrypodWeb.OrderLookupController
@@ -21,7 +22,7 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
:page_description,
"Get in touch with us for any questions or help with your order."
)
- |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/contact")
+ |> assign(:og_url, R.url(R.contact()))
|> assign(:tracking_state, :idle)
|> assign(:page, page)
@@ -54,7 +55,7 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
{:noreply,
socket
|> put_flash(:info, "Message sent! We'll get back to you soon.")
- |> push_navigate(to: "/contact")}
+ |> push_navigate(to: R.contact())}
{:error, :invalid_params} ->
{:noreply, put_flash(socket, :error, "Please fill in all required fields.")}
diff --git a/lib/berrypod_web/live/shop/pages/content.ex b/lib/berrypod_web/live/shop/pages/content.ex
index 7978453..dd97922 100644
--- a/lib/berrypod_web/live/shop/pages/content.ex
+++ b/lib/berrypod_web/live/shop/pages/content.ex
@@ -9,6 +9,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
alias Berrypod.LegalPages
alias Berrypod.Pages
alias Berrypod.Theme.PreviewData
+ alias BerrypodWeb.R
def init(socket, _params, _uri) do
# Content pages load in handle_params based on live_action
@@ -38,7 +39,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
%{
page_title: "About",
page_description: "Your story goes here – this is sample content for the demo shop",
- og_url: BerrypodWeb.Endpoint.url() <> "/about"
+ og_url: R.url(R.about())
},
PreviewData.about_content()
}
@@ -49,7 +50,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
%{
page_title: "Delivery & returns",
page_description: "Everything you need to know about shipping and returns.",
- og_url: BerrypodWeb.Endpoint.url() <> "/delivery"
+ og_url: R.url(R.delivery())
},
LegalPages.delivery_content()
}
@@ -60,7 +61,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
%{
page_title: "Privacy policy",
page_description: "How we handle your personal information.",
- og_url: BerrypodWeb.Endpoint.url() <> "/privacy"
+ og_url: R.url(R.privacy())
},
LegalPages.privacy_content()
}
@@ -71,7 +72,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
%{
page_title: "Terms of service",
page_description: "The terms and conditions governing purchases from our shop.",
- og_url: BerrypodWeb.Endpoint.url() <> "/terms"
+ og_url: R.url(R.terms())
},
LegalPages.terms_content()
}
diff --git a/lib/berrypod_web/live/shop/pages/order_detail.ex b/lib/berrypod_web/live/shop/pages/order_detail.ex
index 91a5f24..d161316 100644
--- a/lib/berrypod_web/live/shop/pages/order_detail.ex
+++ b/lib/berrypod_web/live/shop/pages/order_detail.ex
@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
import Phoenix.LiveView, only: [push_navigate: 2]
alias Berrypod.{Orders, Pages}
+ alias BerrypodWeb.R
alias Berrypod.Products
alias Berrypod.Products.ProductImage
@@ -54,7 +55,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
{:noreply, socket}
else
- {:noreply, push_navigate(socket, to: "/orders")}
+ {:noreply, push_navigate(socket, to: R.orders())}
end
end
diff --git a/lib/berrypod_web/live/shop/pages/product.ex b/lib/berrypod_web/live/shop/pages/product.ex
index ac0a03f..f942b0c 100644
--- a/lib/berrypod_web/live/shop/pages/product.ex
+++ b/lib/berrypod_web/live/shop/pages/product.ex
@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
alias Berrypod.{Analytics, Cart, Pages}
+ alias BerrypodWeb.R
alias Berrypod.Images.Optimizer
alias Berrypod.Products
alias Berrypod.Products.{Product, ProductImage}
@@ -15,11 +16,11 @@ defmodule BerrypodWeb.Shop.Pages.Product do
# Try to get product by slug, including discontinued products
case Products.get_product_by_slug(slug, preload: [:variants, :images]) do
nil ->
- {:noreply, push_navigate(socket, to: "/collections/all")}
+ {:noreply, push_navigate(socket, to: R.collection("all"))}
%{visible: false, status: status} when status != "discontinued" ->
# Hidden but not discontinued - redirect away
- {:noreply, push_navigate(socket, to: "/collections/all")}
+ {:noreply, push_navigate(socket, to: R.collection("all"))}
product ->
all_images =
@@ -42,12 +43,12 @@ defmodule BerrypodWeb.Shop.Pages.Product do
if connected?(socket) and socket.assigns[:analytics_visitor_hash] do
Analytics.track_event(
"product_view",
- Map.put(BerrypodWeb.AnalyticsHook.attrs(socket), :pathname, "/products/#{slug}")
+ Map.put(BerrypodWeb.AnalyticsHook.attrs(socket), :pathname, R.product(slug))
)
end
base = BerrypodWeb.Endpoint.url()
- og_url = base <> "/products/#{slug}"
+ og_url = R.url(R.product(slug))
og_image = og_image_url(all_images)
page = Pages.get_page("pdp")
@@ -107,7 +108,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
Map.put(
BerrypodWeb.AnalyticsHook.attrs(socket),
:pathname,
- "/products/#{socket.assigns.product.slug}"
+ R.product(socket.assigns.product.slug)
)
)
end
@@ -204,7 +205,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
opt_type.values
|> Enum.map(fn value ->
params = Map.put(selected_options, opt_type.name, value.title)
- {value.title, "/products/#{slug}?#{URI.encode_query(params)}"}
+ {value.title, R.product(slug) <> "?#{URI.encode_query(params)}"}
end)
|> Map.new()
@@ -278,7 +279,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
# ── JSON-LD and meta helpers ─────────────────────────────────────────
- defp product_json_ld(product, url, image, base) do
+ defp product_json_ld(product, url, image, _base) do
category_slug =
if product.category,
do: product.category |> String.downcase() |> String.replace(" ", "-"),
@@ -286,13 +287,13 @@ defmodule BerrypodWeb.Shop.Pages.Product do
breadcrumbs =
[
- %{"@type" => "ListItem", "position" => 1, "name" => "Home", "item" => base <> "/"},
+ %{"@type" => "ListItem", "position" => 1, "name" => "Home", "item" => R.url(R.home())},
product.category &&
%{
"@type" => "ListItem",
"position" => 2,
"name" => product.category,
- "item" => base <> "/collections/#{category_slug}"
+ "item" => R.url(R.collection(category_slug))
},
%{
"@type" => "ListItem",
diff --git a/lib/berrypod_web/live/shop/pages/search.ex b/lib/berrypod_web/live/shop/pages/search.ex
index c84dc47..179fffd 100644
--- a/lib/berrypod_web/live/shop/pages/search.ex
+++ b/lib/berrypod_web/live/shop/pages/search.ex
@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Search do
import Phoenix.LiveView, only: [push_patch: 2]
alias Berrypod.{Pages, Search}
+ alias BerrypodWeb.R
def init(socket, _params, _uri) do
page = Pages.get_page("search")
@@ -32,7 +33,7 @@ defmodule BerrypodWeb.Shop.Pages.Search do
end
def handle_event("search_submit", %{"q" => query}, socket) do
- {:noreply, push_patch(socket, to: "/search?q=#{query}")}
+ {:noreply, push_patch(socket, to: R.search() <> "?q=#{query}")}
end
def handle_event(_event, _params, _socket), do: :cont
diff --git a/lib/berrypod_web/page_editor_hook.ex b/lib/berrypod_web/page_editor_hook.ex
index 45ffb21..fda6b68 100644
--- a/lib/berrypod_web/page_editor_hook.ex
+++ b/lib/berrypod_web/page_editor_hook.ex
@@ -270,10 +270,11 @@ defmodule BerrypodWeb.PageEditorHook do
end
# Initialize settings form from current page if not already set
- # Only custom pages have editable settings (meta_description, published, etc.)
+ # Init settings form for pages with editable settings
+ # Custom pages: all settings editable
+ # System pages: only url_slug editable (except home)
socket =
- if is_nil(socket.assigns[:settings_form]) && socket.assigns[:page] &&
- socket.assigns.page[:type] == "custom" do
+ if is_nil(socket.assigns[:settings_form]) && socket.assigns[:page] do
init_settings_form(socket)
else
socket
@@ -930,14 +931,14 @@ defmodule BerrypodWeb.PageEditorHook do
# Catch-all for unknown theme actions
defp handle_theme_action(_action, _params, socket), do: {:halt, socket}
- # ── Settings editing actions (custom page settings) ────────────────
+ # ── Settings editing actions (page settings) ────────────────
# Validate page settings form (live as-you-type)
defp handle_settings_action("validate_page", %{"page" => params}, socket) do
page = socket.assigns.page
- # Only allow editing custom pages
- if page && page.type == "custom" do
+ # Allow editing for custom pages (all fields) or system pages (url_slug only)
+ if page && page.type in ["custom", "system"] do
socket =
socket
|> assign(:settings_form, params)
@@ -954,43 +955,127 @@ defmodule BerrypodWeb.PageEditorHook do
defp handle_settings_action("save_page", %{"page" => params}, socket) do
page = socket.assigns.page
- # Only allow editing custom pages
- if page && page.type == "custom" do
- # Normalize checkbox fields (unchecked checkboxes aren't sent)
- params =
- params
- |> Map.put_new("published", "false")
- |> Map.put_new("show_in_nav", "false")
+ cond do
+ # Custom pages: save all settings including url_slug
+ page && page.type == "custom" ->
+ save_custom_page_settings(socket, page, params)
- old_slug = page.slug
+ # System pages with url_prefix (collections, products, orders)
+ page && page.type == "system" && params["url_prefix"] ->
+ save_system_page_url_prefix(socket, page, params["url_prefix"])
- # Fetch the Page struct from DB (assigns.page may be a map from cache)
- page_struct = Pages.get_page_struct(page.slug)
+ # System pages: only save url_slug (other fields are read-only)
+ page && page.type == "system" && params["url_slug"] ->
+ save_system_page_url_slug(socket, page, params["url_slug"])
- case Pages.update_custom_page(page_struct, params) do
- {:ok, updated_page} ->
- # Reinitialize form from saved page
+ true ->
+ {:halt, socket}
+ end
+ end
+
+ defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
+
+ defp save_custom_page_settings(socket, page, params) do
+ # Normalize checkbox fields (unchecked checkboxes aren't sent)
+ params =
+ params
+ |> Map.put_new("published", "false")
+ |> Map.put_new("show_in_nav", "false")
+
+ old_slug = page.slug
+
+ # Fetch the Page struct from DB (assigns.page may be a map from cache)
+ page_struct = Pages.get_page_struct(page.slug)
+
+ # Handle url_slug change separately to ensure redirects are created
+ url_slug = params["url_slug"]
+ old_url_slug = page[:url_slug]
+
+ # First update the main page settings
+ case Pages.update_custom_page(page_struct, params) do
+ {:ok, updated_page} ->
+ # If url_slug changed, handle redirect creation
+ socket =
+ if url_slug != old_url_slug do
+ Pages.update_page_url_slug(updated_page, url_slug)
+ # Reload page to get updated url_slug
+ updated_page = Pages.get_page(updated_page.slug)
+ assign(socket, :page, updated_page)
+ else
+ assign(socket, :page, updated_page)
+ end
+
+ socket =
+ socket
+ |> assign(:settings_form, nil)
+ |> assign(:settings_dirty, false)
+ |> assign(:settings_save_status, :saved)
+
+ # Reinit form with new page values
+ socket = init_settings_form(socket)
+
+ # If slug changed, redirect to new URL
+ socket =
+ if updated_page.slug != old_slug do
+ push_navigate(socket, to: "/#{updated_page.slug}")
+ else
+ socket
+ end
+
+ {:halt, socket}
+
+ {:error, _changeset} ->
+ socket = assign(socket, :settings_save_status, :error)
+ {:halt, socket}
+ end
+ end
+
+ defp save_system_page_url_slug(socket, page, url_slug) do
+ case Pages.update_page_url_slug(page.slug, url_slug) do
+ {:ok, _updated} ->
+ # Reload page from cache to get updated data
+ updated_page = Pages.get_page(page.slug)
+
+ socket =
+ socket
+ |> assign(:page, updated_page)
+ |> assign(:settings_form, nil)
+ |> assign(:settings_dirty, false)
+ |> assign(:settings_save_status, :saved)
+
+ socket = init_settings_form(socket)
+ {:halt, socket}
+
+ {:error, _} ->
+ socket = assign(socket, :settings_save_status, :error)
+ {:halt, socket}
+ end
+ end
+
+ # Save URL prefix for prefixed pages (collection, pdp, orders)
+ defp save_system_page_url_prefix(socket, page, new_prefix) do
+ # Determine the prefix type based on the page slug
+ prefix_type =
+ case page.slug do
+ "collection" -> :collections
+ "pdp" -> :products
+ "orders" -> :orders
+ "order_detail" -> :orders
+ _ -> nil
+ end
+
+ if prefix_type do
+ case Settings.update_url_prefix(prefix_type, new_prefix) do
+ {:ok, _} ->
socket =
socket
- |> assign(:page, updated_page)
|> assign(:settings_form, nil)
|> assign(:settings_dirty, false)
|> assign(:settings_save_status, :saved)
- # Reinit form with new page values
- socket = init_settings_form(socket)
-
- # If slug changed, redirect to new URL
- socket =
- if updated_page.slug != old_slug do
- push_navigate(socket, to: "/#{updated_page.slug}")
- else
- socket
- end
-
{:halt, socket}
- {:error, _changeset} ->
+ {:error, _reason} ->
socket = assign(socket, :settings_save_status, :error)
{:halt, socket}
end
@@ -999,9 +1084,6 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
- # Catch-all for unknown settings actions
- defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
-
# --- Site tab event handlers ---
defp handle_site_action("update", %{"site" => site_params}, socket) do
@@ -1266,13 +1348,33 @@ defmodule BerrypodWeb.PageEditorHook do
defp has_settings_changed?(page, params) do
page.title != (params["title"] || "") or
page.slug != (params["slug"] || "") or
+ (page[:url_slug] || "") != (params["url_slug"] || "") or
(page.meta_description || "") != (params["meta_description"] || "") or
to_string(page.published) != (params["published"] || "false") or
to_string(page.show_in_nav) != (params["show_in_nav"] || "false") or
(page.nav_label || "") != (params["nav_label"] || "") or
- to_string(page.nav_position || 0) != (params["nav_position"] || "0")
+ to_string(page.nav_position || 0) != (params["nav_position"] || "0") or
+ has_prefix_changed?(page, params)
end
+ # Check if URL prefix changed for prefixed pages
+ defp has_prefix_changed?(page, params) do
+ case params["url_prefix"] do
+ nil ->
+ false
+
+ new_prefix ->
+ prefix_type = prefix_type_for_page(page.slug)
+ prefix_type != nil and BerrypodWeb.R.prefix(prefix_type) != new_prefix
+ end
+ end
+
+ defp prefix_type_for_page("collection"), do: :collections
+ defp prefix_type_for_page("pdp"), do: :products
+ defp prefix_type_for_page("orders"), do: :orders
+ defp prefix_type_for_page("order_detail"), do: :orders
+ defp prefix_type_for_page(_), do: nil
+
# Initialize settings form from page values
defp init_settings_form(socket) do
page = socket.assigns.page
@@ -1280,6 +1382,7 @@ defmodule BerrypodWeb.PageEditorHook do
form = %{
"title" => page.title || "",
"slug" => page.slug || "",
+ "url_slug" => page[:url_slug] || "",
"meta_description" => page.meta_description || "",
"published" => to_string(page.published),
"show_in_nav" => to_string(page.show_in_nav),
@@ -1506,6 +1609,15 @@ defmodule BerrypodWeb.PageEditorHook do
defp save_all_tabs(socket) do
socket
|> maybe_save_page()
+ |> maybe_save_settings()
+ |> maybe_finish_save()
+ end
+
+ # If settings save triggered a navigation, don't overwrite the socket
+ defp maybe_finish_save(%{redirected: {:live, _, _}} = socket), do: socket
+
+ defp maybe_finish_save(socket) do
+ socket
|> maybe_save_theme()
|> maybe_save_site()
|> assign(:editor_save_status, :saved)
@@ -1537,6 +1649,124 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
+ defp maybe_save_settings(socket) do
+ if socket.assigns[:settings_dirty] do
+ page = socket.assigns.page
+ params = socket.assigns[:settings_form] || %{}
+
+ cond do
+ # Custom pages: save all settings including url_slug
+ page && page.type == "custom" ->
+ do_save_custom_page_settings(socket, page, params)
+
+ # System pages with url_prefix (collections, products, orders)
+ page && page.type == "system" && params["url_prefix"] ->
+ do_save_system_page_url_prefix(socket, page, params["url_prefix"])
+
+ # System pages: only save url_slug
+ page && page.type == "system" && params["url_slug"] ->
+ do_save_system_page_url_slug(socket, page, params["url_slug"])
+
+ true ->
+ socket
+ end
+ else
+ socket
+ end
+ end
+
+ defp do_save_custom_page_settings(socket, page, params) do
+ params =
+ params
+ |> Map.put_new("published", "false")
+ |> Map.put_new("show_in_nav", "false")
+
+ page_struct = Pages.get_page_struct(page.slug)
+ url_slug = params["url_slug"]
+ old_url_slug = page[:url_slug]
+
+ case Pages.update_custom_page(page_struct, params) do
+ {:ok, updated_page} ->
+ if url_slug != old_url_slug do
+ Pages.update_page_url_slug(updated_page, url_slug)
+ end
+
+ updated_page = Pages.get_page(updated_page.slug)
+
+ socket
+ |> assign(:page, updated_page)
+ |> assign(:settings_form, nil)
+ |> assign(:settings_dirty, false)
+ |> init_settings_form()
+
+ {:error, _changeset} ->
+ socket
+ end
+ end
+
+ defp do_save_system_page_url_slug(socket, page, url_slug) do
+ old_url = Pages.Page.effective_url(page)
+
+ case Pages.update_page_url_slug(page.slug, url_slug) do
+ {:ok, _updated} ->
+ updated_page = Pages.get_page(page.slug)
+ new_url = Pages.Page.effective_url(updated_page)
+
+ socket =
+ socket
+ |> assign(:page, updated_page)
+ |> assign(:settings_form, nil)
+ |> assign(:settings_dirty, false)
+ |> init_settings_form()
+
+ # If URL changed, navigate to the new URL
+ if old_url != new_url do
+ push_navigate(socket, to: "/#{new_url}?edit=page")
+ else
+ socket
+ end
+
+ {:error, _reason} ->
+ socket
+ end
+ end
+
+ defp do_save_system_page_url_prefix(socket, page, new_prefix) do
+ prefix_type = prefix_type_for_page(page.slug)
+
+ if prefix_type do
+ old_prefix = BerrypodWeb.R.prefix(prefix_type)
+
+ case Settings.update_url_prefix(prefix_type, new_prefix) do
+ {:ok, _} ->
+ socket =
+ socket
+ |> assign(:settings_form, nil)
+ |> assign(:settings_dirty, false)
+
+ # If prefix changed, navigate to the new URL
+ if old_prefix != new_prefix do
+ # For collection page, navigate to /new-prefix/all
+ new_path =
+ case page.slug do
+ "collection" -> "/#{new_prefix}/all?edit=page"
+ "pdp" -> "/#{new_prefix}?edit=page"
+ _ -> "/#{new_prefix}?edit=page"
+ end
+
+ push_navigate(socket, to: new_path)
+ else
+ socket
+ end
+
+ {:error, _reason} ->
+ socket
+ end
+ else
+ socket
+ end
+ end
+
defp maybe_save_theme(socket) do
if socket.assigns[:theme_dirty] do
case Settings.update_theme_settings(Map.from_struct(socket.assigns.theme_editor_settings)) do
diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex
index a6d65e5..ee37b06 100644
--- a/lib/berrypod_web/page_renderer.ex
+++ b/lib/berrypod_web/page_renderer.ex
@@ -19,6 +19,7 @@ defmodule BerrypodWeb.PageRenderer do
import BerrypodWeb.CoreComponents, only: [icon: 1, external_link: 1]
alias Berrypod.Cart
+ alias BerrypodWeb.R
# ── Public API ──────────────────────────────────────────────────
@@ -88,6 +89,7 @@ defmodule BerrypodWeb.PageRenderer do
editor_dirty={@editor_dirty}
theme_dirty={Map.get(assigns, :theme_dirty, false)}
site_dirty={Map.get(assigns, :site_dirty, false)}
+ settings_dirty={Map.get(assigns, :settings_dirty, false)}
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
editor_save_status={@editor_save_status}
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
@@ -373,14 +375,18 @@ defmodule BerrypodWeb.PageRenderer do
defp page_settings_section(assigns) do
form = assigns.form || %{}
is_custom = assigns.page[:type] == "custom"
-
page = assigns.page
+ # Determine URL structure for this page
+ url_info = compute_url_info(page, form)
+
assigns =
assigns
|> assign(:is_custom, is_custom)
+ |> assign(:url_info, url_info)
|> assign(:form_title, form["title"] || page[:title] || "")
|> assign(:form_slug, form["slug"] || page[:slug] || "")
+ |> assign(:form_url_slug, form["url_slug"] || page[:url_slug] || "")
|> assign(:form_meta, form["meta_description"] || page[:meta_description] || "")
|> assign(:form_published, form_checked?(form, "published", page[:published]))
|> assign(:form_show_in_nav, form_checked?(form, "show_in_nav", page[:show_in_nav]))
@@ -415,22 +421,8 @@ defmodule BerrypodWeb.PageRenderer do
/>
-
+ <%!-- URL editor - different layouts based on page type --%>
+ <.url_editor url_info={@url_info} is_custom={@is_custom} form_slug={@form_slug} />
Meta description
@@ -497,6 +489,166 @@ defmodule BerrypodWeb.PageRenderer do
"""
end
+ # URL editor component - renders the right UI based on page type
+ attr :url_info, :map, required: true
+ attr :is_custom, :boolean, required: true
+ attr :form_slug, :string, required: true
+
+ defp url_editor(%{url_info: %{type: :none}} = assigns) do
+ # Home page - no URL field
+ ~H""
+ end
+
+ defp url_editor(%{url_info: %{type: :simple}} = assigns) do
+ # Simple single-segment URL (about, contact, cart, custom pages, etc.)
+ ~H"""
+
+
URL
+
+ /
+
+
+
{@url_info.hint}
+
+ """
+ end
+
+ defp url_editor(%{url_info: %{type: :prefixed}} = assigns) do
+ # Prefixed URL (collections, products, orders)
+ ~H"""
+
+
URL
+
+ /
+
+ /
+ {@url_info.suffix}
+
+
{@url_info.hint}
+
+ """
+ end
+
+ defp url_editor(%{url_info: %{type: :fixed}} = assigns) do
+ # Fixed URL (checkout/success, etc.)
+ ~H"""
+
+
URL
+
+ {@url_info.path}
+
+
+ """
+ end
+
+ # Compute URL info for a page based on its type
+ # Uses generic placeholders for prefixed pages since we're editing the template
+ defp compute_url_info(page, form) do
+ slug = page[:slug]
+
+ case slug do
+ # Home - no URL field
+ "home" ->
+ %{type: :none}
+
+ # Prefixed pages - show editable prefix + placeholder suffix
+ "collection" ->
+ prefix = form["url_prefix"] || R.prefix(:collections)
+
+ %{
+ type: :prefixed,
+ prefix: prefix,
+ prefix_type: :collections,
+ suffix: ":slug",
+ hint: "Changing the prefix affects all collection pages"
+ }
+
+ "pdp" ->
+ prefix = form["url_prefix"] || R.prefix(:products)
+
+ %{
+ type: :prefixed,
+ prefix: prefix,
+ prefix_type: :products,
+ suffix: ":id",
+ hint: "Changing the prefix affects all product pages"
+ }
+
+ "orders" ->
+ prefix = form["url_prefix"] || R.prefix(:orders)
+
+ %{
+ type: :prefixed,
+ prefix: prefix,
+ prefix_type: :orders,
+ suffix: ":number",
+ hint: "Changing the prefix affects all order pages"
+ }
+
+ "order_detail" ->
+ prefix = form["url_prefix"] || R.prefix(:orders)
+
+ %{
+ type: :prefixed,
+ prefix: prefix,
+ prefix_type: :orders,
+ suffix: ":number",
+ hint: "Changing the prefix affects all order pages"
+ }
+
+ # Fixed paths
+ "checkout_success" ->
+ %{type: :fixed, path: "/checkout/success"}
+
+ "error" ->
+ %{type: :none}
+
+ # Simple system pages and custom pages
+ _ ->
+ if page[:type] == "custom" do
+ # Custom page - edit the slug directly
+ value = form["slug"] || slug
+
+ %{
+ type: :simple,
+ value: value,
+ field_name: "page[slug]",
+ hint: nil
+ }
+ else
+ # System page - edit the url_slug override
+ value = form["url_slug"] || page[:url_slug] || ""
+
+ %{
+ type: :simple,
+ value: value,
+ field_name: "page[url_slug]",
+ hint:
+ if(value == "",
+ do: "Default: /#{slug}",
+ else: "Old URL /#{slug} will redirect here"
+ )
+ }
+ end
+ end
+ end
+
defp form_checked?(form, key, page_value) when is_map(form) do
case form[key] do
"true" -> true
@@ -842,7 +994,7 @@ defmodule BerrypodWeb.PageRenderer do
<% end %>
@@ -1129,20 +1281,20 @@ defmodule BerrypodWeb.PageRenderer do
This link has expired or is invalid.
- Head back to the <.link patch="/contact">contact page to request a new one.
+ Head back to the <.link patch={R.contact()}>contact page to request a new one.
<% assigns[:orders] == [] -> %>
No orders found for that email address.
- If something doesn't look right, <.link patch="/contact">get in touch.
+ If something doesn't look right, <.link patch={R.contact()}>get in touch.
<% true -> %>
<%= for order <- assigns[:orders] do %>
- <.link patch={"/orders/#{order.order_number}"} class="order-summary-card">
+ <.link patch={R.order(order.order_number)} class="order-summary-card">
{order.order_number}
@@ -1184,7 +1336,7 @@ defmodule BerrypodWeb.PageRenderer do
~H"""
<%= if assigns[:order] do %>