diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css
index f245346..6b5abe4 100644
--- a/assets/css/shop/components.css
+++ b/assets/css/shop/components.css
@@ -462,6 +462,29 @@
text-decoration: underline;
}
+ .collection-sort-submit {
+ padding: 0.375rem 1rem;
+ font-size: var(--t-text-small);
+ }
+
+ /* ── Search page ── */
+
+ .search-page-form {
+ display: flex;
+ gap: 0.75rem;
+ margin-bottom: 2rem;
+ max-width: 32rem;
+ }
+
+ .search-page-form input {
+ flex: 1;
+ }
+
+ .search-page-count {
+ color: var(--t-text-secondary);
+ margin-bottom: 1.5rem;
+ }
+
/* ── Breadcrumb ── */
.breadcrumb {
@@ -1414,6 +1437,10 @@
text-decoration: underline;
}
+ .cart-remove-form {
+ display: contents;
+ }
+
/* ── Cart empty state ── */
.cart-empty {
diff --git a/lib/berrypod_web/cart_hook.ex b/lib/berrypod_web/cart_hook.ex
index ffbfd62..c21463a 100644
--- a/lib/berrypod_web/cart_hook.ex
+++ b/lib/berrypod_web/cart_hook.ex
@@ -106,6 +106,34 @@ defmodule BerrypodWeb.CartHook do
{:halt, socket}
end
+ defp handle_cart_event(
+ "update_quantity_form",
+ %{"variant_id" => id, "quantity" => qty_str},
+ socket
+ ) do
+ quantity = String.to_integer(qty_str)
+ cart = Cart.update_quantity(socket.assigns.raw_cart, id, quantity)
+ new_qty = Cart.get_quantity(cart, id)
+
+ socket =
+ socket
+ |> broadcast_and_update(cart)
+ |> assign(:cart_status, "Quantity updated to #{new_qty}")
+
+ {:halt, socket}
+ end
+
+ defp handle_cart_event("remove_item_form", %{"variant_id" => id}, socket) do
+ cart = Cart.remove_item(socket.assigns.raw_cart, id)
+
+ socket =
+ socket
+ |> broadcast_and_update(cart)
+ |> assign(:cart_status, "Item removed from cart")
+
+ {:halt, socket}
+ end
+
defp handle_cart_event(_event, _params, socket), do: {:cont, socket}
# Shared info handlers
diff --git a/lib/berrypod_web/components/page_templates/pdp.html.heex b/lib/berrypod_web/components/page_templates/pdp.html.heex
index d96b780..7e82d79 100644
--- a/lib/berrypod_web/components/page_templates/pdp.html.heex
+++ b/lib/berrypod_web/components/page_templates/pdp.html.heex
@@ -25,26 +25,36 @@
<.product_info product={@product} display_price={@display_price} />
- <%!-- Dynamic variant selectors --%>
- <%= for option_type <- @option_types do %>
- <.variant_selector
- option_type={option_type}
- selected={@selected_options[option_type.name]}
- available={@available_options[option_type.name] || []}
- mode={@mode}
+
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
<.product_details product={@product} />
diff --git a/lib/berrypod_web/components/shop_components/cart.ex b/lib/berrypod_web/components/shop_components/cart.ex
index 457dbbf..9db0c71 100644
--- a/lib/berrypod_web/components/shop_components/cart.ex
+++ b/lib/berrypod_web/components/shop_components/cart.ex
@@ -213,11 +213,18 @@ defmodule BerrypodWeb.ShopComponents.Cart do
<%= if @show_quantity_controls do %>
-
+
+
<% else %>
Qty: {@item.quantity}
@@ -306,15 +313,17 @@ defmodule BerrypodWeb.ShopComponents.Cart do
def cart_remove_button(assigns) do
~H"""
-
+
"""
end
diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex
index 5ebae71..52086e8 100644
--- a/lib/berrypod_web/components/shop_components/layout.ex
+++ b/lib/berrypod_web/components/shop_components/layout.ex
@@ -755,10 +755,10 @@ defmodule BerrypodWeb.ShopComponents.Layout do
/>
-
-
+
"""
diff --git a/lib/berrypod_web/components/shop_components/product.ex b/lib/berrypod_web/components/shop_components/product.ex
index 9566d7c..25a3f69 100644
--- a/lib/berrypod_web/components/shop_components/product.ex
+++ b/lib/berrypod_web/components/shop_components/product.ex
@@ -1525,8 +1525,8 @@ defmodule BerrypodWeb.ShopComponents.Product do
~H"""
"""
diff --git a/lib/berrypod_web/live/shop/search.ex b/lib/berrypod_web/live/shop/search.ex
new file mode 100644
index 0000000..aac8597
--- /dev/null
+++ b/lib/berrypod_web/live/shop/search.ex
@@ -0,0 +1,75 @@
+defmodule BerrypodWeb.Shop.Search do
+ use BerrypodWeb, :live_view
+
+ alias Berrypod.Search
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, :page_title, "Search")}
+ end
+
+ @impl true
+ def handle_params(params, _uri, socket) do
+ query = params["q"] || ""
+ results = if query != "", do: Search.search(query), else: []
+
+ {:noreply,
+ socket
+ |> assign(:search_page_query, query)
+ |> assign(:search_page_results, results)}
+ end
+
+ @impl true
+ def handle_event("search_submit", %{"q" => query}, socket) do
+ {:noreply, push_patch(socket, to: ~p"/search?q=#{query}")}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+ <.shop_layout {layout_assigns(assigns)} active_page="search">
+
+ <.page_title text="Search" />
+
+
+
+ <%= if @search_page_results != [] do %>
+
+ {length(@search_page_results)} {if length(@search_page_results) == 1,
+ do: "result",
+ else: "results"} for "{@search_page_query}"
+
+ <.product_grid theme_settings={@theme_settings}>
+ <%= for product <- @search_page_results do %>
+ <.product_card
+ product={product}
+ theme_settings={@theme_settings}
+ mode={@mode}
+ variant={:default}
+ />
+ <% end %>
+
+ <% else %>
+ <%= if @search_page_query != "" do %>
+
+
No products found for "{@search_page_query}"
+ <.link navigate="/collections/all" class="collection-empty-link">
+ Browse all products
+
+
+ <% end %>
+ <% end %>
+
+
+ """
+ end
+end
diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex
index 328a221..dfcf9d2 100644
--- a/lib/berrypod_web/router.ex
+++ b/lib/berrypod_web/router.ex
@@ -81,6 +81,7 @@ defmodule BerrypodWeb.Router do
live "/collections/:slug", Shop.Collection, :show
live "/products/:id", Shop.ProductShow, :show
live "/cart", Shop.Cart, :index
+ live "/search", Shop.Search, :index
live "/checkout/success", Shop.CheckoutSuccess, :show
live "/orders", Shop.Orders, :index
live "/orders/:order_number", Shop.OrderDetail, :show
@@ -88,6 +89,11 @@ defmodule BerrypodWeb.Router do
# Checkout (POST — creates Stripe session and redirects)
post "/checkout", CheckoutController, :create
+
+ # Cart form actions (no-JS fallbacks for LiveView cart events)
+ post "/cart/add", CartController, :add
+ post "/cart/remove", CartController, :remove
+ post "/cart/update", CartController, :update_item
end
# Health check (no auth, no theme loading — for load balancers and uptime monitors)
diff --git a/test/berrypod/analytics/retention_worker_test.exs b/test/berrypod/analytics/retention_worker_test.exs
index 020bd8b..44b033f 100644
--- a/test/berrypod/analytics/retention_worker_test.exs
+++ b/test/berrypod/analytics/retention_worker_test.exs
@@ -4,6 +4,11 @@ defmodule Berrypod.Analytics.RetentionWorkerTest do
alias Berrypod.Analytics.{Event, RetentionWorker}
alias Berrypod.Repo
+ setup do
+ Repo.delete_all(Event)
+ :ok
+ end
+
test "deletes events older than 12 months" do
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.truncate(:second)
diff --git a/test/berrypod_web/controllers/cart_controller_test.exs b/test/berrypod_web/controllers/cart_controller_test.exs
new file mode 100644
index 0000000..b6839d7
--- /dev/null
+++ b/test/berrypod_web/controllers/cart_controller_test.exs
@@ -0,0 +1,111 @@
+defmodule BerrypodWeb.CartControllerTest do
+ use BerrypodWeb.ConnCase, async: false
+
+ import Berrypod.AccountsFixtures
+
+ alias Berrypod.ProductsFixtures
+
+ setup do
+ user_fixture()
+ {:ok, _} = Berrypod.Settings.set_site_live(true)
+ :ok
+ end
+
+ defp create_variant(_context) do
+ product = ProductsFixtures.complete_product_fixture(%{title: "Test Print"})
+ variant = List.first(product.variants)
+ %{product: product, variant: variant}
+ end
+
+ defp conn_with_cart(conn, variant_id, qty) do
+ Phoenix.ConnTest.init_test_session(conn, %{"cart" => [{variant_id, qty}]})
+ end
+
+ describe "POST /cart/add" do
+ setup [:create_variant]
+
+ test "adds item to session cart and redirects to /cart", %{conn: conn, variant: variant} do
+ conn = post(conn, ~p"/cart/add", %{"variant_id" => variant.id, "quantity" => "1"})
+
+ assert redirected_to(conn) == "/cart"
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Added to basket"
+
+ cart = get_session(conn, "cart")
+ assert [{variant_id, 1}] = cart
+ assert variant_id == variant.id
+ end
+
+ test "increments quantity if item already in cart", %{conn: conn, variant: variant} do
+ conn =
+ conn
+ |> conn_with_cart(variant.id, 2)
+ |> post(~p"/cart/add", %{"variant_id" => variant.id, "quantity" => "3"})
+
+ cart = get_session(conn, "cart")
+ assert [{_, 5}] = cart
+ end
+
+ test "defaults quantity to 1 for invalid values", %{conn: conn, variant: variant} do
+ conn = post(conn, ~p"/cart/add", %{"variant_id" => variant.id, "quantity" => "abc"})
+
+ cart = get_session(conn, "cart")
+ assert [{_, 1}] = cart
+ end
+
+ test "handles missing params gracefully", %{conn: conn} do
+ conn = post(conn, ~p"/cart/add", %{})
+
+ assert redirected_to(conn) == "/cart"
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Could not add item to basket"
+ end
+ end
+
+ describe "POST /cart/remove" do
+ setup [:create_variant]
+
+ test "removes item from session cart", %{conn: conn, variant: variant} do
+ conn =
+ conn
+ |> conn_with_cart(variant.id, 2)
+ |> post(~p"/cart/remove", %{"variant_id" => variant.id})
+
+ assert redirected_to(conn) == "/cart"
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Removed from basket"
+
+ cart = get_session(conn, "cart")
+ assert cart == []
+ end
+
+ test "handles removing non-existent item", %{conn: conn} do
+ conn = post(conn, ~p"/cart/remove", %{"variant_id" => Ecto.UUID.generate()})
+
+ assert redirected_to(conn) == "/cart"
+ end
+ end
+
+ describe "POST /cart/update" do
+ setup [:create_variant]
+
+ test "updates quantity in session cart", %{conn: conn, variant: variant} do
+ conn =
+ conn
+ |> conn_with_cart(variant.id, 1)
+ |> post(~p"/cart/update", %{"variant_id" => variant.id, "quantity" => "5"})
+
+ assert redirected_to(conn) == "/cart"
+
+ cart = get_session(conn, "cart")
+ assert [{_, 5}] = cart
+ end
+
+ test "removes item when quantity is 0 or less", %{conn: conn, variant: variant} do
+ conn =
+ conn
+ |> conn_with_cart(variant.id, 3)
+ |> post(~p"/cart/update", %{"variant_id" => variant.id, "quantity" => "0"})
+
+ cart = get_session(conn, "cart")
+ assert cart == []
+ end
+ end
+end
diff --git a/test/berrypod_web/live/admin/analytics_test.exs b/test/berrypod_web/live/admin/analytics_test.exs
index 323d6a5..0038a36 100644
--- a/test/berrypod_web/live/admin/analytics_test.exs
+++ b/test/berrypod_web/live/admin/analytics_test.exs
@@ -10,6 +10,7 @@ defmodule BerrypodWeb.Admin.AnalyticsTest do
setup do
send(Buffer, :flush)
:timer.sleep(50)
+ Repo.delete_all(Event)
user = user_fixture()
%{user: user}
diff --git a/test/berrypod_web/live/shop/cart_test.exs b/test/berrypod_web/live/shop/cart_test.exs
index be38b18..2361722 100644
--- a/test/berrypod_web/live/shop/cart_test.exs
+++ b/test/berrypod_web/live/shop/cart_test.exs
@@ -68,41 +68,39 @@ defmodule BerrypodWeb.Shop.CartTest do
test "incrementing quantity updates the display", %{
conn: conn,
- product: product,
variant: variant
} do
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
html =
view
- |> element("#main-content button[aria-label='Increase quantity of #{product.title}']")
- |> render_click()
+ |> form("#main-content form[phx-submit='update_quantity_form']")
+ |> render_submit(%{"quantity" => "2"})
assert html =~ "Quantity updated to 2"
end
test "decrementing to zero removes the item", %{
conn: conn,
- product: product,
variant: variant
} do
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
html =
view
- |> element("#main-content button[aria-label='Decrease quantity of #{product.title}']")
- |> render_click()
+ |> form("#main-content form[phx-submit='update_quantity_form']")
+ |> render_submit(%{"quantity" => "0"})
assert html =~ "Your basket is empty"
end
- test "remove button removes the item", %{conn: conn, product: product, variant: variant} do
+ test "remove button removes the item", %{conn: conn, variant: variant} do
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
html =
view
- |> element("#main-content button[aria-label='Remove #{product.title} from cart']")
- |> render_click()
+ |> form("#main-content form[phx-submit='remove_item_form']")
+ |> render_submit()
assert html =~ "Your basket is empty"
end
diff --git a/test/berrypod_web/live/shop/product_show_test.exs b/test/berrypod_web/live/shop/product_show_test.exs
index 296401f..4fd78e8 100644
--- a/test/berrypod_web/live/shop/product_show_test.exs
+++ b/test/berrypod_web/live/shop/product_show_test.exs
@@ -292,11 +292,11 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
view |> element("button[phx-click='increment_quantity']") |> render_click()
view |> element("button[phx-click='increment_quantity']") |> render_click()
- # Add to cart
+ # Add to cart via form submit
html =
view
- |> element("button", "Add to basket")
- |> render_click()
+ |> form("form[phx-submit='add_to_cart']")
+ |> render_submit()
# Decrement should be disabled again (quantity reset to 1)
assert html =~ ~s(phx-click="decrement_quantity")
@@ -310,8 +310,8 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
html =
view
- |> element("button", "Add to basket")
- |> render_click()
+ |> form("form[phx-submit='add_to_cart']")
+ |> render_submit()
assert html =~ "added to cart"
end
@@ -321,8 +321,8 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
html =
view
- |> element("button", "Add to basket")
- |> render_click()
+ |> form("form[phx-submit='add_to_cart']")
+ |> render_submit()
assert html =~ "Mountain Sunrise Print"
end
diff --git a/test/berrypod_web/live/shop/search_page_test.exs b/test/berrypod_web/live/shop/search_page_test.exs
new file mode 100644
index 0000000..0106b3c
--- /dev/null
+++ b/test/berrypod_web/live/shop/search_page_test.exs
@@ -0,0 +1,57 @@
+defmodule BerrypodWeb.Shop.SearchPageTest do
+ use BerrypodWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+ import Berrypod.AccountsFixtures
+
+ alias Berrypod.ProductsFixtures
+
+ setup do
+ user_fixture()
+ {:ok, _} = Berrypod.Settings.set_site_live(true)
+ :ok
+ end
+
+ describe "GET /search" do
+ test "renders search page with empty query", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/search")
+
+ assert html =~ "Search"
+ assert html =~ ~s(placeholder="Search products...")
+ end
+
+ test "renders search results for a matching query", %{conn: conn} do
+ product =
+ ProductsFixtures.complete_product_fixture(%{title: "Blue Mountain Print"})
+
+ Berrypod.Search.index_product(product)
+
+ {:ok, _view, html} = live(conn, ~p"/search?q=mountain")
+
+ assert html =~ "Blue Mountain Print"
+ assert html =~ "1 result"
+ end
+
+ test "renders no-results message for unmatched query", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/search?q=nonexistent")
+
+ assert html =~ "No products found"
+ end
+
+ test "search form submits via LiveView and updates URL", %{conn: conn} do
+ product =
+ ProductsFixtures.complete_product_fixture(%{title: "Red Sunset Poster"})
+
+ Berrypod.Search.index_product(product)
+
+ {:ok, view, _html} = live(conn, ~p"/search")
+
+ html =
+ view
+ |> form(".search-page-form", %{"q" => "sunset"})
+ |> render_submit()
+
+ assert html =~ "Red Sunset Poster"
+ end
+ end
+end