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

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