integrate R module and add url editor ui

Replaces hardcoded paths with R module throughout:
- Shop components: layout nav, cart, product links
- Controllers: cart, checkout, contact, seo, order lookup
- Shop pages: collection, product, search, checkout success, etc.
- Site context: nav item url resolution

Admin URL management:
- Settings page: prefix editor with validation feedback
- Page renderer: url_editor component for page URLs
- CSS for url editor styling

Test updates for cache isolation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-04-01 00:36:17 +01:00
parent c115f08cb8
commit a41771efc8
28 changed files with 938 additions and 160 deletions

View File

@ -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 {

View File

@ -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"

View File

@ -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())

View File

@ -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
<h3 class="cart-item-name" data-size={if @size == :compact, do: "compact"}>
<%= if @mode != :preview do %>
<.link
patch={"/products/#{@item.product_id}"}
patch={R.product(@item.product_id)}
class="cart-item-name-link"
>
{@item.name}
@ -321,7 +322,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
</button>
<% else %>
<.link
patch="/collections/all"
patch={R.collection("all")}
class="cart-continue-link"
>
Continue shopping
@ -533,7 +534,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
</.shop_button>
<p class="order-summary-notice">Checkout isn't available yet.</p>
<.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
</.shop_button>
<p class="order-summary-notice">Remove unavailable items to checkout.</p>
<.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_button>
</form>
<.shop_link_outline
href="/collections/all"
href={R.collection("all")}
class="order-summary-continue"
>
Continue shopping

View File

@ -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
</a>
<% 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 %>
<li>
<.link
patch="/collections/all"
patch={R.collection("all")}
class="footer-link"
>
All products
@ -877,7 +887,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<%= for category <- @categories do %>
<li>
<.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 />
</.link>
<a
href="/search"
href={R.search()}
phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")}
class="header-icon-btn"
aria-label="Search"
@ -1031,7 +1041,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
</svg>
</a>
<a
href="/cart"
href={R.cart()}
phx-click={open_cart_drawer_js()}
class="header-icon-btn"
aria-label="Cart"
@ -1241,6 +1251,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :editor_dirty, :boolean, default: false
attr :theme_dirty, :boolean, default: false
attr :site_dirty, :boolean, default: false
attr :settings_dirty, :boolean, default: false
attr :editor_sheet_state, :atom, default: :collapsed
attr :editor_save_status, :atom, default: :idle
attr :editor_active_tab, :atom, default: :page
@ -1263,7 +1274,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
any_editing = assigns.editing || assigns.theme_editing
# Any tab has unsaved changes
any_dirty = assigns.editor_dirty || assigns.theme_dirty || assigns.site_dirty
any_dirty =
assigns.editor_dirty || assigns.theme_dirty || assigns.site_dirty || assigns.settings_dirty
assigns =
assigns

View File

@ -5,6 +5,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
import BerrypodWeb.ShopComponents.Content, only: [responsive_image: 1]
alias Berrypod.Products.{Product, ProductImage}
alias BerrypodWeb.R
@doc """
Renders a product card with configurable variants.
@ -98,7 +99,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
product_url =
if assigns.clickable && assigns.mode != :preview do
"/products/#{Map.get(assigns.product, :slug) || Map.get(assigns.product, :id)}"
R.product(Map.get(assigns.product, :slug) || Map.get(assigns.product, :id))
end
assigns =
@ -157,7 +158,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
</p>
<% else %>
<.link
patch={"/collections/#{Slug.slugify(@product.category)}"}
patch={R.collection(Slug.slugify(@product.category))}
class="product-card-category"
>
{@product.category}
@ -177,7 +178,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
</a>
<% 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
</a>
<% else %>
<.link
patch={"/collections/#{category.slug}"}
patch={R.collection(category.slug)}
class="category-card"
>
<div

View File

@ -41,13 +41,13 @@ defmodule BerrypodWeb.CartController do
conn
|> 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 =

View File

@ -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" />
</.external_link>
<.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" />

View File

@ -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
</form>
</div>
</section>
<%!-- URL prefixes --%>
<section class="admin-section">
<h2 class="admin-section-title">URL prefixes</h2>
<p class="admin-section-desc">
Customise the URL structure for products and collections.
Old URLs will automatically redirect to the new ones.
</p>
<div class="admin-section-body">
<form
phx-change="change_prefix"
phx-submit="save_prefixes"
class="admin-stack"
>
<div class="admin-row admin-row-lg">
<label class="admin-label admin-label-inline" for="prefix-products">
Products
</label>
<div class="admin-row admin-row-sm">
<span class="admin-text-secondary">/</span>
<.input
name="prefixes[products]"
id="prefix-products"
value={@products_prefix}
placeholder="products"
pattern="[a-z0-9-]+"
/>
<span class="admin-text-secondary">/</span>
<span class="admin-text-tertiary">product-slug</span>
</div>
</div>
<div class="admin-row admin-row-lg">
<label class="admin-label admin-label-inline" for="prefix-collections">
Collections
</label>
<div class="admin-row admin-row-sm">
<span class="admin-text-secondary">/</span>
<.input
name="prefixes[collections]"
id="prefix-collections"
value={@collections_prefix}
placeholder="collections"
pattern="[a-z0-9-]+"
/>
<span class="admin-text-secondary">/</span>
<span class="admin-text-tertiary">collection-slug</span>
</div>
</div>
<p class="admin-help-text">
Use only lowercase letters, numbers, and hyphens.
</p>
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Save</.button>
<.inline_feedback status={@prefix_status} />
</div>
</form>
</div>
</section>
</div>
"""
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

View File

@ -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

View File

@ -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

View File

@ -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.")}

View File

@ -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()
}

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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,8 +955,27 @@ 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
cond do
# Custom pages: save all settings including url_slug
page && page.type == "custom" ->
save_custom_page_settings(socket, page, params)
# 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"])
# 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"])
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
@ -967,12 +987,26 @@ defmodule BerrypodWeb.PageEditorHook do
# 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} ->
# Reinitialize form from saved 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(:page, updated_page)
|> assign(:settings_form, nil)
|> assign(:settings_dirty, false)
|> assign(:settings_save_status, :saved)
@ -994,13 +1028,61 @@ defmodule BerrypodWeb.PageEditorHook do
socket = assign(socket, :settings_save_status, :error)
{:halt, socket}
end
else
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
# Catch-all for unknown settings actions
defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
# 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(:settings_form, nil)
|> assign(:settings_dirty, false)
|> assign(:settings_save_status, :saved)
{:halt, socket}
{:error, _reason} ->
socket = assign(socket, :settings_save_status, :error)
{:halt, socket}
end
else
{:halt, socket}
end
end
# --- Site tab event handlers ---
@ -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

View File

@ -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
/>
</div>
<div class="page-settings-field">
<label class="page-settings-label" for="page-settings-slug">URL slug</label>
<div class="page-settings-slug-input">
<span class="page-settings-slug-prefix">/</span>
<input
type="text"
id="page-settings-slug"
name="page[slug]"
value={@form_slug}
class={["admin-input", !@is_custom && "admin-input-disabled"]}
pattern="[a-z0-9-]+"
disabled={!@is_custom}
title={if !@is_custom, do: "System page URLs cannot be changed", else: nil}
/>
</div>
</div>
<%!-- URL editor - different layouts based on page type --%>
<.url_editor url_info={@url_info} is_custom={@is_custom} form_slug={@form_slug} />
<div class="page-settings-field">
<label class="page-settings-label" for="page-settings-meta">Meta description</label>
@ -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"""
<div class="page-settings-field">
<label class="page-settings-label" for="page-settings-url">URL</label>
<div class="url-editor">
<span class="url-editor-slash">/</span>
<input
type="text"
id="page-settings-url"
name={@url_info.field_name}
value={@url_info.value}
class="admin-input url-editor-input"
pattern="[a-z0-9-]+"
phx-debounce="500"
/>
</div>
<p :if={@url_info.hint} class="page-settings-hint">{@url_info.hint}</p>
</div>
"""
end
defp url_editor(%{url_info: %{type: :prefixed}} = assigns) do
# Prefixed URL (collections, products, orders)
~H"""
<div class="page-settings-field">
<label class="page-settings-label">URL</label>
<div class="url-editor url-editor-segmented">
<span class="url-editor-slash">/</span>
<input
type="text"
id="page-settings-prefix"
name="page[url_prefix]"
value={@url_info.prefix}
class="admin-input url-editor-input url-editor-prefix"
pattern="[a-z0-9-]+"
phx-debounce="500"
/>
<span class="url-editor-slash">/</span>
<span class="url-editor-fixed">{@url_info.suffix}</span>
</div>
<p :if={@url_info.hint} class="page-settings-hint">{@url_info.hint}</p>
</div>
"""
end
defp url_editor(%{url_info: %{type: :fixed}} = assigns) do
# Fixed URL (checkout/success, etc.)
~H"""
<div class="page-settings-field">
<label class="page-settings-label">URL</label>
<div class="url-editor">
<span class="url-editor-fixed-path">{@url_info.path}</span>
</div>
</div>
"""
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
</nav>
<form
action={~p"/collections/#{@current_slug || "all"}"}
action={R.collection(@current_slug || "all")}
method="get"
phx-change="sort_changed"
>
@ -890,14 +1042,14 @@ defmodule BerrypodWeb.PageRenderer do
<.shop_pagination
:if={assigns[:pagination] && assigns[:pagination].total_pages > 1}
page={assigns[:pagination]}
base_path={~p"/collections/#{@collection_slug}"}
base_path={R.collection(@collection_slug)}
params={@sort_params}
/>
<%= if (assigns[:products] || []) == [] do %>
<div class="collection-empty">
<p>No products found in this collection.</p>
<.link patch={~p"/collections/all"} class="collection-empty-link">
<.link patch={R.collection("all")} class="collection-empty-link">
View all products
</.link>
</div>
@ -1075,7 +1227,7 @@ defmodule BerrypodWeb.PageRenderer do
<% end %>
<div class="checkout-actions">
<.shop_link_button href="/collections/all" class="checkout-cta">
<.shop_link_button href={R.collection("all")} class="checkout-cta">
Continue shopping
</.shop_link_button>
</div>
@ -1104,7 +1256,7 @@ defmodule BerrypodWeb.PageRenderer do
Please wait while we confirm your payment. This usually takes a few seconds.
</p>
<p class="checkout-pending-hint">
If this page doesn't update, please <.link patch="/contact" class="checkout-contact-link">contact us</.link>.
If this page doesn't update, please <.link patch={R.contact()} class="checkout-contact-link">contact us</.link>.
</p>
</div>
<% end %>
@ -1129,20 +1281,20 @@ defmodule BerrypodWeb.PageRenderer do
<div class="orders-empty">
<p>This link has expired or is invalid.</p>
<p class="orders-empty-hint">
Head back to the <.link patch="/contact">contact page</.link> to request a new one.
Head back to the <.link patch={R.contact()}>contact page</.link> to request a new one.
</p>
</div>
<% assigns[:orders] == [] -> %>
<div class="orders-empty">
<p>No orders found for that email address.</p>
<p class="orders-empty-hint">
If something doesn't look right, <.link patch="/contact">get in touch</.link>.
If something doesn't look right, <.link patch={R.contact()}>get in touch</.link>.
</p>
</div>
<% true -> %>
<div class="orders-list">
<%= 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">
<div class="order-summary-top">
<div>
<p class="order-summary-number">{order.order_number}</p>
@ -1184,7 +1336,7 @@ defmodule BerrypodWeb.PageRenderer do
~H"""
<%= if assigns[:order] do %>
<div class="order-detail-header">
<.link patch="/orders" class="order-detail-back">&larr; Back to orders</.link>
<.link patch={R.orders()} class="order-detail-back">&larr; Back to orders</.link>
<h1 class="checkout-heading" style="margin-top: 1.5rem;">{assigns[:order].order_number}</h1>
<p class="checkout-meta">{Calendar.strftime(assigns[:order].inserted_at, "%-d %B %Y")}</p>
<span class={"order-status-badge order-status-badge-#{assigns[:order].fulfilment_status} order-status-badge-lg"}>
@ -1239,7 +1391,7 @@ defmodule BerrypodWeb.PageRenderer do
<div>
<%= if info && info.slug do %>
<.link
patch={"/products/#{info.slug}"}
patch={R.product(info.slug)}
class="checkout-item-name checkout-item-link"
>
{item.product_name}
@ -1286,7 +1438,7 @@ defmodule BerrypodWeb.PageRenderer do
<% end %>
<div class="checkout-actions">
<.shop_link_button href="/collections/all">Continue shopping</.shop_link_button>
<.shop_link_button href={R.collection("all")}>Continue shopping</.shop_link_button>
</div>
<% end %>
"""
@ -1298,7 +1450,7 @@ defmodule BerrypodWeb.PageRenderer do
~H"""
<.page_title text="Search" />
<form action="/search" method="get" phx-submit="search_submit" class="search-page-form">
<form action={R.search()} method="get" phx-submit="search_submit" class="search-page-form">
<input
type="search"
name="q"
@ -1329,7 +1481,7 @@ defmodule BerrypodWeb.PageRenderer do
<%= if (assigns[:search_page_query] || "") != "" do %>
<div class="collection-empty">
<p>No products found for &ldquo;{assigns[:search_page_query]}&rdquo;</p>
<.link patch="/collections/all" class="collection-empty-link">Browse all products</.link>
<.link patch={R.collection("all")} class="collection-empty-link">Browse all products</.link>
</div>
<% end %>
<% end %>
@ -1469,7 +1621,7 @@ defmodule BerrypodWeb.PageRenderer do
slug = cat |> String.downcase() |> String.replace(" ", "-")
[
%{label: cat, page: "collection", href: "/collections/#{slug}"},
%{label: cat, page: "collection", href: R.collection(slug)},
%{label: title, current: true}
]
end
@ -1480,8 +1632,8 @@ defmodule BerrypodWeb.PageRenderer do
defp breadcrumb_items(_), do: []
defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}"
defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}"
defp collection_path(slug, "featured"), do: R.collection(slug)
defp collection_path(slug, sort), do: R.collection(slug) <> "?sort=#{sort}"
# Resolves an image_id to a full URL for blocks that need a single URL (e.g. background-image).
defp resolve_block_image_url(image_id) do

View File

@ -18,7 +18,7 @@ defmodule BerrypodWeb.Plugs.BrokenUrlTracker do
def call(conn, {router, router_opts}) do
router.call(conn, router_opts)
rescue
e in Phoenix.Router.NoRouteError ->
e in [Phoenix.Router.NoRouteError, BerrypodWeb.NotFoundError] ->
unless static_path?(conn.request_path) do
prior_hits = Berrypod.Analytics.count_pageviews_for_path(conn.request_path)
Berrypod.Redirects.record_broken_url(conn.request_path, prior_hits)

View File

@ -605,6 +605,65 @@ defmodule Berrypod.PagesTest do
end
end
describe "update_page_url_slug/2" do
test "updates url_slug for system page" do
{:ok, updated} = Pages.update_page_url_slug("about", "our-story")
assert updated.url_slug == "our-story"
assert updated.slug == "about"
end
test "creates redirect when url_slug changes" do
{:ok, _} = Pages.update_page_url_slug("about", "our-story")
assert {:ok, redirect} = Berrypod.Redirects.lookup("/about")
assert redirect.to_path == "/our-story"
end
test "clears url_slug when set to empty string" do
{:ok, _} = Pages.update_page_url_slug("about", "our-story")
{:ok, cleared} = Pages.update_page_url_slug("about", "")
assert cleared.url_slug == nil
end
test "creates system page in DB if it doesn't exist yet" do
# About page hasn't been saved to DB, only exists as defaults
assert Pages.get_page_struct("delivery") == nil
{:ok, updated} = Pages.update_page_url_slug("delivery", "shipping")
assert updated.url_slug == "shipping"
assert Pages.get_page_struct("delivery") != nil
end
test "returns error for non-existent custom page" do
assert {:error, :not_found} = Pages.update_page_url_slug("nope", "anything")
end
test "deletes stale redirect when new URL becomes live" do
# Create a redirect pointing FROM /my-url
Berrypod.Redirects.create_auto(%{
from_path: "/my-url",
to_path: "/somewhere-else",
source: "manual"
})
# Now make /my-url a live page
{:ok, _} = Pages.update_page_url_slug("about", "my-url")
# The stale redirect should be gone
assert :not_found = Berrypod.Redirects.lookup("/my-url")
end
test "invalidates R cache so new URL resolves immediately" do
{:ok, _} = Pages.update_page_url_slug("cart", "basket")
# R.cart() should now return /basket
assert BerrypodWeb.R.cart() == "/basket"
end
end
describe "duplicate_custom_page/1" do
test "creates a draft copy with -copy slug" do
{:ok, original} =

View File

@ -1,5 +1,5 @@
defmodule Berrypod.SiteTest do
use Berrypod.DataCase, async: true
use Berrypod.DataCase, async: false
alias Berrypod.Site
alias Berrypod.Site.SocialLink
@ -274,4 +274,49 @@ defmodule Berrypod.SiteTest do
assert Site.show_newsletter?() == true
end
end
describe "nav_items_for_shop/1" do
test "resolves system page URLs through R module" do
# Set a custom URL for the about page
{:ok, _} = Berrypod.Pages.update_page_url_slug("about", "our-story")
# Create a nav item pointing to /about (the default URL)
{:ok, _} = Site.create_nav_item(%{location: "header", label: "About", url: "/about"})
# The resolved URL should use the custom slug
[item] = Site.nav_items_for_shop(:header)
assert item["href"] == "/our-story"
end
test "resolves home page URL" do
{:ok, _} = Site.create_nav_item(%{location: "header", label: "Home", url: "/"})
[item] = Site.nav_items_for_shop(:header)
assert item["href"] == "/"
end
test "preserves external URLs" do
{:ok, _} =
Site.create_nav_item(%{
location: "header",
label: "External",
url: "https://example.com"
})
[item] = Site.nav_items_for_shop(:header)
assert item["href"] == "https://example.com"
end
test "preserves special routes like /collections/all" do
{:ok, _} =
Site.create_nav_item(%{
location: "header",
label: "Shop",
url: "/collections/all"
})
[item] = Site.nav_items_for_shop(:header)
assert item["href"] == "/collections/all"
end
end
end

View File

@ -1,18 +1,25 @@
defmodule BerrypodWeb.Plugs.BrokenUrlTrackerTest do
use BerrypodWeb.ConnCase, async: true
alias Berrypod.Redirects
import Berrypod.AccountsFixtures
alias Berrypod.{Redirects, Settings}
setup do
Redirects.create_table()
# Create admin user so SetupHook allows access
user_fixture()
# Mark site as live so requests aren't redirected to /coming-soon
{:ok, _} = Settings.set_site_live(true)
:ok
end
test "records broken URL on 404", %{conn: conn} do
# Multi-segment path — not caught by the /:slug catch-all route
conn = get(conn, "/zz/nonexistent-path")
assert conn.status in [404, 500]
# Multi-segment path goes through the catch-all route and raises NotFoundError.
# The BrokenUrlTracker plug catches this and records it before re-raising.
assert_error_sent :not_found, fn ->
get(conn, "/zz/nonexistent-path")
end
[broken_url] = Redirects.list_broken_urls()
assert broken_url.path == "/zz/nonexistent-path"
@ -20,7 +27,11 @@ defmodule BerrypodWeb.Plugs.BrokenUrlTrackerTest do
end
test "skips static asset paths", %{conn: conn} do
# Static asset paths should not be recorded as broken URLs.
# These raise NotFoundError but the tracker ignores them.
assert_error_sent :not_found, fn ->
get(conn, "/assets/missing-file.js")
end
assert Redirects.list_broken_urls() == []
end

View File

@ -32,9 +32,23 @@ defmodule BerrypodWeb.ConnCase do
end
setup tags do
Berrypod.DataCase.setup_sandbox(tags)
pid = Berrypod.DataCase.setup_sandbox(tags)
Berrypod.Settings.SettingsCache.invalidate_all()
{:ok, conn: Phoenix.ConnTest.build_conn()}
# Clear caches without re-warming from DB (which would bypass sandbox)
BerrypodWeb.R.clear()
Berrypod.Pages.PageCache.invalidate_all()
Berrypod.Redirects.clear_cache()
# Add sandbox metadata to conn so Phoenix.Ecto.SQL.Sandbox plug
# can allow LiveView processes to access the test's DB connection
metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Berrypod.Repo, pid)
encoded_metadata = Phoenix.Ecto.SQL.Sandbox.encode_metadata(metadata)
conn =
Phoenix.ConnTest.build_conn()
|> Plug.Conn.put_req_header("user-agent", encoded_metadata)
{:ok, conn: conn}
end
@doc """

View File

@ -30,15 +30,21 @@ defmodule Berrypod.DataCase do
setup tags do
Berrypod.DataCase.setup_sandbox(tags)
Berrypod.Settings.SettingsCache.invalidate_all()
# Clear caches without re-warming from DB (which would bypass sandbox)
BerrypodWeb.R.clear()
Berrypod.Pages.PageCache.invalidate_all()
Berrypod.Redirects.clear_cache()
:ok
end
@doc """
Sets up the sandbox based on the test tags.
Returns the owner pid for use in metadata generation.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Berrypod.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
pid
end
@doc """