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:
parent
c115f08cb8
commit
a41771efc8
@ -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 {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.")}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,43 +955,127 @@ 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
|
||||||
# Normalize checkbox fields (unchecked checkboxes aren't sent)
|
page && page.type == "custom" ->
|
||||||
params =
|
save_custom_page_settings(socket, page, params)
|
||||||
params
|
|
||||||
|> Map.put_new("published", "false")
|
|
||||||
|> Map.put_new("show_in_nav", "false")
|
|
||||||
|
|
||||||
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)
|
# System pages: only save url_slug (other fields are read-only)
|
||||||
page_struct = Pages.get_page_struct(page.slug)
|
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
|
true ->
|
||||||
{:ok, updated_page} ->
|
{:halt, socket}
|
||||||
# Reinitialize form from saved page
|
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 =
|
||||||
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)
|
||||||
|
|
||||||
# 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}
|
{:halt, socket}
|
||||||
|
|
||||||
{:error, _changeset} ->
|
{:error, _reason} ->
|
||||||
socket = assign(socket, :settings_save_status, :error)
|
socket = assign(socket, :settings_save_status, :error)
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
end
|
end
|
||||||
@ -999,9 +1084,6 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Catch-all for unknown settings actions
|
|
||||||
defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
|
|
||||||
|
|
||||||
# --- Site tab event handlers ---
|
# --- Site tab event handlers ---
|
||||||
|
|
||||||
defp handle_site_action("update", %{"site" => site_params}, socket) do
|
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
|
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
|
||||||
|
|||||||
@ -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">← Back to orders</.link>
|
<.link patch={R.orders()} class="order-detail-back">← 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 “{assigns[:search_page_query]}”</p>
|
<p>No products found for “{assigns[:search_page_query]}”</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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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} =
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
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() == []
|
assert Redirects.list_broken_urls() == []
|
||||||
end
|
end
|
||||||
|
|||||||
@ -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 """
|
||||||
|
|||||||
@ -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 """
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user