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:
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user