diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 9018d92..4da415d 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -2543,6 +2543,73 @@ width: 5rem; } +.page-settings-hint { + font-size: 0.6875rem; + color: var(--admin-text-secondary); + margin: 0; + padding-top: 0.125rem; +} + +/* URL editor - segmented input for page URLs */ + +.url-editor { + display: flex; + align-items: center; + gap: 0; +} + +.url-editor-slash { + padding: 0.375rem 0.25rem; + background: var(--admin-bg); + border: 1px solid var(--admin-border); + border-right: none; + font-size: 0.875rem; + color: var(--admin-text-tertiary); + + &:first-child { + border-radius: var(--admin-radius) 0 0 var(--admin-radius); + } +} + +.url-editor-input { + border-radius: 0; + flex: 1; + min-width: 4rem; + + &:last-child { + border-radius: 0 var(--admin-radius) var(--admin-radius) 0; + } +} + +.url-editor-segmented .url-editor-input { + flex: 0 1 auto; + width: auto; +} + +.url-editor-prefix { + max-width: 8rem; +} + +.url-editor-fixed { + padding: 0.375rem 0.5rem; + background: var(--admin-bg); + border: 1px solid var(--admin-border); + border-left: none; + border-radius: 0 var(--admin-radius) var(--admin-radius) 0; + font-size: 0.875rem; + color: var(--admin-text-tertiary); + font-style: italic; +} + +.url-editor-fixed-path { + padding: 0.375rem 0.5rem; + background: var(--admin-bg); + border: 1px solid var(--admin-border); + border-radius: var(--admin-radius); + font-size: 0.875rem; + color: var(--admin-text-secondary); +} + /* Block list in editor */ .block-list { diff --git a/lib/berrypod/site.ex b/lib/berrypod/site.ex index ba5f895..9bed53f 100644 --- a/lib/berrypod/site.ex +++ b/lib/berrypod/site.ex @@ -22,6 +22,7 @@ defmodule Berrypod.Site do NavItem |> where([n], n.location == ^location) |> order_by([n], asc: n.position) + |> preload(:page) |> Repo.all() end @@ -89,14 +90,42 @@ defmodule Berrypod.Site do end defp nav_item_to_map(%NavItem{} = item) do + # When page_id is set, resolve URL dynamically via R module + # This ensures nav links stay up to date when page URLs change + href = resolve_nav_item_url(item) + %{ "label" => item.label, - "href" => item.url, - "slug" => slug_from_url(item.url), + "href" => href, + "slug" => slug_from_url(href), "page_id" => item.page_id } end + defp resolve_nav_item_url(%NavItem{page: %Berrypod.Pages.Page{} = page}) do + BerrypodWeb.R.page(page.slug) + end + + defp resolve_nav_item_url(%NavItem{url: "/" <> slug}) when slug != "" do + # Internal page link stored as URL - resolve through R module + # This handles cases where page_id isn't set but URL points to a page + # Strip any trailing path segments (e.g., /collections/all -> collections) + base_slug = slug |> String.split("/") |> List.first() + + # Route system pages through R module to get custom URL slugs + if Berrypod.Pages.Page.system_slug?(base_slug) do + BerrypodWeb.R.page(base_slug) + else + # Check if this is a custom page with a url_slug + case Berrypod.Pages.get_page(base_slug) do + %{url_slug: url_slug} when not is_nil(url_slug) -> "/" <> url_slug + _ -> "/" <> slug + end + end + end + + defp resolve_nav_item_url(%NavItem{url: url}), do: url + defp slug_from_url("/"), do: "home" defp slug_from_url("/collections" <> _), do: "collection" defp slug_from_url("/products" <> _), do: "pdp" diff --git a/lib/berrypod_web.ex b/lib/berrypod_web.ex index 67c0883..05faf7f 100644 --- a/lib/berrypod_web.ex +++ b/lib/berrypod_web.ex @@ -44,6 +44,8 @@ defmodule BerrypodWeb do use Gettext, backend: BerrypodWeb.Gettext import Plug.Conn + # Runtime URL paths (custom slugs/prefixes) + alias BerrypodWeb.R unquote(verified_routes()) end @@ -53,6 +55,11 @@ defmodule BerrypodWeb do quote do use Phoenix.LiveView + # Enable sandbox access for LiveView processes in test mode + if Application.compile_env(:berrypod, :env) == :test do + on_mount BerrypodWeb.LiveSandboxHook + end + unquote(html_helpers()) end end @@ -93,6 +100,8 @@ defmodule BerrypodWeb do # Common modules used in templates alias Phoenix.LiveView.JS alias BerrypodWeb.Layouts + # Runtime URL paths (custom slugs/prefixes) + alias BerrypodWeb.R # Routes generation with the ~p sigil unquote(verified_routes()) diff --git a/lib/berrypod_web/components/shop_components/cart.ex b/lib/berrypod_web/components/shop_components/cart.ex index 37265e5..1273e68 100644 --- a/lib/berrypod_web/components/shop_components/cart.ex +++ b/lib/berrypod_web/components/shop_components/cart.ex @@ -6,6 +6,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do import BerrypodWeb.ShopComponents.Base alias Berrypod.Products.{Product, ProductImage} + alias BerrypodWeb.R defp close_cart_drawer_js do Phoenix.LiveView.JS.push("close_cart_drawer") @@ -193,7 +194,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do > <%= if @mode != :preview do %> <.link - patch={"/products/#{@item.product_id}"} + patch={R.product(@item.product_id)} class={["cart-item-image", !@item.image && "cart-item-image--empty"]} data-size={if @size == :compact, do: "compact"} style={if @item.image, do: "background-image: url('#{@item.image}');"} @@ -212,7 +213,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do

<%= if @mode != :preview do %> <.link - patch={"/products/#{@item.product_id}"} + patch={R.product(@item.product_id)} class="cart-item-name-link" > {@item.name} @@ -321,7 +322,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do <% else %> <.link - patch="/collections/all" + patch={R.collection("all")} class="cart-continue-link" > Continue shopping @@ -533,7 +534,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do

Checkout isn't available yet.

<.shop_link_outline - href="/collections/all" + href={R.collection("all")} class="order-summary-continue" > Continue shopping @@ -544,7 +545,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do

Remove unavailable items to checkout.

<.shop_link_outline - href="/collections/all" + href={R.collection("all")} class="order-summary-continue" > Continue shopping @@ -557,7 +558,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do <.shop_link_outline - href="/collections/all" + href={R.collection("all")} class="order-summary-continue" > Continue shopping diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex index 2479796..56ac723 100644 --- a/lib/berrypod_web/components/shop_components/layout.ex +++ b/lib/berrypod_web/components/shop_components/layout.ex @@ -4,6 +4,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do import BerrypodWeb.ShopComponents.Cart import BerrypodWeb.ShopComponents.Content + alias BerrypodWeb.R + @doc """ Renders the announcement bar. @@ -153,10 +155,18 @@ defmodule BerrypodWeb.ShopComponents.Layout do # Extract a slug from a URL for nav item matching defp extract_slug_from_url(url) when is_binary(url) do cond do - url == "/" -> "home" - String.starts_with?(url, "/collections") -> "collection" - String.starts_with?(url, "/products") -> "pdp" - true -> url |> String.trim_leading("/") |> String.split("/") |> List.first() || "" + url == "/" -> + "home" + + true -> + # Extract first segment and check if it's a known prefix + first_segment = url |> String.trim_leading("/") |> String.split("/") |> List.first() || "" + + case R.prefix_type_from_segment(first_segment) do + :collections -> "collection" + :products -> "pdp" + _ -> first_segment + end end end @@ -651,7 +661,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do aria-selected="false" > <.link - patch={"/products/#{item.product.slug || item.product.id}"} + patch={R.product(item.product.slug || item.product.id)} class="search-result" phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")} > @@ -782,7 +792,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do <% else %> <.link - patch={"/collections/#{category.slug}"} + patch={R.collection(category.slug)} class="mobile-nav-link" phx-click={ Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer") @@ -868,7 +878,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do <% else %>
  • <.link - patch="/collections/all" + patch={R.collection("all")} class="footer-link" > All products @@ -877,7 +887,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do <%= for category <- @categories do %>
  • <.link - patch={"/collections/#{category.slug}"} + patch={R.collection(category.slug)} class="footer-link" > {category.name} @@ -1013,7 +1023,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do <.admin_cog_svg /> <% else %> <.link - patch={"/collections/#{Slug.slugify(@product.category)}"} + patch={R.collection(Slug.slugify(@product.category))} class="product-card-category" > {@product.category} @@ -177,7 +178,7 @@ defmodule BerrypodWeb.ShopComponents.Product do <% else %> <.link - patch={"/products/#{Map.get(@product, :slug) || Map.get(@product, :id)}"} + patch={R.product(Map.get(@product, :slug) || Map.get(@product, :id))} class="stretched-link" > {@product.title} @@ -629,7 +630,7 @@ defmodule BerrypodWeb.ShopComponents.Product do <% else %> <.link - patch={"/collections/#{category.slug}"} + patch={R.collection(category.slug)} class="category-card" >
    Cart.put_in_session(cart) |> put_flash(:info, "Added to basket") - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) end def add(conn, _params) do conn |> put_flash(:error, "Could not add item to basket") - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) end @doc """ @@ -60,7 +60,7 @@ defmodule BerrypodWeb.CartController do conn |> Cart.put_in_session(cart) |> put_flash(:info, "Removed from basket") - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) end @doc """ @@ -73,7 +73,7 @@ defmodule BerrypodWeb.CartController do conn |> Cart.put_in_session(cart) - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) end @doc """ @@ -82,11 +82,11 @@ defmodule BerrypodWeb.CartController do def update_country(conn, %{"country" => code}) when is_binary(code) and code != "" do conn |> put_session("country_code", code) - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) end def update_country(conn, _params) do - redirect(conn, to: ~p"/cart") + redirect(conn, to: R.cart()) end defp parse_quantity(str) when is_binary(str) do diff --git a/lib/berrypod_web/controllers/checkout_controller.ex b/lib/berrypod_web/controllers/checkout_controller.ex index 8fa4294..6fb5f87 100644 --- a/lib/berrypod_web/controllers/checkout_controller.ex +++ b/lib/berrypod_web/controllers/checkout_controller.ex @@ -11,7 +11,7 @@ defmodule BerrypodWeb.CheckoutController do unless Settings.has_secret?("stripe_api_key") do conn |> put_flash(:error, "Checkout isn't available yet") - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) else cart_items = Cart.get_from_session(get_session(conn)) hydrated = Cart.hydrate(cart_items) @@ -20,12 +20,12 @@ defmodule BerrypodWeb.CheckoutController do hydrated == [] -> conn |> put_flash(:error, "Your basket is empty") - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) Enum.any?(hydrated, &(&1.is_available == false)) -> conn |> put_flash(:error, "Some items in your basket are no longer available") - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) true -> track_checkout_start(conn) @@ -45,7 +45,7 @@ defmodule BerrypodWeb.CheckoutController do conn |> put_flash(:error, "Something went wrong. Please try again.") - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) end end @@ -67,14 +67,12 @@ defmodule BerrypodWeb.CheckoutController do } end) - base_url = BerrypodWeb.Endpoint.url() - params = %{ mode: "payment", line_items: line_items, - success_url: "#{base_url}/checkout/success?session_id={CHECKOUT_SESSION_ID}", - cancel_url: "#{base_url}/cart", + success_url: R.url(R.checkout_success()) <> "?session_id={CHECKOUT_SESSION_ID}", + cancel_url: R.url(R.cart()), metadata: %{"order_id" => order.id}, shipping_address_collection: %{ allowed_countries: ["GB", "US", "CA", "AU", "DE", "FR", "NL", "IE", "AT", "BE"] @@ -96,7 +94,7 @@ defmodule BerrypodWeb.CheckoutController do conn |> put_flash(:error, "Payment setup failed. Please try again.") - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) {:error, reason} -> Logger.error("Stripe session creation failed: #{inspect(reason)}") @@ -104,7 +102,7 @@ defmodule BerrypodWeb.CheckoutController do conn |> put_flash(:error, "Payment setup failed. Please try again.") - |> redirect(to: ~p"/cart") + |> redirect(to: R.cart()) end end diff --git a/lib/berrypod_web/controllers/contact_controller.ex b/lib/berrypod_web/controllers/contact_controller.ex index 74d2262..69cd9cf 100644 --- a/lib/berrypod_web/controllers/contact_controller.ex +++ b/lib/berrypod_web/controllers/contact_controller.ex @@ -13,17 +13,17 @@ defmodule BerrypodWeb.ContactController do {:ok, _} -> conn |> put_flash(:info, "Message sent! We'll get back to you soon.") - |> redirect(to: ~p"/contact") + |> redirect(to: R.contact()) {:error, :invalid_params} -> conn |> put_flash(:error, "Please fill in all required fields.") - |> redirect(to: ~p"/contact") + |> redirect(to: R.contact()) {:error, _} -> conn |> put_flash(:error, "Sorry, something went wrong. Please try again.") - |> redirect(to: ~p"/contact") + |> redirect(to: R.contact()) end end end diff --git a/lib/berrypod_web/controllers/order_lookup_controller.ex b/lib/berrypod_web/controllers/order_lookup_controller.ex index e79034a..62efc0c 100644 --- a/lib/berrypod_web/controllers/order_lookup_controller.ex +++ b/lib/berrypod_web/controllers/order_lookup_controller.ex @@ -19,7 +19,7 @@ defmodule BerrypodWeb.OrderLookupController do :error, "No orders found for that address. Make sure you use the same email you checked out with." ) - |> redirect(to: ~p"/contact") + |> redirect(to: R.contact()) else token = generate_token(email) link = BerrypodWeb.Endpoint.url() <> ~p"/orders/verify/#{token}" @@ -30,14 +30,14 @@ defmodule BerrypodWeb.OrderLookupController do :info, "We've sent a link to your email address. It'll expire after an hour." ) - |> redirect(to: ~p"/contact") + |> redirect(to: R.contact()) end end def lookup(conn, _params) do conn |> put_flash(:error, "Please enter your email address.") - |> redirect(to: ~p"/contact") + |> redirect(to: R.contact()) end def verify(conn, %{"token" => token}) do @@ -45,17 +45,17 @@ defmodule BerrypodWeb.OrderLookupController do {:ok, email} -> conn |> put_session(:order_lookup_email, email) - |> redirect(to: ~p"/orders") + |> redirect(to: R.orders()) {:error, :expired} -> conn |> put_flash(:error, "That link has expired. Please request a new one.") - |> redirect(to: ~p"/contact") + |> redirect(to: R.contact()) {:error, _} -> conn |> put_flash(:error, "That link is invalid.") - |> redirect(to: ~p"/contact") + |> redirect(to: R.contact()) end end diff --git a/lib/berrypod_web/controllers/seo_controller.ex b/lib/berrypod_web/controllers/seo_controller.ex index f7fb2c7..f275d38 100644 --- a/lib/berrypod_web/controllers/seo_controller.ex +++ b/lib/berrypod_web/controllers/seo_controller.ex @@ -2,6 +2,7 @@ defmodule BerrypodWeb.SeoController do use BerrypodWeb, :controller alias Berrypod.{Pages, Products} + alias BerrypodWeb.R def robots(conn, _params) do base = BerrypodWeb.Endpoint.url() @@ -29,23 +30,23 @@ defmodule BerrypodWeb.SeoController do categories = Products.list_categories() static_pages = [ - {"/", "daily", "1.0"}, - {"/collections/all", "daily", "0.9"}, - {"/about", "monthly", "0.5"}, - {"/contact", "monthly", "0.5"}, - {"/delivery", "monthly", "0.5"}, - {"/privacy", "monthly", "0.3"}, - {"/terms", "monthly", "0.3"} + {R.home(), "daily", "1.0"}, + {R.collection("all"), "daily", "0.9"}, + {R.about(), "monthly", "0.5"}, + {R.contact(), "monthly", "0.5"}, + {R.delivery(), "monthly", "0.5"}, + {R.privacy(), "monthly", "0.3"}, + {R.terms(), "monthly", "0.3"} ] category_pages = Enum.map(categories, fn cat -> - {"/collections/#{cat.slug}", "daily", "0.8"} + {R.collection(cat.slug), "daily", "0.8"} end) product_pages = Enum.map(products, fn product -> - {"/products/#{product.slug}", "weekly", "0.9"} + {R.product(product.slug), "weekly", "0.9"} end) custom_pages = diff --git a/lib/berrypod_web/live/admin/product_show.ex b/lib/berrypod_web/live/admin/product_show.ex index b4aaf2a..a5063cf 100644 --- a/lib/berrypod_web/live/admin/product_show.ex +++ b/lib/berrypod_web/live/admin/product_show.ex @@ -4,6 +4,7 @@ defmodule BerrypodWeb.Admin.ProductShow do alias Berrypod.Products alias Berrypod.Products.{Product, ProductImage, ProductVariant} alias Berrypod.Cart + alias BerrypodWeb.R @impl true def mount(%{"id" => id}, _session, socket) do @@ -109,7 +110,7 @@ defmodule BerrypodWeb.Admin.ProductShow do <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> <.link - navigate={~p"/products/#{@product.slug}"} + navigate={R.product(@product.slug)} class="admin-btn admin-btn-ghost admin-btn-sm" > View on shop <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> diff --git a/lib/berrypod_web/live/admin/settings.ex b/lib/berrypod_web/live/admin/settings.ex index f9acb42..152ae67 100644 --- a/lib/berrypod_web/live/admin/settings.ex +++ b/lib/berrypod_web/live/admin/settings.ex @@ -18,7 +18,15 @@ defmodule BerrypodWeb.Admin.Settings do |> assign(:from_address_status, :idle) |> assign(:signing_secret_status, :idle) |> assign_stripe_state() - |> assign_products_state()} + |> assign_products_state() + |> assign_url_prefixes()} + end + + defp assign_url_prefixes(socket) do + socket + |> assign(:products_prefix, Settings.get_url_prefix(:products)) + |> assign(:collections_prefix, Settings.get_url_prefix(:collections)) + |> assign(:prefix_status, :idle) end # -- Stripe assigns -- @@ -117,6 +125,51 @@ defmodule BerrypodWeb.Admin.Settings do end end + # -- Events: URL prefixes -- + + def handle_event("change_prefix", _params, socket) do + {:noreply, assign(socket, :prefix_status, :idle)} + end + + def handle_event("save_prefixes", %{"prefixes" => params}, socket) do + products_prefix = params["products"] || "" + collections_prefix = params["collections"] || "" + + errors = [] + + # Update products prefix if changed + errors = + if products_prefix != socket.assigns.products_prefix do + case Settings.update_url_prefix(:products, products_prefix) do + {:ok, _} -> errors + {:error, reason} -> [{:products, reason} | errors] + end + else + errors + end + + # Update collections prefix if changed + errors = + if collections_prefix != socket.assigns.collections_prefix do + case Settings.update_url_prefix(:collections, collections_prefix) do + {:ok, _} -> errors + {:error, reason} -> [{:collections, reason} | errors] + end + else + errors + end + + if errors == [] do + {:noreply, + socket + |> assign_url_prefixes() + |> assign(:prefix_status, :saved)} + else + error_message = format_prefix_errors(errors) + {:noreply, put_flash(socket, :error, error_message)} + end + end + # -- Events: Stripe -- def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do @@ -388,6 +441,67 @@ defmodule BerrypodWeb.Admin.Settings do
    + + <%!-- URL prefixes --%> +
    +

    URL prefixes

    +

    + Customise the URL structure for products and collections. + Old URLs will automatically redirect to the new ones. +

    +
    +
    +
    + +
    + / + <.input + name="prefixes[products]" + id="prefix-products" + value={@products_prefix} + placeholder="products" + pattern="[a-z0-9-]+" + /> + / + product-slug +
    +
    + +
    + +
    + / + <.input + name="prefixes[collections]" + id="prefix-collections" + value={@collections_prefix} + placeholder="collections" + pattern="[a-z0-9-]+" + /> + / + collection-slug +
    +
    + +

    + Use only lowercase letters, numbers, and hyphens. +

    + +
    + <.button phx-disable-with="Saving...">Save + <.inline_feedback status={@prefix_status} /> +
    +
    +
    +
    """ end @@ -639,4 +753,20 @@ defmodule BerrypodWeb.Admin.Settings do true -> "#{div(diff, 86400)} days ago" end end + + defp format_prefix_errors(errors) do + Enum.map_join(errors, "; ", fn {field, reason} -> + field_name = if field == :products, do: "Products", else: "Collections" + + message = + case reason do + :empty_prefix -> "can't be blank" + :invalid_format -> "must contain only letters, numbers, and hyphens" + :reserved_prefix -> "is reserved" + _ -> "is invalid" + end + + "#{field_name} prefix #{message}" + end) + end end diff --git a/lib/berrypod_web/live/shop/pages/checkout_success.ex b/lib/berrypod_web/live/shop/pages/checkout_success.ex index 80322a3..f866668 100644 --- a/lib/berrypod_web/live/shop/pages/checkout_success.ex +++ b/lib/berrypod_web/live/shop/pages/checkout_success.ex @@ -8,6 +8,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do import Phoenix.LiveView, only: [connected?: 1, redirect: 2] alias Berrypod.{Analytics, Orders, Pages} + alias BerrypodWeb.R def init(socket, %{"session_id" => session_id}, _uri) do order = Orders.get_order_by_stripe_session(session_id) @@ -21,7 +22,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do attrs = BerrypodWeb.AnalyticsHook.attrs(socket) - |> Map.merge(%{pathname: "/checkout/success", revenue: order.total}) + |> Map.merge(%{pathname: R.checkout_success(), revenue: order.total}) Analytics.track_event("purchase", attrs) end @@ -54,7 +55,7 @@ defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do end def init(socket, _params, _uri) do - {:redirect, redirect(socket, to: "/")} + {:redirect, redirect(socket, to: R.home())} end def handle_params(_params, _uri, socket) do diff --git a/lib/berrypod_web/live/shop/pages/collection.ex b/lib/berrypod_web/live/shop/pages/collection.ex index 2f287c2..2ce9a30 100644 --- a/lib/berrypod_web/live/shop/pages/collection.ex +++ b/lib/berrypod_web/live/shop/pages/collection.ex @@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3] alias Berrypod.{Pages, Pagination, Products} + alias BerrypodWeb.R @sort_options [ {"featured", "Featured"}, @@ -29,6 +30,11 @@ defmodule BerrypodWeb.Shop.Pages.Collection do {:noreply, socket} end + # When accessed via custom URL (e.g. /shop) without a collection slug, show all products + def handle_params(params, uri, socket) when not is_map_key(params, "slug") do + handle_params(Map.put(params, "slug", "all"), uri, socket) + end + def handle_params(%{"slug" => slug} = params, _uri, socket) do sort = params["sort"] || "featured" page_num = Pagination.parse_page(params) @@ -39,7 +45,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do socket |> assign(:page_title, title) |> assign(:page_description, collection_description(title)) - |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}") + |> assign(:og_url, R.url(R.collection(slug))) |> assign(:collection_title, title) |> assign(:collection_slug, slug) |> assign(:current_category, category) @@ -53,7 +59,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do socket = socket |> put_flash(:error, "Collection not found") - |> push_navigate(to: "/collections/all") + |> push_navigate(to: R.collection("all")) {:noreply, socket} end @@ -67,7 +73,7 @@ defmodule BerrypodWeb.Shop.Pages.Collection do category -> category.slug end - {:noreply, push_patch(socket, to: "/collections/#{slug}?sort=#{sort}")} + {:noreply, push_patch(socket, to: R.collection(slug) <> "?sort=#{sort}")} end def handle_event(_event, _params, _socket), do: :cont diff --git a/lib/berrypod_web/live/shop/pages/contact.ex b/lib/berrypod_web/live/shop/pages/contact.ex index 0d7a380..a95cefe 100644 --- a/lib/berrypod_web/live/shop/pages/contact.ex +++ b/lib/berrypod_web/live/shop/pages/contact.ex @@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Contact do import Phoenix.LiveView, only: [push_navigate: 2, put_flash: 3] alias Berrypod.{ContactNotifier, Orders} + alias BerrypodWeb.R alias Berrypod.Orders.OrderNotifier alias Berrypod.Pages alias BerrypodWeb.OrderLookupController @@ -21,7 +22,7 @@ defmodule BerrypodWeb.Shop.Pages.Contact do :page_description, "Get in touch with us for any questions or help with your order." ) - |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/contact") + |> assign(:og_url, R.url(R.contact())) |> assign(:tracking_state, :idle) |> assign(:page, page) @@ -54,7 +55,7 @@ defmodule BerrypodWeb.Shop.Pages.Contact do {:noreply, socket |> put_flash(:info, "Message sent! We'll get back to you soon.") - |> push_navigate(to: "/contact")} + |> push_navigate(to: R.contact())} {:error, :invalid_params} -> {:noreply, put_flash(socket, :error, "Please fill in all required fields.")} diff --git a/lib/berrypod_web/live/shop/pages/content.ex b/lib/berrypod_web/live/shop/pages/content.ex index 7978453..dd97922 100644 --- a/lib/berrypod_web/live/shop/pages/content.ex +++ b/lib/berrypod_web/live/shop/pages/content.ex @@ -9,6 +9,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do alias Berrypod.LegalPages alias Berrypod.Pages alias Berrypod.Theme.PreviewData + alias BerrypodWeb.R def init(socket, _params, _uri) do # Content pages load in handle_params based on live_action @@ -38,7 +39,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do %{ page_title: "About", page_description: "Your story goes here – this is sample content for the demo shop", - og_url: BerrypodWeb.Endpoint.url() <> "/about" + og_url: R.url(R.about()) }, PreviewData.about_content() } @@ -49,7 +50,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do %{ page_title: "Delivery & returns", page_description: "Everything you need to know about shipping and returns.", - og_url: BerrypodWeb.Endpoint.url() <> "/delivery" + og_url: R.url(R.delivery()) }, LegalPages.delivery_content() } @@ -60,7 +61,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do %{ page_title: "Privacy policy", page_description: "How we handle your personal information.", - og_url: BerrypodWeb.Endpoint.url() <> "/privacy" + og_url: R.url(R.privacy()) }, LegalPages.privacy_content() } @@ -71,7 +72,7 @@ defmodule BerrypodWeb.Shop.Pages.Content do %{ page_title: "Terms of service", page_description: "The terms and conditions governing purchases from our shop.", - og_url: BerrypodWeb.Endpoint.url() <> "/terms" + og_url: R.url(R.terms()) }, LegalPages.terms_content() } diff --git a/lib/berrypod_web/live/shop/pages/order_detail.ex b/lib/berrypod_web/live/shop/pages/order_detail.ex index 91a5f24..d161316 100644 --- a/lib/berrypod_web/live/shop/pages/order_detail.ex +++ b/lib/berrypod_web/live/shop/pages/order_detail.ex @@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do import Phoenix.LiveView, only: [push_navigate: 2] alias Berrypod.{Orders, Pages} + alias BerrypodWeb.R alias Berrypod.Products alias Berrypod.Products.ProductImage @@ -54,7 +55,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do {:noreply, socket} else - {:noreply, push_navigate(socket, to: "/orders")} + {:noreply, push_navigate(socket, to: R.orders())} end end diff --git a/lib/berrypod_web/live/shop/pages/product.ex b/lib/berrypod_web/live/shop/pages/product.ex index ac0a03f..f942b0c 100644 --- a/lib/berrypod_web/live/shop/pages/product.ex +++ b/lib/berrypod_web/live/shop/pages/product.ex @@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2] alias Berrypod.{Analytics, Cart, Pages} + alias BerrypodWeb.R alias Berrypod.Images.Optimizer alias Berrypod.Products alias Berrypod.Products.{Product, ProductImage} @@ -15,11 +16,11 @@ defmodule BerrypodWeb.Shop.Pages.Product do # Try to get product by slug, including discontinued products case Products.get_product_by_slug(slug, preload: [:variants, :images]) do nil -> - {:noreply, push_navigate(socket, to: "/collections/all")} + {:noreply, push_navigate(socket, to: R.collection("all"))} %{visible: false, status: status} when status != "discontinued" -> # Hidden but not discontinued - redirect away - {:noreply, push_navigate(socket, to: "/collections/all")} + {:noreply, push_navigate(socket, to: R.collection("all"))} product -> all_images = @@ -42,12 +43,12 @@ defmodule BerrypodWeb.Shop.Pages.Product do if connected?(socket) and socket.assigns[:analytics_visitor_hash] do Analytics.track_event( "product_view", - Map.put(BerrypodWeb.AnalyticsHook.attrs(socket), :pathname, "/products/#{slug}") + Map.put(BerrypodWeb.AnalyticsHook.attrs(socket), :pathname, R.product(slug)) ) end base = BerrypodWeb.Endpoint.url() - og_url = base <> "/products/#{slug}" + og_url = R.url(R.product(slug)) og_image = og_image_url(all_images) page = Pages.get_page("pdp") @@ -107,7 +108,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do Map.put( BerrypodWeb.AnalyticsHook.attrs(socket), :pathname, - "/products/#{socket.assigns.product.slug}" + R.product(socket.assigns.product.slug) ) ) end @@ -204,7 +205,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do opt_type.values |> Enum.map(fn value -> params = Map.put(selected_options, opt_type.name, value.title) - {value.title, "/products/#{slug}?#{URI.encode_query(params)}"} + {value.title, R.product(slug) <> "?#{URI.encode_query(params)}"} end) |> Map.new() @@ -278,7 +279,7 @@ defmodule BerrypodWeb.Shop.Pages.Product do # ── JSON-LD and meta helpers ───────────────────────────────────────── - defp product_json_ld(product, url, image, base) do + defp product_json_ld(product, url, image, _base) do category_slug = if product.category, do: product.category |> String.downcase() |> String.replace(" ", "-"), @@ -286,13 +287,13 @@ defmodule BerrypodWeb.Shop.Pages.Product do breadcrumbs = [ - %{"@type" => "ListItem", "position" => 1, "name" => "Home", "item" => base <> "/"}, + %{"@type" => "ListItem", "position" => 1, "name" => "Home", "item" => R.url(R.home())}, product.category && %{ "@type" => "ListItem", "position" => 2, "name" => product.category, - "item" => base <> "/collections/#{category_slug}" + "item" => R.url(R.collection(category_slug)) }, %{ "@type" => "ListItem", diff --git a/lib/berrypod_web/live/shop/pages/search.ex b/lib/berrypod_web/live/shop/pages/search.ex index c84dc47..179fffd 100644 --- a/lib/berrypod_web/live/shop/pages/search.ex +++ b/lib/berrypod_web/live/shop/pages/search.ex @@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.Pages.Search do import Phoenix.LiveView, only: [push_patch: 2] alias Berrypod.{Pages, Search} + alias BerrypodWeb.R def init(socket, _params, _uri) do page = Pages.get_page("search") @@ -32,7 +33,7 @@ defmodule BerrypodWeb.Shop.Pages.Search do end def handle_event("search_submit", %{"q" => query}, socket) do - {:noreply, push_patch(socket, to: "/search?q=#{query}")} + {:noreply, push_patch(socket, to: R.search() <> "?q=#{query}")} end def handle_event(_event, _params, _socket), do: :cont diff --git a/lib/berrypod_web/page_editor_hook.ex b/lib/berrypod_web/page_editor_hook.ex index 45ffb21..fda6b68 100644 --- a/lib/berrypod_web/page_editor_hook.ex +++ b/lib/berrypod_web/page_editor_hook.ex @@ -270,10 +270,11 @@ defmodule BerrypodWeb.PageEditorHook do end # Initialize settings form from current page if not already set - # Only custom pages have editable settings (meta_description, published, etc.) + # Init settings form for pages with editable settings + # Custom pages: all settings editable + # System pages: only url_slug editable (except home) socket = - if is_nil(socket.assigns[:settings_form]) && socket.assigns[:page] && - socket.assigns.page[:type] == "custom" do + if is_nil(socket.assigns[:settings_form]) && socket.assigns[:page] do init_settings_form(socket) else socket @@ -930,14 +931,14 @@ defmodule BerrypodWeb.PageEditorHook do # Catch-all for unknown theme actions defp handle_theme_action(_action, _params, socket), do: {:halt, socket} - # ── Settings editing actions (custom page settings) ──────────────── + # ── Settings editing actions (page settings) ──────────────── # Validate page settings form (live as-you-type) defp handle_settings_action("validate_page", %{"page" => params}, socket) do page = socket.assigns.page - # Only allow editing custom pages - if page && page.type == "custom" do + # Allow editing for custom pages (all fields) or system pages (url_slug only) + if page && page.type in ["custom", "system"] do socket = socket |> assign(:settings_form, params) @@ -954,43 +955,127 @@ defmodule BerrypodWeb.PageEditorHook do defp handle_settings_action("save_page", %{"page" => params}, socket) do page = socket.assigns.page - # Only allow editing custom pages - if page && page.type == "custom" do - # Normalize checkbox fields (unchecked checkboxes aren't sent) - params = - params - |> Map.put_new("published", "false") - |> Map.put_new("show_in_nav", "false") + cond do + # Custom pages: save all settings including url_slug + page && page.type == "custom" -> + save_custom_page_settings(socket, page, params) - old_slug = page.slug + # System pages with url_prefix (collections, products, orders) + page && page.type == "system" && params["url_prefix"] -> + save_system_page_url_prefix(socket, page, params["url_prefix"]) - # Fetch the Page struct from DB (assigns.page may be a map from cache) - page_struct = Pages.get_page_struct(page.slug) + # System pages: only save url_slug (other fields are read-only) + page && page.type == "system" && params["url_slug"] -> + save_system_page_url_slug(socket, page, params["url_slug"]) - case Pages.update_custom_page(page_struct, params) do - {:ok, updated_page} -> - # Reinitialize form from saved page + true -> + {:halt, socket} + end + end + + defp handle_settings_action(_action, _params, socket), do: {:halt, socket} + + defp save_custom_page_settings(socket, page, params) do + # Normalize checkbox fields (unchecked checkboxes aren't sent) + params = + params + |> Map.put_new("published", "false") + |> Map.put_new("show_in_nav", "false") + + old_slug = page.slug + + # Fetch the Page struct from DB (assigns.page may be a map from cache) + page_struct = Pages.get_page_struct(page.slug) + + # Handle url_slug change separately to ensure redirects are created + url_slug = params["url_slug"] + old_url_slug = page[:url_slug] + + # First update the main page settings + case Pages.update_custom_page(page_struct, params) do + {:ok, updated_page} -> + # If url_slug changed, handle redirect creation + socket = + if url_slug != old_url_slug do + Pages.update_page_url_slug(updated_page, url_slug) + # Reload page to get updated url_slug + updated_page = Pages.get_page(updated_page.slug) + assign(socket, :page, updated_page) + else + assign(socket, :page, updated_page) + end + + socket = + socket + |> assign(:settings_form, nil) + |> assign(:settings_dirty, false) + |> assign(:settings_save_status, :saved) + + # Reinit form with new page values + socket = init_settings_form(socket) + + # If slug changed, redirect to new URL + socket = + if updated_page.slug != old_slug do + push_navigate(socket, to: "/#{updated_page.slug}") + else + socket + end + + {:halt, socket} + + {:error, _changeset} -> + socket = assign(socket, :settings_save_status, :error) + {:halt, socket} + end + end + + defp save_system_page_url_slug(socket, page, url_slug) do + case Pages.update_page_url_slug(page.slug, url_slug) do + {:ok, _updated} -> + # Reload page from cache to get updated data + updated_page = Pages.get_page(page.slug) + + socket = + socket + |> assign(:page, updated_page) + |> assign(:settings_form, nil) + |> assign(:settings_dirty, false) + |> assign(:settings_save_status, :saved) + + socket = init_settings_form(socket) + {:halt, socket} + + {:error, _} -> + socket = assign(socket, :settings_save_status, :error) + {:halt, socket} + end + end + + # Save URL prefix for prefixed pages (collection, pdp, orders) + defp save_system_page_url_prefix(socket, page, new_prefix) do + # Determine the prefix type based on the page slug + prefix_type = + case page.slug do + "collection" -> :collections + "pdp" -> :products + "orders" -> :orders + "order_detail" -> :orders + _ -> nil + end + + if prefix_type do + case Settings.update_url_prefix(prefix_type, new_prefix) do + {:ok, _} -> socket = socket - |> assign(:page, updated_page) |> assign(:settings_form, nil) |> assign(:settings_dirty, false) |> assign(:settings_save_status, :saved) - # Reinit form with new page values - socket = init_settings_form(socket) - - # If slug changed, redirect to new URL - socket = - if updated_page.slug != old_slug do - push_navigate(socket, to: "/#{updated_page.slug}") - else - socket - end - {:halt, socket} - {:error, _changeset} -> + {:error, _reason} -> socket = assign(socket, :settings_save_status, :error) {:halt, socket} end @@ -999,9 +1084,6 @@ defmodule BerrypodWeb.PageEditorHook do end end - # Catch-all for unknown settings actions - defp handle_settings_action(_action, _params, socket), do: {:halt, socket} - # --- Site tab event handlers --- defp handle_site_action("update", %{"site" => site_params}, socket) do @@ -1266,13 +1348,33 @@ defmodule BerrypodWeb.PageEditorHook do defp has_settings_changed?(page, params) do page.title != (params["title"] || "") or page.slug != (params["slug"] || "") or + (page[:url_slug] || "") != (params["url_slug"] || "") or (page.meta_description || "") != (params["meta_description"] || "") or to_string(page.published) != (params["published"] || "false") or to_string(page.show_in_nav) != (params["show_in_nav"] || "false") or (page.nav_label || "") != (params["nav_label"] || "") or - to_string(page.nav_position || 0) != (params["nav_position"] || "0") + to_string(page.nav_position || 0) != (params["nav_position"] || "0") or + has_prefix_changed?(page, params) end + # Check if URL prefix changed for prefixed pages + defp has_prefix_changed?(page, params) do + case params["url_prefix"] do + nil -> + false + + new_prefix -> + prefix_type = prefix_type_for_page(page.slug) + prefix_type != nil and BerrypodWeb.R.prefix(prefix_type) != new_prefix + end + end + + defp prefix_type_for_page("collection"), do: :collections + defp prefix_type_for_page("pdp"), do: :products + defp prefix_type_for_page("orders"), do: :orders + defp prefix_type_for_page("order_detail"), do: :orders + defp prefix_type_for_page(_), do: nil + # Initialize settings form from page values defp init_settings_form(socket) do page = socket.assigns.page @@ -1280,6 +1382,7 @@ defmodule BerrypodWeb.PageEditorHook do form = %{ "title" => page.title || "", "slug" => page.slug || "", + "url_slug" => page[:url_slug] || "", "meta_description" => page.meta_description || "", "published" => to_string(page.published), "show_in_nav" => to_string(page.show_in_nav), @@ -1506,6 +1609,15 @@ defmodule BerrypodWeb.PageEditorHook do defp save_all_tabs(socket) do socket |> maybe_save_page() + |> maybe_save_settings() + |> maybe_finish_save() + end + + # If settings save triggered a navigation, don't overwrite the socket + defp maybe_finish_save(%{redirected: {:live, _, _}} = socket), do: socket + + defp maybe_finish_save(socket) do + socket |> maybe_save_theme() |> maybe_save_site() |> assign(:editor_save_status, :saved) @@ -1537,6 +1649,124 @@ defmodule BerrypodWeb.PageEditorHook do end end + defp maybe_save_settings(socket) do + if socket.assigns[:settings_dirty] do + page = socket.assigns.page + params = socket.assigns[:settings_form] || %{} + + cond do + # Custom pages: save all settings including url_slug + page && page.type == "custom" -> + do_save_custom_page_settings(socket, page, params) + + # System pages with url_prefix (collections, products, orders) + page && page.type == "system" && params["url_prefix"] -> + do_save_system_page_url_prefix(socket, page, params["url_prefix"]) + + # System pages: only save url_slug + page && page.type == "system" && params["url_slug"] -> + do_save_system_page_url_slug(socket, page, params["url_slug"]) + + true -> + socket + end + else + socket + end + end + + defp do_save_custom_page_settings(socket, page, params) do + params = + params + |> Map.put_new("published", "false") + |> Map.put_new("show_in_nav", "false") + + page_struct = Pages.get_page_struct(page.slug) + url_slug = params["url_slug"] + old_url_slug = page[:url_slug] + + case Pages.update_custom_page(page_struct, params) do + {:ok, updated_page} -> + if url_slug != old_url_slug do + Pages.update_page_url_slug(updated_page, url_slug) + end + + updated_page = Pages.get_page(updated_page.slug) + + socket + |> assign(:page, updated_page) + |> assign(:settings_form, nil) + |> assign(:settings_dirty, false) + |> init_settings_form() + + {:error, _changeset} -> + socket + end + end + + defp do_save_system_page_url_slug(socket, page, url_slug) do + old_url = Pages.Page.effective_url(page) + + case Pages.update_page_url_slug(page.slug, url_slug) do + {:ok, _updated} -> + updated_page = Pages.get_page(page.slug) + new_url = Pages.Page.effective_url(updated_page) + + socket = + socket + |> assign(:page, updated_page) + |> assign(:settings_form, nil) + |> assign(:settings_dirty, false) + |> init_settings_form() + + # If URL changed, navigate to the new URL + if old_url != new_url do + push_navigate(socket, to: "/#{new_url}?edit=page") + else + socket + end + + {:error, _reason} -> + socket + end + end + + defp do_save_system_page_url_prefix(socket, page, new_prefix) do + prefix_type = prefix_type_for_page(page.slug) + + if prefix_type do + old_prefix = BerrypodWeb.R.prefix(prefix_type) + + case Settings.update_url_prefix(prefix_type, new_prefix) do + {:ok, _} -> + socket = + socket + |> assign(:settings_form, nil) + |> assign(:settings_dirty, false) + + # If prefix changed, navigate to the new URL + if old_prefix != new_prefix do + # For collection page, navigate to /new-prefix/all + new_path = + case page.slug do + "collection" -> "/#{new_prefix}/all?edit=page" + "pdp" -> "/#{new_prefix}?edit=page" + _ -> "/#{new_prefix}?edit=page" + end + + push_navigate(socket, to: new_path) + else + socket + end + + {:error, _reason} -> + socket + end + else + socket + end + end + defp maybe_save_theme(socket) do if socket.assigns[:theme_dirty] do case Settings.update_theme_settings(Map.from_struct(socket.assigns.theme_editor_settings)) do diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index a6d65e5..ee37b06 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -19,6 +19,7 @@ defmodule BerrypodWeb.PageRenderer do import BerrypodWeb.CoreComponents, only: [icon: 1, external_link: 1] alias Berrypod.Cart + alias BerrypodWeb.R # ── Public API ────────────────────────────────────────────────── @@ -88,6 +89,7 @@ defmodule BerrypodWeb.PageRenderer do editor_dirty={@editor_dirty} theme_dirty={Map.get(assigns, :theme_dirty, false)} site_dirty={Map.get(assigns, :site_dirty, false)} + settings_dirty={Map.get(assigns, :settings_dirty, false)} editor_sheet_state={assigns[:editor_sheet_state] || :collapsed} editor_save_status={@editor_save_status} editor_active_tab={Map.get(assigns, :editor_active_tab, :page)} @@ -373,14 +375,18 @@ defmodule BerrypodWeb.PageRenderer do defp page_settings_section(assigns) do form = assigns.form || %{} is_custom = assigns.page[:type] == "custom" - page = assigns.page + # Determine URL structure for this page + url_info = compute_url_info(page, form) + assigns = assigns |> assign(:is_custom, is_custom) + |> assign(:url_info, url_info) |> assign(:form_title, form["title"] || page[:title] || "") |> assign(:form_slug, form["slug"] || page[:slug] || "") + |> assign(:form_url_slug, form["url_slug"] || page[:url_slug] || "") |> assign(:form_meta, form["meta_description"] || page[:meta_description] || "") |> assign(:form_published, form_checked?(form, "published", page[:published])) |> assign(:form_show_in_nav, form_checked?(form, "show_in_nav", page[:show_in_nav])) @@ -415,22 +421,8 @@ defmodule BerrypodWeb.PageRenderer do /> -
    - -
    - / - -
    -
    + <%!-- URL editor - different layouts based on page type --%> + <.url_editor url_info={@url_info} is_custom={@is_custom} form_slug={@form_slug} />
    @@ -497,6 +489,166 @@ defmodule BerrypodWeb.PageRenderer do """ end + # URL editor component - renders the right UI based on page type + attr :url_info, :map, required: true + attr :is_custom, :boolean, required: true + attr :form_slug, :string, required: true + + defp url_editor(%{url_info: %{type: :none}} = assigns) do + # Home page - no URL field + ~H"" + end + + defp url_editor(%{url_info: %{type: :simple}} = assigns) do + # Simple single-segment URL (about, contact, cart, custom pages, etc.) + ~H""" +
    + +
    + / + +
    +

    {@url_info.hint}

    +
    + """ + end + + defp url_editor(%{url_info: %{type: :prefixed}} = assigns) do + # Prefixed URL (collections, products, orders) + ~H""" +
    + +
    + / + + / + {@url_info.suffix} +
    +

    {@url_info.hint}

    +
    + """ + end + + defp url_editor(%{url_info: %{type: :fixed}} = assigns) do + # Fixed URL (checkout/success, etc.) + ~H""" +
    + +
    + {@url_info.path} +
    +
    + """ + end + + # Compute URL info for a page based on its type + # Uses generic placeholders for prefixed pages since we're editing the template + defp compute_url_info(page, form) do + slug = page[:slug] + + case slug do + # Home - no URL field + "home" -> + %{type: :none} + + # Prefixed pages - show editable prefix + placeholder suffix + "collection" -> + prefix = form["url_prefix"] || R.prefix(:collections) + + %{ + type: :prefixed, + prefix: prefix, + prefix_type: :collections, + suffix: ":slug", + hint: "Changing the prefix affects all collection pages" + } + + "pdp" -> + prefix = form["url_prefix"] || R.prefix(:products) + + %{ + type: :prefixed, + prefix: prefix, + prefix_type: :products, + suffix: ":id", + hint: "Changing the prefix affects all product pages" + } + + "orders" -> + prefix = form["url_prefix"] || R.prefix(:orders) + + %{ + type: :prefixed, + prefix: prefix, + prefix_type: :orders, + suffix: ":number", + hint: "Changing the prefix affects all order pages" + } + + "order_detail" -> + prefix = form["url_prefix"] || R.prefix(:orders) + + %{ + type: :prefixed, + prefix: prefix, + prefix_type: :orders, + suffix: ":number", + hint: "Changing the prefix affects all order pages" + } + + # Fixed paths + "checkout_success" -> + %{type: :fixed, path: "/checkout/success"} + + "error" -> + %{type: :none} + + # Simple system pages and custom pages + _ -> + if page[:type] == "custom" do + # Custom page - edit the slug directly + value = form["slug"] || slug + + %{ + type: :simple, + value: value, + field_name: "page[slug]", + hint: nil + } + else + # System page - edit the url_slug override + value = form["url_slug"] || page[:url_slug] || "" + + %{ + type: :simple, + value: value, + field_name: "page[url_slug]", + hint: + if(value == "", + do: "Default: /#{slug}", + else: "Old URL /#{slug} will redirect here" + ) + } + end + end + end + defp form_checked?(form, key, page_value) when is_map(form) do case form[key] do "true" -> true @@ -842,7 +994,7 @@ defmodule BerrypodWeb.PageRenderer do
    @@ -890,14 +1042,14 @@ defmodule BerrypodWeb.PageRenderer do <.shop_pagination :if={assigns[:pagination] && assigns[:pagination].total_pages > 1} page={assigns[:pagination]} - base_path={~p"/collections/#{@collection_slug}"} + base_path={R.collection(@collection_slug)} params={@sort_params} /> <%= if (assigns[:products] || []) == [] do %>

    No products found in this collection.

    - <.link patch={~p"/collections/all"} class="collection-empty-link"> + <.link patch={R.collection("all")} class="collection-empty-link"> View all products
    @@ -1075,7 +1227,7 @@ defmodule BerrypodWeb.PageRenderer do <% end %>
    - <.shop_link_button href="/collections/all" class="checkout-cta"> + <.shop_link_button href={R.collection("all")} class="checkout-cta"> Continue shopping
    @@ -1104,7 +1256,7 @@ defmodule BerrypodWeb.PageRenderer do Please wait while we confirm your payment. This usually takes a few seconds.

    - If this page doesn't update, please <.link patch="/contact" class="checkout-contact-link">contact us. + If this page doesn't update, please <.link patch={R.contact()} class="checkout-contact-link">contact us.

    <% end %> @@ -1129,20 +1281,20 @@ defmodule BerrypodWeb.PageRenderer do

    This link has expired or is invalid.

    - Head back to the <.link patch="/contact">contact page to request a new one. + Head back to the <.link patch={R.contact()}>contact page to request a new one.

    <% assigns[:orders] == [] -> %>

    No orders found for that email address.

    - If something doesn't look right, <.link patch="/contact">get in touch. + If something doesn't look right, <.link patch={R.contact()}>get in touch.

    <% true -> %>
    <%= for order <- assigns[:orders] do %> - <.link patch={"/orders/#{order.order_number}"} class="order-summary-card"> + <.link patch={R.order(order.order_number)} class="order-summary-card">

    {order.order_number}

    @@ -1184,7 +1336,7 @@ defmodule BerrypodWeb.PageRenderer do ~H""" <%= if assigns[:order] do %>
    - <.link patch="/orders" class="order-detail-back">← Back to orders + <.link patch={R.orders()} class="order-detail-back">← Back to orders

    {assigns[:order].order_number}

    {Calendar.strftime(assigns[:order].inserted_at, "%-d %B %Y")}

    @@ -1239,7 +1391,7 @@ defmodule BerrypodWeb.PageRenderer do
    <%= if info && info.slug do %> <.link - patch={"/products/#{info.slug}"} + patch={R.product(info.slug)} class="checkout-item-name checkout-item-link" > {item.product_name} @@ -1286,7 +1438,7 @@ defmodule BerrypodWeb.PageRenderer do <% end %>
    - <.shop_link_button href="/collections/all">Continue shopping + <.shop_link_button href={R.collection("all")}>Continue shopping
    <% end %> """ @@ -1298,7 +1450,7 @@ defmodule BerrypodWeb.PageRenderer do ~H""" <.page_title text="Search" /> - +

    No products found for “{assigns[:search_page_query]}”

    - <.link patch="/collections/all" class="collection-empty-link">Browse all products + <.link patch={R.collection("all")} class="collection-empty-link">Browse all products
    <% end %> <% end %> @@ -1469,7 +1621,7 @@ defmodule BerrypodWeb.PageRenderer do slug = cat |> String.downcase() |> String.replace(" ", "-") [ - %{label: cat, page: "collection", href: "/collections/#{slug}"}, + %{label: cat, page: "collection", href: R.collection(slug)}, %{label: title, current: true} ] end @@ -1480,8 +1632,8 @@ defmodule BerrypodWeb.PageRenderer do defp breadcrumb_items(_), do: [] - defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}" - defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}" + defp collection_path(slug, "featured"), do: R.collection(slug) + defp collection_path(slug, sort), do: R.collection(slug) <> "?sort=#{sort}" # Resolves an image_id to a full URL for blocks that need a single URL (e.g. background-image). defp resolve_block_image_url(image_id) do diff --git a/lib/berrypod_web/plugs/broken_url_tracker.ex b/lib/berrypod_web/plugs/broken_url_tracker.ex index 6084a31..019b347 100644 --- a/lib/berrypod_web/plugs/broken_url_tracker.ex +++ b/lib/berrypod_web/plugs/broken_url_tracker.ex @@ -18,7 +18,7 @@ defmodule BerrypodWeb.Plugs.BrokenUrlTracker do def call(conn, {router, router_opts}) do router.call(conn, router_opts) rescue - e in Phoenix.Router.NoRouteError -> + e in [Phoenix.Router.NoRouteError, BerrypodWeb.NotFoundError] -> unless static_path?(conn.request_path) do prior_hits = Berrypod.Analytics.count_pageviews_for_path(conn.request_path) Berrypod.Redirects.record_broken_url(conn.request_path, prior_hits) diff --git a/test/berrypod/pages_test.exs b/test/berrypod/pages_test.exs index aaa7bd2..eaef418 100644 --- a/test/berrypod/pages_test.exs +++ b/test/berrypod/pages_test.exs @@ -605,6 +605,65 @@ defmodule Berrypod.PagesTest do end end + describe "update_page_url_slug/2" do + test "updates url_slug for system page" do + {:ok, updated} = Pages.update_page_url_slug("about", "our-story") + + assert updated.url_slug == "our-story" + assert updated.slug == "about" + end + + test "creates redirect when url_slug changes" do + {:ok, _} = Pages.update_page_url_slug("about", "our-story") + + assert {:ok, redirect} = Berrypod.Redirects.lookup("/about") + assert redirect.to_path == "/our-story" + end + + test "clears url_slug when set to empty string" do + {:ok, _} = Pages.update_page_url_slug("about", "our-story") + {:ok, cleared} = Pages.update_page_url_slug("about", "") + + assert cleared.url_slug == nil + end + + test "creates system page in DB if it doesn't exist yet" do + # About page hasn't been saved to DB, only exists as defaults + assert Pages.get_page_struct("delivery") == nil + + {:ok, updated} = Pages.update_page_url_slug("delivery", "shipping") + + assert updated.url_slug == "shipping" + assert Pages.get_page_struct("delivery") != nil + end + + test "returns error for non-existent custom page" do + assert {:error, :not_found} = Pages.update_page_url_slug("nope", "anything") + end + + test "deletes stale redirect when new URL becomes live" do + # Create a redirect pointing FROM /my-url + Berrypod.Redirects.create_auto(%{ + from_path: "/my-url", + to_path: "/somewhere-else", + source: "manual" + }) + + # Now make /my-url a live page + {:ok, _} = Pages.update_page_url_slug("about", "my-url") + + # The stale redirect should be gone + assert :not_found = Berrypod.Redirects.lookup("/my-url") + end + + test "invalidates R cache so new URL resolves immediately" do + {:ok, _} = Pages.update_page_url_slug("cart", "basket") + + # R.cart() should now return /basket + assert BerrypodWeb.R.cart() == "/basket" + end + end + describe "duplicate_custom_page/1" do test "creates a draft copy with -copy slug" do {:ok, original} = diff --git a/test/berrypod/site_test.exs b/test/berrypod/site_test.exs index dedcac2..2315bc0 100644 --- a/test/berrypod/site_test.exs +++ b/test/berrypod/site_test.exs @@ -1,5 +1,5 @@ defmodule Berrypod.SiteTest do - use Berrypod.DataCase, async: true + use Berrypod.DataCase, async: false alias Berrypod.Site alias Berrypod.Site.SocialLink @@ -274,4 +274,49 @@ defmodule Berrypod.SiteTest do assert Site.show_newsletter?() == true end end + + describe "nav_items_for_shop/1" do + test "resolves system page URLs through R module" do + # Set a custom URL for the about page + {:ok, _} = Berrypod.Pages.update_page_url_slug("about", "our-story") + + # Create a nav item pointing to /about (the default URL) + {:ok, _} = Site.create_nav_item(%{location: "header", label: "About", url: "/about"}) + + # The resolved URL should use the custom slug + [item] = Site.nav_items_for_shop(:header) + assert item["href"] == "/our-story" + end + + test "resolves home page URL" do + {:ok, _} = Site.create_nav_item(%{location: "header", label: "Home", url: "/"}) + + [item] = Site.nav_items_for_shop(:header) + assert item["href"] == "/" + end + + test "preserves external URLs" do + {:ok, _} = + Site.create_nav_item(%{ + location: "header", + label: "External", + url: "https://example.com" + }) + + [item] = Site.nav_items_for_shop(:header) + assert item["href"] == "https://example.com" + end + + test "preserves special routes like /collections/all" do + {:ok, _} = + Site.create_nav_item(%{ + location: "header", + label: "Shop", + url: "/collections/all" + }) + + [item] = Site.nav_items_for_shop(:header) + assert item["href"] == "/collections/all" + end + end end diff --git a/test/berrypod_web/plugs/broken_url_tracker_test.exs b/test/berrypod_web/plugs/broken_url_tracker_test.exs index 0810f2e..6b60ffd 100644 --- a/test/berrypod_web/plugs/broken_url_tracker_test.exs +++ b/test/berrypod_web/plugs/broken_url_tracker_test.exs @@ -1,18 +1,25 @@ defmodule BerrypodWeb.Plugs.BrokenUrlTrackerTest do use BerrypodWeb.ConnCase, async: true - alias Berrypod.Redirects + import Berrypod.AccountsFixtures + + alias Berrypod.{Redirects, Settings} setup do Redirects.create_table() + # Create admin user so SetupHook allows access + user_fixture() + # Mark site as live so requests aren't redirected to /coming-soon + {:ok, _} = Settings.set_site_live(true) :ok end test "records broken URL on 404", %{conn: conn} do - # Multi-segment path — not caught by the /:slug catch-all route - conn = get(conn, "/zz/nonexistent-path") - - assert conn.status in [404, 500] + # Multi-segment path goes through the catch-all route and raises NotFoundError. + # The BrokenUrlTracker plug catches this and records it before re-raising. + assert_error_sent :not_found, fn -> + get(conn, "/zz/nonexistent-path") + end [broken_url] = Redirects.list_broken_urls() assert broken_url.path == "/zz/nonexistent-path" @@ -20,7 +27,11 @@ defmodule BerrypodWeb.Plugs.BrokenUrlTrackerTest do end test "skips static asset paths", %{conn: conn} do - get(conn, "/assets/missing-file.js") + # Static asset paths should not be recorded as broken URLs. + # These raise NotFoundError but the tracker ignores them. + assert_error_sent :not_found, fn -> + get(conn, "/assets/missing-file.js") + end assert Redirects.list_broken_urls() == [] end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 5ad4d2c..db7b1ee 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -32,9 +32,23 @@ defmodule BerrypodWeb.ConnCase do end setup tags do - Berrypod.DataCase.setup_sandbox(tags) + pid = Berrypod.DataCase.setup_sandbox(tags) Berrypod.Settings.SettingsCache.invalidate_all() - {:ok, conn: Phoenix.ConnTest.build_conn()} + # Clear caches without re-warming from DB (which would bypass sandbox) + BerrypodWeb.R.clear() + Berrypod.Pages.PageCache.invalidate_all() + Berrypod.Redirects.clear_cache() + + # Add sandbox metadata to conn so Phoenix.Ecto.SQL.Sandbox plug + # can allow LiveView processes to access the test's DB connection + metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Berrypod.Repo, pid) + encoded_metadata = Phoenix.Ecto.SQL.Sandbox.encode_metadata(metadata) + + conn = + Phoenix.ConnTest.build_conn() + |> Plug.Conn.put_req_header("user-agent", encoded_metadata) + + {:ok, conn: conn} end @doc """ diff --git a/test/support/data_case.ex b/test/support/data_case.ex index b6f24bd..a01e530 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -30,15 +30,21 @@ defmodule Berrypod.DataCase do setup tags do Berrypod.DataCase.setup_sandbox(tags) Berrypod.Settings.SettingsCache.invalidate_all() + # Clear caches without re-warming from DB (which would bypass sandbox) + BerrypodWeb.R.clear() + Berrypod.Pages.PageCache.invalidate_all() + Berrypod.Redirects.clear_cache() :ok end @doc """ Sets up the sandbox based on the test tags. + Returns the owner pid for use in metadata generation. """ def setup_sandbox(tags) do pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Berrypod.Repo, shared: not tags[:async]) on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + pid end @doc """