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; 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 in editor */
.block-list { .block-list {

View File

@ -22,6 +22,7 @@ defmodule Berrypod.Site do
NavItem NavItem
|> where([n], n.location == ^location) |> where([n], n.location == ^location)
|> order_by([n], asc: n.position) |> order_by([n], asc: n.position)
|> preload(:page)
|> Repo.all() |> Repo.all()
end end
@ -89,14 +90,42 @@ defmodule Berrypod.Site do
end end
defp nav_item_to_map(%NavItem{} = item) do 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, "label" => item.label,
"href" => item.url, "href" => href,
"slug" => slug_from_url(item.url), "slug" => slug_from_url(href),
"page_id" => item.page_id "page_id" => item.page_id
} }
end 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("/"), do: "home"
defp slug_from_url("/collections" <> _), do: "collection" defp slug_from_url("/collections" <> _), do: "collection"
defp slug_from_url("/products" <> _), do: "pdp" defp slug_from_url("/products" <> _), do: "pdp"

View File

@ -44,6 +44,8 @@ defmodule BerrypodWeb do
use Gettext, backend: BerrypodWeb.Gettext use Gettext, backend: BerrypodWeb.Gettext
import Plug.Conn import Plug.Conn
# Runtime URL paths (custom slugs/prefixes)
alias BerrypodWeb.R
unquote(verified_routes()) unquote(verified_routes())
end end
@ -53,6 +55,11 @@ defmodule BerrypodWeb do
quote do quote do
use Phoenix.LiveView 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()) unquote(html_helpers())
end end
end end
@ -93,6 +100,8 @@ defmodule BerrypodWeb do
# Common modules used in templates # Common modules used in templates
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
alias BerrypodWeb.Layouts alias BerrypodWeb.Layouts
# Runtime URL paths (custom slugs/prefixes)
alias BerrypodWeb.R
# Routes generation with the ~p sigil # Routes generation with the ~p sigil
unquote(verified_routes()) unquote(verified_routes())

View File

@ -6,6 +6,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
import BerrypodWeb.ShopComponents.Base import BerrypodWeb.ShopComponents.Base
alias Berrypod.Products.{Product, ProductImage} alias Berrypod.Products.{Product, ProductImage}
alias BerrypodWeb.R
defp close_cart_drawer_js do defp close_cart_drawer_js do
Phoenix.LiveView.JS.push("close_cart_drawer") Phoenix.LiveView.JS.push("close_cart_drawer")
@ -193,7 +194,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
> >
<%= if @mode != :preview do %> <%= if @mode != :preview do %>
<.link <.link
patch={"/products/#{@item.product_id}"} patch={R.product(@item.product_id)}
class={["cart-item-image", !@item.image && "cart-item-image--empty"]} class={["cart-item-image", !@item.image && "cart-item-image--empty"]}
data-size={if @size == :compact, do: "compact"} data-size={if @size == :compact, do: "compact"}
style={if @item.image, do: "background-image: url('#{@item.image}');"} 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"}> <h3 class="cart-item-name" data-size={if @size == :compact, do: "compact"}>
<%= if @mode != :preview do %> <%= if @mode != :preview do %>
<.link <.link
patch={"/products/#{@item.product_id}"} patch={R.product(@item.product_id)}
class="cart-item-name-link" class="cart-item-name-link"
> >
{@item.name} {@item.name}
@ -321,7 +322,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
</button> </button>
<% else %> <% else %>
<.link <.link
patch="/collections/all" patch={R.collection("all")}
class="cart-continue-link" class="cart-continue-link"
> >
Continue shopping Continue shopping
@ -533,7 +534,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
</.shop_button> </.shop_button>
<p class="order-summary-notice">Checkout isn't available yet.</p> <p class="order-summary-notice">Checkout isn't available yet.</p>
<.shop_link_outline <.shop_link_outline
href="/collections/all" href={R.collection("all")}
class="order-summary-continue" class="order-summary-continue"
> >
Continue shopping Continue shopping
@ -544,7 +545,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
</.shop_button> </.shop_button>
<p class="order-summary-notice">Remove unavailable items to checkout.</p> <p class="order-summary-notice">Remove unavailable items to checkout.</p>
<.shop_link_outline <.shop_link_outline
href="/collections/all" href={R.collection("all")}
class="order-summary-continue" class="order-summary-continue"
> >
Continue shopping Continue shopping
@ -557,7 +558,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do
</.shop_button> </.shop_button>
</form> </form>
<.shop_link_outline <.shop_link_outline
href="/collections/all" href={R.collection("all")}
class="order-summary-continue" class="order-summary-continue"
> >
Continue shopping Continue shopping

View File

@ -4,6 +4,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
import BerrypodWeb.ShopComponents.Cart import BerrypodWeb.ShopComponents.Cart
import BerrypodWeb.ShopComponents.Content import BerrypodWeb.ShopComponents.Content
alias BerrypodWeb.R
@doc """ @doc """
Renders the announcement bar. Renders the announcement bar.
@ -153,10 +155,18 @@ defmodule BerrypodWeb.ShopComponents.Layout do
# Extract a slug from a URL for nav item matching # Extract a slug from a URL for nav item matching
defp extract_slug_from_url(url) when is_binary(url) do defp extract_slug_from_url(url) when is_binary(url) do
cond do cond do
url == "/" -> "home" url == "/" ->
String.starts_with?(url, "/collections") -> "collection" "home"
String.starts_with?(url, "/products") -> "pdp"
true -> url |> String.trim_leading("/") |> String.split("/") |> List.first() || "" 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
end end
@ -651,7 +661,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
aria-selected="false" aria-selected="false"
> >
<.link <.link
patch={"/products/#{item.product.slug || item.product.id}"} patch={R.product(item.product.slug || item.product.id)}
class="search-result" class="search-result"
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")} phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
> >
@ -782,7 +792,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
</a> </a>
<% else %> <% else %>
<.link <.link
patch={"/collections/#{category.slug}"} patch={R.collection(category.slug)}
class="mobile-nav-link" class="mobile-nav-link"
phx-click={ phx-click={
Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer") Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")
@ -868,7 +878,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<% else %> <% else %>
<li> <li>
<.link <.link
patch="/collections/all" patch={R.collection("all")}
class="footer-link" class="footer-link"
> >
All products All products
@ -877,7 +887,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<%= for category <- @categories do %> <%= for category <- @categories do %>
<li> <li>
<.link <.link
patch={"/collections/#{category.slug}"} patch={R.collection(category.slug)}
class="footer-link" class="footer-link"
> >
{category.name} {category.name}
@ -1013,7 +1023,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<.admin_cog_svg /> <.admin_cog_svg />
</.link> </.link>
<a <a
href="/search" href={R.search()}
phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")} phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")}
class="header-icon-btn" class="header-icon-btn"
aria-label="Search" aria-label="Search"
@ -1031,7 +1041,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
</svg> </svg>
</a> </a>
<a <a
href="/cart" href={R.cart()}
phx-click={open_cart_drawer_js()} phx-click={open_cart_drawer_js()}
class="header-icon-btn" class="header-icon-btn"
aria-label="Cart" aria-label="Cart"
@ -1241,6 +1251,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :editor_dirty, :boolean, default: false attr :editor_dirty, :boolean, default: false
attr :theme_dirty, :boolean, default: false attr :theme_dirty, :boolean, default: false
attr :site_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_sheet_state, :atom, default: :collapsed
attr :editor_save_status, :atom, default: :idle attr :editor_save_status, :atom, default: :idle
attr :editor_active_tab, :atom, default: :page attr :editor_active_tab, :atom, default: :page
@ -1263,7 +1274,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
any_editing = assigns.editing || assigns.theme_editing any_editing = assigns.editing || assigns.theme_editing
# Any tab has unsaved changes # 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 =
assigns assigns

View File

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

View File

@ -41,13 +41,13 @@ defmodule BerrypodWeb.CartController do
conn conn
|> Cart.put_in_session(cart) |> Cart.put_in_session(cart)
|> put_flash(:info, "Added to basket") |> put_flash(:info, "Added to basket")
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
end end
def add(conn, _params) do def add(conn, _params) do
conn conn
|> put_flash(:error, "Could not add item to basket") |> put_flash(:error, "Could not add item to basket")
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
end end
@doc """ @doc """
@ -60,7 +60,7 @@ defmodule BerrypodWeb.CartController do
conn conn
|> Cart.put_in_session(cart) |> Cart.put_in_session(cart)
|> put_flash(:info, "Removed from basket") |> put_flash(:info, "Removed from basket")
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
end end
@doc """ @doc """
@ -73,7 +73,7 @@ defmodule BerrypodWeb.CartController do
conn conn
|> Cart.put_in_session(cart) |> Cart.put_in_session(cart)
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
end end
@doc """ @doc """
@ -82,11 +82,11 @@ defmodule BerrypodWeb.CartController do
def update_country(conn, %{"country" => code}) when is_binary(code) and code != "" do def update_country(conn, %{"country" => code}) when is_binary(code) and code != "" do
conn conn
|> put_session("country_code", code) |> put_session("country_code", code)
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
end end
def update_country(conn, _params) do def update_country(conn, _params) do
redirect(conn, to: ~p"/cart") redirect(conn, to: R.cart())
end end
defp parse_quantity(str) when is_binary(str) do 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 unless Settings.has_secret?("stripe_api_key") do
conn conn
|> put_flash(:error, "Checkout isn't available yet") |> put_flash(:error, "Checkout isn't available yet")
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
else else
cart_items = Cart.get_from_session(get_session(conn)) cart_items = Cart.get_from_session(get_session(conn))
hydrated = Cart.hydrate(cart_items) hydrated = Cart.hydrate(cart_items)
@ -20,12 +20,12 @@ defmodule BerrypodWeb.CheckoutController do
hydrated == [] -> hydrated == [] ->
conn conn
|> put_flash(:error, "Your basket is empty") |> put_flash(:error, "Your basket is empty")
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
Enum.any?(hydrated, &(&1.is_available == false)) -> Enum.any?(hydrated, &(&1.is_available == false)) ->
conn conn
|> put_flash(:error, "Some items in your basket are no longer available") |> put_flash(:error, "Some items in your basket are no longer available")
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
true -> true ->
track_checkout_start(conn) track_checkout_start(conn)
@ -45,7 +45,7 @@ defmodule BerrypodWeb.CheckoutController do
conn conn
|> put_flash(:error, "Something went wrong. Please try again.") |> put_flash(:error, "Something went wrong. Please try again.")
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
end end
end end
@ -67,14 +67,12 @@ defmodule BerrypodWeb.CheckoutController do
} }
end) end)
base_url = BerrypodWeb.Endpoint.url()
params = params =
%{ %{
mode: "payment", mode: "payment",
line_items: line_items, line_items: line_items,
success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}", success_url: R.url(R.checkout_success()) <> "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "#{base_url}/cart", cancel_url: R.url(R.cart()),
metadata: %{"order_id" => order.id}, metadata: %{"order_id" => order.id},
shipping_address_collection: %{ shipping_address_collection: %{
allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"] allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"]
@ -96,7 +94,7 @@ defmodule BerrypodWeb.CheckoutController do
conn conn
|> put_flash(:error, "Payment setup failed. Please try again.") |> put_flash(:error, "Payment setup failed. Please try again.")
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
{:error, reason} -> {:error, reason} ->
Logger.error("Stripe session creation failed: #{inspect(reason)}") Logger.error("Stripe session creation failed: #{inspect(reason)}")
@ -104,7 +102,7 @@ defmodule BerrypodWeb.CheckoutController do
conn conn
|> put_flash(:error, "Payment setup failed. Please try again.") |> put_flash(:error, "Payment setup failed. Please try again.")
|> redirect(to: ~p"/cart") |> redirect(to: R.cart())
end end
end end

View File

@ -13,17 +13,17 @@ defmodule BerrypodWeb.ContactController do
{:ok, _} -> {:ok, _} ->
conn conn
|> put_flash(:info, "Message sent! We'll get back to you soon.") |> put_flash(:info, "Message sent! We'll get back to you soon.")
|> redirect(to: ~p"/contact") |> redirect(to: R.contact())
{:error, :invalid_params} -> {:error, :invalid_params} ->
conn conn
|> put_flash(:error, "Please fill in all required fields.") |> put_flash(:error, "Please fill in all required fields.")
|> redirect(to: ~p"/contact") |> redirect(to: R.contact())
{:error, _} -> {:error, _} ->
conn conn
|> put_flash(:error, "Sorry, something went wrong. Please try again.") |> put_flash(:error, "Sorry, something went wrong. Please try again.")
|> redirect(to: ~p"/contact") |> redirect(to: R.contact())
end end
end end
end end

View File

@ -19,7 +19,7 @@ defmodule BerrypodWeb.OrderLookupController do
:error, :error,
"No orders found for that address. Make sure you use the same email you checked out with." "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 else
token = generate_token(email) token = generate_token(email)
link = BerrypodWeb.Endpoint.url() <> ~p"/orders/verify/#{token}" link = BerrypodWeb.Endpoint.url() <> ~p"/orders/verify/#{token}"
@ -30,14 +30,14 @@ defmodule BerrypodWeb.OrderLookupController do
:info, :info,
"We've sent a link to your email address. It'll expire after an hour." "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
end end
def lookup(conn, _params) do def lookup(conn, _params) do
conn conn
|> put_flash(:error, "Please enter your email address.") |> put_flash(:error, "Please enter your email address.")
|> redirect(to: ~p"/contact") |> redirect(to: R.contact())
end end
def verify(conn, %{"token" => token}) do def verify(conn, %{"token" => token}) do
@ -45,17 +45,17 @@ defmodule BerrypodWeb.OrderLookupController do
{:ok, email} -> {:ok, email} ->
conn conn
|> put_session(:order_lookup_email, email) |> put_session(:order_lookup_email, email)
|> redirect(to: ~p"/orders") |> redirect(to: R.orders())
{:error, :expired} -> {:error, :expired} ->
conn conn
|> put_flash(:error, "That link has expired. Please request a new one.") |> put_flash(:error, "That link has expired. Please request a new one.")
|> redirect(to: ~p"/contact") |> redirect(to: R.contact())
{:error, _} -> {:error, _} ->
conn conn
|> put_flash(:error, "That link is invalid.") |> put_flash(:error, "That link is invalid.")
|> redirect(to: ~p"/contact") |> redirect(to: R.contact())
end end
end end

View File

@ -2,6 +2,7 @@ defmodule BerrypodWeb.SeoController do
use BerrypodWeb, :controller use BerrypodWeb, :controller
alias Berrypod.{Pages, Products} alias Berrypod.{Pages, Products}
alias BerrypodWeb.R
def robots(conn, _params) do def robots(conn, _params) do
base = BerrypodWeb.Endpoint.url() base = BerrypodWeb.Endpoint.url()
@ -29,23 +30,23 @@ defmodule BerrypodWeb.SeoController do
categories = Products.list_categories() categories = Products.list_categories()
static_pages = [ static_pages = [
{"/", "daily", "1.0"}, {R.home(), "daily", "1.0"},
{"/collections/all", "daily", "0.9"}, {R.collection("all"), "daily", "0.9"},
{"/about", "monthly", "0.5"}, {R.about(), "monthly", "0.5"},
{"/contact", "monthly", "0.5"}, {R.contact(), "monthly", "0.5"},
{"/delivery", "monthly", "0.5"}, {R.delivery(), "monthly", "0.5"},
{"/privacy", "monthly", "0.3"}, {R.privacy(), "monthly", "0.3"},
{"/terms", "monthly", "0.3"} {R.terms(), "monthly", "0.3"}
] ]
category_pages = category_pages =
Enum.map(categories, fn cat -> Enum.map(categories, fn cat ->
{"/collections/#{cat.slug}", "daily", "0.8"} {R.collection(cat.slug), "daily", "0.8"}
end) end)
product_pages = product_pages =
Enum.map(products, fn product -> Enum.map(products, fn product ->
{"/products/#{product.slug}", "weekly", "0.9"} {R.product(product.slug), "weekly", "0.9"}
end) end)
custom_pages = custom_pages =

View File

@ -4,6 +4,7 @@ defmodule BerrypodWeb.Admin.ProductShow do
alias Berrypod.Products alias Berrypod.Products
alias Berrypod.Products.{Product, ProductImage, ProductVariant} alias Berrypod.Products.{Product, ProductImage, ProductVariant}
alias Berrypod.Cart alias Berrypod.Cart
alias BerrypodWeb.R
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do 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" /> <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" />
</.external_link> </.external_link>
<.link <.link
navigate={~p"/products/#{@product.slug}"} navigate={R.product(@product.slug)}
class="admin-btn admin-btn-ghost admin-btn-sm" 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 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(:from_address_status, :idle)
|> assign(:signing_secret_status, :idle) |> assign(:signing_secret_status, :idle)
|> assign_stripe_state() |> 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 end
# -- Stripe assigns -- # -- Stripe assigns --
@ -117,6 +125,51 @@ defmodule BerrypodWeb.Admin.Settings do
end end
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 -- # -- Events: Stripe --
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
@ -388,6 +441,67 @@ defmodule BerrypodWeb.Admin.Settings do
</form> </form>
</div> </div>
</section> </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> </div>
""" """
end end
@ -639,4 +753,20 @@ defmodule BerrypodWeb.Admin.Settings do
true -> "#{div(diff, 86400)} days ago" true -> "#{div(diff, 86400)} days ago"
end end
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 end

View File

@ -8,6 +8,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
import Phoenix.LiveView, only: [connected?: 1, redirect: 2] import Phoenix.LiveView, only: [connected?: 1, redirect: 2]
alias Berrypod.{Analytics, Orders, Pages} alias Berrypod.{Analytics, Orders, Pages}
alias BerrypodWeb.R
def init(socket, %{"session_id" => session_id}, _uri) do def init(socket, %{"session_id" => session_id}, _uri) do
order = Orders.get_order_by_stripe_session(session_id) 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 if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do
attrs = attrs =
BerrypodWeb.AnalyticsHook.attrs(socket) 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) Analytics.track_event("purchase", attrs)
end end
@ -54,7 +55,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do
end end
def init(socket, _params, _uri) do def init(socket, _params, _uri) do
{:redirect, redirect(socket, to: "/")} {:redirect, redirect(socket, to: R.home())}
end end
def handle_params(_params, _uri, socket) do 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] import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3]
alias Berrypod.{Pages, Pagination, Products} alias Berrypod.{Pages, Pagination, Products}
alias BerrypodWeb.R
@sort_options [ @sort_options [
{"featured", "Featured"}, {"featured", "Featured"},
@ -29,6 +30,11 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
{:noreply, socket} {:noreply, socket}
end 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 def handle_params(%{"slug" => slug} = params, _uri, socket) do
sort = params["sort"] || "featured" sort = params["sort"] || "featured"
page_num = Pagination.parse_page(params) page_num = Pagination.parse_page(params)
@ -39,7 +45,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
socket socket
|> assign(:page_title, title) |> assign(:page_title, title)
|> assign(:page_description, collection_description(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_title, title)
|> assign(:collection_slug, slug) |> assign(:collection_slug, slug)
|> assign(:current_category, category) |> assign(:current_category, category)
@ -53,7 +59,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
socket = socket =
socket socket
|> put_flash(:error, "Collection not found") |> put_flash(:error, "Collection not found")
|> push_navigate(to: "/collections/all") |> push_navigate(to: R.collection("all"))
{:noreply, socket} {:noreply, socket}
end end
@ -67,7 +73,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do
category -> category.slug category -> category.slug
end end
{:noreply, push_patch(socket, to: "/collections/#{slug}?sort=#{sort}")} {:noreply, push_patch(socket, to: R.collection(slug) <> "?sort=#{sort}")}
end end
def handle_event(_event, _params, _socket), do: :cont 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] import Phoenix.LiveView, only: [push_navigate: 2, put_flash: 3]
alias Berrypod.{ContactNotifier, Orders} alias Berrypod.{ContactNotifier, Orders}
alias BerrypodWeb.R
alias Berrypod.Orders.OrderNotifier alias Berrypod.Orders.OrderNotifier
alias Berrypod.Pages alias Berrypod.Pages
alias BerrypodWeb.OrderLookupController alias BerrypodWeb.OrderLookupController
@ -21,7 +22,7 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
:page_description, :page_description,
"Get in touch with us for any questions or help with your order." "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(:tracking_state, :idle)
|> assign(:page, page) |> assign(:page, page)
@ -54,7 +55,7 @@ defmodule BerrypodWeb.Shop.Pages.Contact do
{:noreply, {:noreply,
socket socket
|> put_flash(:info, "Message sent! We'll get back to you soon.") |> put_flash(:info, "Message sent! We'll get back to you soon.")
|> push_navigate(to: "/contact")} |> push_navigate(to: R.contact())}
{:error, :invalid_params} -> {:error, :invalid_params} ->
{:noreply, put_flash(socket, :error, "Please fill in all required fields.")} {: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.LegalPages
alias Berrypod.Pages alias Berrypod.Pages
alias Berrypod.Theme.PreviewData alias Berrypod.Theme.PreviewData
alias BerrypodWeb.R
def init(socket, _params, _uri) do def init(socket, _params, _uri) do
# Content pages load in handle_params based on live_action # Content pages load in handle_params based on live_action
@ -38,7 +39,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
%{ %{
page_title: "About", page_title: "About",
page_description: "Your story goes here this is sample content for the demo shop", 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() PreviewData.about_content()
} }
@ -49,7 +50,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
%{ %{
page_title: "Delivery & returns", page_title: "Delivery & returns",
page_description: "Everything you need to know about shipping and 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() LegalPages.delivery_content()
} }
@ -60,7 +61,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
%{ %{
page_title: "Privacy policy", page_title: "Privacy policy",
page_description: "How we handle your personal information.", page_description: "How we handle your personal information.",
og_url: BerrypodWeb.Endpoint.url() <> "/privacy" og_url: R.url(R.privacy())
}, },
LegalPages.privacy_content() LegalPages.privacy_content()
} }
@ -71,7 +72,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do
%{ %{
page_title: "Terms of service", page_title: "Terms of service",
page_description: "The terms and conditions governing purchases from our shop.", 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() LegalPages.terms_content()
} }

View File

@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
import Phoenix.LiveView, only: [push_navigate: 2] import Phoenix.LiveView, only: [push_navigate: 2]
alias Berrypod.{Orders, Pages} alias Berrypod.{Orders, Pages}
alias BerrypodWeb.R
alias Berrypod.Products alias Berrypod.Products
alias Berrypod.Products.ProductImage alias Berrypod.Products.ProductImage
@ -54,7 +55,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
{:noreply, socket} {:noreply, socket}
else else
{:noreply, push_navigate(socket, to: "/orders")} {:noreply, push_navigate(socket, to: R.orders())}
end end
end end

View File

@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2] import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2]
alias Berrypod.{Analytics, Cart, Pages} alias Berrypod.{Analytics, Cart, Pages}
alias BerrypodWeb.R
alias Berrypod.Images.Optimizer alias Berrypod.Images.Optimizer
alias Berrypod.Products alias Berrypod.Products
alias Berrypod.Products.{Product, ProductImage} alias Berrypod.Products.{Product, ProductImage}
@ -15,11 +16,11 @@ defmodule BerrypodWeb.Shop.Pages.Product do
# Try to get product by slug, including discontinued products # Try to get product by slug, including discontinued products
case Products.get_product_by_slug(slug, preload: [:variants, :images]) do case Products.get_product_by_slug(slug, preload: [:variants, :images]) do
nil -> nil ->
{:noreply, push_navigate(socket, to: "/collections/all")} {:noreply, push_navigate(socket, to: R.collection("all"))}
%{visible: false, status: status} when status != "discontinued" -> %{visible: false, status: status} when status != "discontinued" ->
# Hidden but not discontinued - redirect away # Hidden but not discontinued - redirect away
{:noreply, push_navigate(socket, to: "/collections/all")} {:noreply, push_navigate(socket, to: R.collection("all"))}
product -> product ->
all_images = all_images =
@ -42,12 +43,12 @@ defmodule BerrypodWeb.Shop.Pages.Product do
if connected?(socket) and socket.assigns[:analytics_visitor_hash] do if connected?(socket) and socket.assigns[:analytics_visitor_hash] do
Analytics.track_event( Analytics.track_event(
"product_view", "product_view",
Map.put(BerrypodWeb.AnalyticsHook.attrs(socket), :pathname, "/products/#{slug}") Map.put(BerrypodWeb.AnalyticsHook.attrs(socket), :pathname, R.product(slug))
) )
end end
base = BerrypodWeb.Endpoint.url() base = BerrypodWeb.Endpoint.url()
og_url = base <> "/products/#{slug}" og_url = R.url(R.product(slug))
og_image = og_image_url(all_images) og_image = og_image_url(all_images)
page = Pages.get_page("pdp") page = Pages.get_page("pdp")
@ -107,7 +108,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
Map.put( Map.put(
BerrypodWeb.AnalyticsHook.attrs(socket), BerrypodWeb.AnalyticsHook.attrs(socket),
:pathname, :pathname,
"/products/#{socket.assigns.product.slug}" R.product(socket.assigns.product.slug)
) )
) )
end end
@ -204,7 +205,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
opt_type.values opt_type.values
|> Enum.map(fn value -> |> Enum.map(fn value ->
params = Map.put(selected_options, opt_type.name, value.title) 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) end)
|> Map.new() |> Map.new()
@ -278,7 +279,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do
# ── JSON-LD and meta helpers ───────────────────────────────────────── # ── 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 = category_slug =
if product.category, if product.category,
do: product.category |> String.downcase() |> String.replace(" ", "-"), do: product.category |> String.downcase() |> String.replace(" ", "-"),
@ -286,13 +287,13 @@ defmodule BerrypodWeb.Shop.Pages.Product do
breadcrumbs = breadcrumbs =
[ [
%{"@type" => "ListItem", "position" => 1, "name" => "Home", "item" => base <> "/"}, %{"@type" => "ListItem", "position" => 1, "name" => "Home", "item" => R.url(R.home())},
product.category && product.category &&
%{ %{
"@type" => "ListItem", "@type" => "ListItem",
"position" => 2, "position" => 2,
"name" => product.category, "name" => product.category,
"item" => base <> "/collections/#{category_slug}" "item" => R.url(R.collection(category_slug))
}, },
%{ %{
"@type" => "ListItem", "@type" => "ListItem",

View File

@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Search do
import Phoenix.LiveView, only: [push_patch: 2] import Phoenix.LiveView, only: [push_patch: 2]
alias Berrypod.{Pages, Search} alias Berrypod.{Pages, Search}
alias BerrypodWeb.R
def init(socket, _params, _uri) do def init(socket, _params, _uri) do
page = Pages.get_page("search") page = Pages.get_page("search")
@ -32,7 +33,7 @@ defmodule BerrypodWeb.Shop.Pages.Search do
end end
def handle_event("search_submit", %{"q" => query}, socket) do 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 end
def handle_event(_event, _params, _socket), do: :cont def handle_event(_event, _params, _socket), do: :cont

View File

@ -270,10 +270,11 @@ defmodule BerrypodWeb.PageEditorHook do
end end
# Initialize settings form from current page if not already set # 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 = socket =
if is_nil(socket.assigns[:settings_form]) && socket.assigns[:page] && if is_nil(socket.assigns[:settings_form]) && socket.assigns[:page] do
socket.assigns.page[:type] == "custom" do
init_settings_form(socket) init_settings_form(socket)
else else
socket socket
@ -930,14 +931,14 @@ defmodule BerrypodWeb.PageEditorHook do
# Catch-all for unknown theme actions # Catch-all for unknown theme actions
defp handle_theme_action(_action, _params, socket), do: {:halt, socket} 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) # Validate page settings form (live as-you-type)
defp handle_settings_action("validate_page", %{"page" => params}, socket) do defp handle_settings_action("validate_page", %{"page" => params}, socket) do
page = socket.assigns.page page = socket.assigns.page
# Only allow editing custom pages # Allow editing for custom pages (all fields) or system pages (url_slug only)
if page && page.type == "custom" do if page && page.type in ["custom", "system"] do
socket = socket =
socket socket
|> assign(:settings_form, params) |> assign(:settings_form, params)
@ -954,8 +955,27 @@ defmodule BerrypodWeb.PageEditorHook do
defp handle_settings_action("save_page", %{"page" => params}, socket) do defp handle_settings_action("save_page", %{"page" => params}, socket) do
page = socket.assigns.page page = socket.assigns.page
# Only allow editing custom pages cond do
if page && page.type == "custom" 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) # Normalize checkbox fields (unchecked checkboxes aren't sent)
params = params =
params params
@ -967,12 +987,26 @@ defmodule BerrypodWeb.PageEditorHook do
# Fetch the Page struct from DB (assigns.page may be a map from cache) # Fetch the Page struct from DB (assigns.page may be a map from cache)
page_struct = Pages.get_page_struct(page.slug) 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 case Pages.update_custom_page(page_struct, params) do
{:ok, updated_page} -> {: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 =
socket socket
|> assign(:page, updated_page)
|> assign(:settings_form, nil) |> assign(:settings_form, nil)
|> assign(:settings_dirty, false) |> assign(:settings_dirty, false)
|> assign(:settings_save_status, :saved) |> assign(:settings_save_status, :saved)
@ -994,13 +1028,61 @@ defmodule BerrypodWeb.PageEditorHook do
socket = assign(socket, :settings_save_status, :error) socket = assign(socket, :settings_save_status, :error)
{:halt, socket} {:halt, socket}
end 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} {:halt, socket}
end end
end end
# Catch-all for unknown settings actions # Save URL prefix for prefixed pages (collection, pdp, orders)
defp handle_settings_action(_action, _params, socket), do: {:halt, socket} 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 --- # --- Site tab event handlers ---
@ -1266,13 +1348,33 @@ defmodule BerrypodWeb.PageEditorHook do
defp has_settings_changed?(page, params) do defp has_settings_changed?(page, params) do
page.title != (params["title"] || "") or page.title != (params["title"] || "") or
page.slug != (params["slug"] || "") or page.slug != (params["slug"] || "") or
(page[:url_slug] || "") != (params["url_slug"] || "") or
(page.meta_description || "") != (params["meta_description"] || "") or (page.meta_description || "") != (params["meta_description"] || "") or
to_string(page.published) != (params["published"] || "false") or to_string(page.published) != (params["published"] || "false") or
to_string(page.show_in_nav) != (params["show_in_nav"] || "false") or to_string(page.show_in_nav) != (params["show_in_nav"] || "false") or
(page.nav_label || "") != (params["nav_label"] || "") 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 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 # Initialize settings form from page values
defp init_settings_form(socket) do defp init_settings_form(socket) do
page = socket.assigns.page page = socket.assigns.page
@ -1280,6 +1382,7 @@ defmodule BerrypodWeb.PageEditorHook do
form = %{ form = %{
"title" => page.title || "", "title" => page.title || "",
"slug" => page.slug || "", "slug" => page.slug || "",
"url_slug" => page[:url_slug] || "",
"meta_description" => page.meta_description || "", "meta_description" => page.meta_description || "",
"published" => to_string(page.published), "published" => to_string(page.published),
"show_in_nav" => to_string(page.show_in_nav), "show_in_nav" => to_string(page.show_in_nav),
@ -1506,6 +1609,15 @@ defmodule BerrypodWeb.PageEditorHook do
defp save_all_tabs(socket) do defp save_all_tabs(socket) do
socket socket
|> maybe_save_page() |> 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_theme()
|> maybe_save_site() |> maybe_save_site()
|> assign(:editor_save_status, :saved) |> assign(:editor_save_status, :saved)
@ -1537,6 +1649,124 @@ defmodule BerrypodWeb.PageEditorHook do
end end
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 defp maybe_save_theme(socket) do
if socket.assigns[:theme_dirty] do if socket.assigns[:theme_dirty] do
case Settings.update_theme_settings(Map.from_struct(socket.assigns.theme_editor_settings)) 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] import BerrypodWeb.CoreComponents, only: [icon: 1, external_link: 1]
alias Berrypod.Cart alias Berrypod.Cart
alias BerrypodWeb.R
# ── Public API ────────────────────────────────────────────────── # ── Public API ──────────────────────────────────────────────────
@ -88,6 +89,7 @@ defmodule BerrypodWeb.PageRenderer do
editor_dirty={@editor_dirty} editor_dirty={@editor_dirty}
theme_dirty={Map.get(assigns, :theme_dirty, false)} theme_dirty={Map.get(assigns, :theme_dirty, false)}
site_dirty={Map.get(assigns, :site_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_sheet_state={assigns[:editor_sheet_state] || :collapsed}
editor_save_status={@editor_save_status} editor_save_status={@editor_save_status}
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)} editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
@ -373,14 +375,18 @@ defmodule BerrypodWeb.PageRenderer do
defp page_settings_section(assigns) do defp page_settings_section(assigns) do
form = assigns.form || %{} form = assigns.form || %{}
is_custom = assigns.page[:type] == "custom" is_custom = assigns.page[:type] == "custom"
page = assigns.page page = assigns.page
# Determine URL structure for this page
url_info = compute_url_info(page, form)
assigns = assigns =
assigns assigns
|> assign(:is_custom, is_custom) |> assign(:is_custom, is_custom)
|> assign(:url_info, url_info)
|> assign(:form_title, form["title"] || page[:title] || "") |> assign(:form_title, form["title"] || page[:title] || "")
|> assign(:form_slug, form["slug"] || page[:slug] || "") |> 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_meta, form["meta_description"] || page[:meta_description] || "")
|> assign(:form_published, form_checked?(form, "published", page[:published])) |> assign(:form_published, form_checked?(form, "published", page[:published]))
|> assign(:form_show_in_nav, form_checked?(form, "show_in_nav", page[:show_in_nav])) |> 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>
<div class="page-settings-field"> <%!-- URL editor - different layouts based on page type --%>
<label class="page-settings-label" for="page-settings-slug">URL slug</label> <.url_editor url_info={@url_info} is_custom={@is_custom} form_slug={@form_slug} />
<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>
<div class="page-settings-field"> <div class="page-settings-field">
<label class="page-settings-label" for="page-settings-meta">Meta description</label> <label class="page-settings-label" for="page-settings-meta">Meta description</label>
@ -497,6 +489,166 @@ defmodule BerrypodWeb.PageRenderer do
""" """
end 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 defp form_checked?(form, key, page_value) when is_map(form) do
case form[key] do case form[key] do
"true" -> true "true" -> true
@ -842,7 +994,7 @@ defmodule BerrypodWeb.PageRenderer do
</nav> </nav>
<form <form
action={~p"/collections/#{@current_slug || "all"}"} action={R.collection(@current_slug || "all")}
method="get" method="get"
phx-change="sort_changed" phx-change="sort_changed"
> >
@ -890,14 +1042,14 @@ defmodule BerrypodWeb.PageRenderer do
<.shop_pagination <.shop_pagination
:if={assigns[:pagination] && assigns[:pagination].total_pages > 1} :if={assigns[:pagination] && assigns[:pagination].total_pages > 1}
page={assigns[:pagination]} page={assigns[:pagination]}
base_path={~p"/collections/#{@collection_slug}"} base_path={R.collection(@collection_slug)}
params={@sort_params} params={@sort_params}
/> />
<%= if (assigns[:products] || []) == [] do %> <%= if (assigns[:products] || []) == [] do %>
<div class="collection-empty"> <div class="collection-empty">
<p>No products found in this collection.</p> <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 View all products
</.link> </.link>
</div> </div>
@ -1075,7 +1227,7 @@ defmodule BerrypodWeb.PageRenderer do
<% end %> <% end %>
<div class="checkout-actions"> <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 Continue shopping
</.shop_link_button> </.shop_link_button>
</div> </div>
@ -1104,7 +1256,7 @@ defmodule BerrypodWeb.PageRenderer do
Please wait while we confirm your payment. This usually takes a few seconds. Please wait while we confirm your payment. This usually takes a few seconds.
</p> </p>
<p class="checkout-pending-hint"> <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> </p>
</div> </div>
<% end %> <% end %>
@ -1129,20 +1281,20 @@ defmodule BerrypodWeb.PageRenderer do
<div class="orders-empty"> <div class="orders-empty">
<p>This link has expired or is invalid.</p> <p>This link has expired or is invalid.</p>
<p class="orders-empty-hint"> <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> </p>
</div> </div>
<% assigns[:orders] == [] -> %> <% assigns[:orders] == [] -> %>
<div class="orders-empty"> <div class="orders-empty">
<p>No orders found for that email address.</p> <p>No orders found for that email address.</p>
<p class="orders-empty-hint"> <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> </p>
</div> </div>
<% true -> %> <% true -> %>
<div class="orders-list"> <div class="orders-list">
<%= for order <- assigns[:orders] do %> <%= 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 class="order-summary-top">
<div> <div>
<p class="order-summary-number">{order.order_number}</p> <p class="order-summary-number">{order.order_number}</p>
@ -1184,7 +1336,7 @@ defmodule BerrypodWeb.PageRenderer do
~H""" ~H"""
<%= if assigns[:order] do %> <%= if assigns[:order] do %>
<div class="order-detail-header"> <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> <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> <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"}> <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> <div>
<%= if info && info.slug do %> <%= if info && info.slug do %>
<.link <.link
patch={"/products/#{info.slug}"} patch={R.product(info.slug)}
class="checkout-item-name checkout-item-link" class="checkout-item-name checkout-item-link"
> >
{item.product_name} {item.product_name}
@ -1286,7 +1438,7 @@ defmodule BerrypodWeb.PageRenderer do
<% end %> <% end %>
<div class="checkout-actions"> <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> </div>
<% end %> <% end %>
""" """
@ -1298,7 +1450,7 @@ defmodule BerrypodWeb.PageRenderer do
~H""" ~H"""
<.page_title text="Search" /> <.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 <input
type="search" type="search"
name="q" name="q"
@ -1329,7 +1481,7 @@ defmodule BerrypodWeb.PageRenderer do
<%= if (assigns[:search_page_query] || "") != "" do %> <%= if (assigns[:search_page_query] || "") != "" do %>
<div class="collection-empty"> <div class="collection-empty">
<p>No products found for &ldquo;{assigns[:search_page_query]}&rdquo;</p> <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> </div>
<% end %> <% end %>
<% end %> <% end %>
@ -1469,7 +1621,7 @@ defmodule BerrypodWeb.PageRenderer do
slug = cat |> String.downcase() |> String.replace(" ", "-") 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} %{label: title, current: true}
] ]
end end
@ -1480,8 +1632,8 @@ defmodule BerrypodWeb.PageRenderer do
defp breadcrumb_items(_), do: [] defp breadcrumb_items(_), do: []
defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}" defp collection_path(slug, "featured"), do: R.collection(slug)
defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}" 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). # 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 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 def call(conn, {router, router_opts}) do
router.call(conn, router_opts) router.call(conn, router_opts)
rescue rescue
e in Phoenix.Router.NoRouteError -> e in [Phoenix.Router.NoRouteError, BerrypodWeb.NotFoundError] ->
unless static_path?(conn.request_path) do unless static_path?(conn.request_path) do
prior_hits = Berrypod.Analytics.count_pageviews_for_path(conn.request_path) prior_hits = Berrypod.Analytics.count_pageviews_for_path(conn.request_path)
Berrypod.Redirects.record_broken_url(conn.request_path, prior_hits) Berrypod.Redirects.record_broken_url(conn.request_path, prior_hits)

View File

@ -605,6 +605,65 @@ defmodule Berrypod.PagesTest do
end end
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 describe "duplicate_custom_page/1" do
test "creates a draft copy with -copy slug" do test "creates a draft copy with -copy slug" do
{:ok, original} = {:ok, original} =

View File

@ -1,5 +1,5 @@
defmodule Berrypod.SiteTest do defmodule Berrypod.SiteTest do
use Berrypod.DataCase, async: true use Berrypod.DataCase, async: false
alias Berrypod.Site alias Berrypod.Site
alias Berrypod.Site.SocialLink alias Berrypod.Site.SocialLink
@ -274,4 +274,49 @@ defmodule Berrypod.SiteTest do
assert Site.show_newsletter?() == true assert Site.show_newsletter?() == true
end end
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 end

View File

@ -1,18 +1,25 @@
defmodule BerrypodWeb.Plugs.BrokenUrlTrackerTest do defmodule BerrypodWeb.Plugs.BrokenUrlTrackerTest do
use BerrypodWeb.ConnCase, async: true use BerrypodWeb.ConnCase, async: true
alias Berrypod.Redirects import Berrypod.AccountsFixtures
alias Berrypod.{Redirects, Settings}
setup do setup do
Redirects.create_table() 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 :ok
end end
test "records broken URL on 404", %{conn: conn} do test "records broken URL on 404", %{conn: conn} do
# Multi-segment path — not caught by the /:slug catch-all route # Multi-segment path goes through the catch-all route and raises NotFoundError.
conn = get(conn, "/zz/nonexistent-path") # The BrokenUrlTracker plug catches this and records it before re-raising.
assert_error_sent :not_found, fn ->
assert conn.status in [404, 500] get(conn, "/zz/nonexistent-path")
end
[broken_url] = Redirects.list_broken_urls() [broken_url] = Redirects.list_broken_urls()
assert broken_url.path == "/zz/nonexistent-path" assert broken_url.path == "/zz/nonexistent-path"
@ -20,7 +27,11 @@ defmodule BerrypodWeb.Plugs.BrokenUrlTrackerTest do
end end
test "skips static asset paths", %{conn: conn} do 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") get(conn, "/assets/missing-file.js")
end
assert Redirects.list_broken_urls() == [] assert Redirects.list_broken_urls() == []
end end

View File

@ -32,9 +32,23 @@ defmodule BerrypodWeb.ConnCase do
end end
setup tags do setup tags do
Berrypod.DataCase.setup_sandbox(tags) pid = Berrypod.DataCase.setup_sandbox(tags)
Berrypod.Settings.SettingsCache.invalidate_all() 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 end
@doc """ @doc """

View File

@ -30,15 +30,21 @@ defmodule Berrypod.DataCase do
setup tags do setup tags do
Berrypod.DataCase.setup_sandbox(tags) Berrypod.DataCase.setup_sandbox(tags)
Berrypod.Settings.SettingsCache.invalidate_all() 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 :ok
end end
@doc """ @doc """
Sets up the sandbox based on the test tags. Sets up the sandbox based on the test tags.
Returns the owner pid for use in metadata generation.
""" """
def setup_sandbox(tags) do def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Berrypod.Repo, shared: not tags[:async]) pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Berrypod.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
pid
end end
@doc """ @doc """